Compare commits

..

51 Commits

Author SHA1 Message Date
kangfenmao
60e3431b36 chore(version): 1.4.11 2025-07-11 12:18:38 +08:00
kangfenmao
84a6c2da59 feat: add HTML code detection utility and integrate into CodeBlockView
- Introduced `isHtmlCode` function to identify HTML content based on DOCTYPE and tag presence.
- Updated `CodeBlockView` to utilize `isHtmlCode` for conditional rendering of HTML artifacts.
- Added comprehensive tests for `isHtmlCode` to ensure accurate detection of HTML structures.
2025-07-11 12:16:44 +08:00
kangfenmao
5b9ff3053b refactor: update styles and layout in markdown and message components
- Removed unnecessary letter and word spacing in markdown styles.
- Adjusted padding in Inputbar for improved layout.
- Modified margin properties in CitationsList and Message components for consistency.
- Enhanced MessageHeader logic to conditionally hide based on message type.
- Updated icon sizes in MessageMenubar for better alignment.
- Added margin adjustments in ThinkingBlock for improved spacing.
2025-07-11 11:33:20 +08:00
SuYao
8340922263 fix: smartblock update not persist to db (#8046)
* chore(version): 1.4.10

* feat: enhance ThinkingTagExtractionMiddleware and update smartBlockUpdate function

- Added support for THINKING_START and TEXT_START chunk types in ThinkingTagExtractionMiddleware.
- Updated smartBlockUpdate function to include an isComplete parameter for better block state management.
- Ensured proper handling of block updates based on completion status across various message types.

* fix: refine block update logic in messageThunk

- Adjusted conditions for canceling throttled block updates based on block type changes and completion status.
- Improved handling of block updates to ensure accurate state management during message processing.

* chore: add comment

* fix: update message block status handling

- Changed the status of image blocks from STREAMING to PENDING to better reflect the processing state.
- Refined logic in OpenAIResponseAPIClient to ensure user messages are correctly handled based on assistant message content.
- Improved rendering conditions in ImageBlock component for better user experience during image loading.

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-07-11 11:33:05 +08:00
one
a93cab6b43 fix(CodePreview): revert to absolute positioning (#7980)
* fix(CodePreview): revert to absolute positioning

* fix: add min width to codeblockview
2025-07-11 11:06:21 +08:00
one
9a81c400ab fix: sticky code toolbar (#8012)
fix: sticky code toolbar for single-model message
2025-07-11 11:05:08 +08:00
西街工坊
808a22d5c6 fix(Doc2xPreprocessProvider): replace filePath split with path.parse… (#8042) 2025-07-11 11:03:05 +08:00
kangfenmao
10e512f32e chore(version): 1.4.10 2025-07-10 23:31:03 +08:00
one
4d75515bd6 refactor: raise the max count of document chunks from 30 to 50 (#7863)
* refactor: raise the max count of document chunks from 30 to 70

- Raise the max count of document chunks count
- Update i18n for websearch rag for consistency

* refactor: lower the count to 50
2025-07-10 22:52:18 +08:00
kangfenmao
3d6c84de6d refactor: improve styling and layout in MessageTools and Prompt components
- Adjusted spacing and border styles in MessageTools for better alignment.
- Updated margin and border properties in Prompt for consistent UI.
- Enhanced background color handling in ToolContentWrapper based on status.
2025-07-10 22:36:48 +08:00
SuYao
3dd393b840 fix: azure-openai (#7978) 2025-07-10 22:17:20 +08:00
LiuVaayne
8f86c53941 feat: implement MCP tool auto-approve functionality (#8007)
*  feat: implement MCP tool auto-approve functionality

- Add auto-approve toggle for MCP tools in settings
- Add improved UI for tool approval with Run/Cancel/Auto-approve buttons
- Add internationalization support for tool approval interface
- Update tool confirmation logic to support auto-approved tools
- Enhance tool status indicators and button styling
- Add disabledAutoApproveTools configuration for MCP servers

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: use table for mcp tools setting

* refactor: improve styles, add missing i18n

* refactor: extract renderStatusIndicator, reuse colors

* refactor: simplify the table

* feat: auto approve same tool in a turn

* feat(i18n): add confirmation tooltip for auto-approve tool in multiple languages

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: one <wangan.cs@gmail.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Teo <cheesen.xu@gmail.com>
2025-07-10 22:14:52 +08:00
Phantom
a7b78c547a fix(encoding): encoding detection and decoding logic (#8024) 2025-07-10 22:13:40 +08:00
Chen Tao
bcc1046cdf feat: add upload file (#8035) 2025-07-10 22:06:09 +08:00
Phantom
c05c06b7a1 fix: VoyageEmbeddings (#8034)
* fix(embeddings): 修复VoyageAI嵌入格式和模型验证错误

修复OpenAIBaseClient中VoyageAI提供商的embedding格式设置问题
完善VoyageEmbeddings模型验证的错误提示信息

* refactor(embeddings): 移除VoyageEmbeddings的模型维度限制检查

简化VoyageEmbeddings的创建逻辑,不再对支持的模型维度进行校验

* fix(embeddings): 修复VoyageEmbeddings模型维度设置问题

修复VoyageEmbeddings中未正确校验模型是否支持设置outputDimension的问题
当provider为voyageai且模型不支持设置dimensions时,自动忽略传入的dimensions参数

* refactor(embeddings): 集中管理支持设置维度的模型列表

将各嵌入模型支持设置维度的模型列表集中到utils模块
不再让VoyageEmbeddings中getDimensions抛出错误,而是自动修复
2025-07-10 21:53:37 +08:00
Phantom
446ebae175 feat(ui): better infinite context (#8021)
* feat(上下文): 添加最大上下文数量限制及显示组件

- 在常量配置中添加 MAX_CONTEXT_COUNT
- 创建 MaxContextCount 组件用于显示无限上下文标识
- 在相关组件中替换硬编码的上下文最大值
- 优化 TokenCount 组件的上下文计数显示样式

* refactor(常量): 添加UNLIMITED_CONTEXT_COUNT常量并替换硬编码值

使用UNLIMITED_CONTEXT_COUNT常量替代多处硬编码的100000值,提高代码可维护性

* refactor(Inputbar): 使用 SlashSeparatorSpan 组件替换内联样式

将 TokenCount.tsx 中的斜杠分隔符内联样式替换为 SlashSeparatorSpan 组件,提高代码可维护性

* fix: 为 InfinityIcon 添加 aria-label 并统一样式
2025-07-10 21:51:31 +08:00
one
ba742b7b1f feat: save to knowledge (#7528)
* feat: save to knowledge

* refactor: simplify checkbox

* feat(i18n): add 'Save to Local File' translation key for multiple languages

---------

Co-authored-by: suyao <sy20010504@gmail.com>
2025-07-10 21:34:01 +08:00
fullex
7c6db809bb fix(SelectionAssistant): [macOS] show actionWindow on fullscreen app (#8004)
* feat(SelectionService): enhance action window handling for macOS fullscreen mode

- Updated processAction and showActionWindow methods to support fullscreen mode on macOS.
- Added isFullScreen parameter to manage action window visibility and positioning.
- Improved action window positioning logic to ensure it remains within screen boundaries.
- Adjusted IPC channel to pass fullscreen state from the renderer to the service.
- Updated SelectionToolbar to track fullscreen state and pass it to the action processing function.

* chore(deps): update selection-hook to version 1.0.6 in package.json and yarn.lock

* fix(SelectionService): improve macOS fullscreen handling and action window focus

- Added app import to manage dock visibility on macOS.
- Enhanced fullscreen handling logic to ensure the dock icon is restored correctly.
- Updated action window focus behavior to prevent unintended hiding when blurred.
- Refactored SelectionActionApp to streamline auto pinning logic and remove redundant useEffect.
- Cleaned up SelectionToolbar by removing unnecessary window size updates when demo is false.

* refactor(SelectionService): remove commented-out code for clarity

* refactor(SelectionService): streamline macOS handling and improve code clarity
2025-07-10 20:41:01 +08:00
Konv Suu
855499681f feat: add confirm for unsaved content in creating agent (#7965) 2025-07-10 19:37:18 +08:00
one
92be3c0f56 chore: update vscode settings (#7974)
* chore: update vscode settings

* refactor: add editorconfig to extensions
2025-07-10 19:34:57 +08:00
one
2a72f391b7 feat: codeblock dot language (#6783)
* feat(CodeBlock): support dot language in code block

- render DOT using @viz-js/viz
- highlight DOT using @viz-js/lang-dot (CodeEditor only)
- extract a special view map, update file structure
- extract and reuse the PreviewError component across special views
- update dependencies, fix peer dependencies

* chore: prepare for merge
2025-07-10 19:32:51 +08:00
SuYao
db642f0837 feat(models): support Grok4 (#8032)
refactor(models): rename and enhance reasoning model functions for clarity and functionality
2025-07-10 19:27:53 +08:00
kangfenmao
fca93b6c51 style: update various component styles for improved layout and readability
- Adjusted color for list items in color.scss for better contrast.
- Modified line-height and margins in markdown.scss for improved text readability.
- Changed height property in FloatingSidebar.tsx for consistent layout.
- Increased padding in AgentsPage.tsx for better spacing.
- Updated padding and border-radius in Inputbar.tsx for enhanced aesthetics.
- Reduced margin in MessageHeader.tsx for tighter layout.
- Refactored GroupTitle styles in AssistantsTab.tsx for better alignment and spacing.
2025-07-10 18:59:00 +08:00
one
7e672d86e7 refactor: do not jump on enabling content search (#7922)
* fix: content search count on enable

* refactor(ContentSearch): do not jump on enabling content search

* refactor: simplify result count
2025-07-10 17:29:43 +08:00
SuYao
e9112cad0f fix(McpToolChunkMiddleware): add logging for tool calls and enhance l… (#8028)
fix(McpToolChunkMiddleware): add logging for tool calls and enhance lookup logic
2025-07-10 17:26:57 +08:00
one
ffbd6445df refactor(Inputbar): make button tooltips disappear faster (#8011) 2025-07-10 17:26:38 +08:00
Alaina Hardie
dff44f2721 Fix: Require typechecking for Mac and Linux target builds (#7219)
fix: Mac builds do not auto-run typecheck, but Windows builds do. This requires an extra manual step when building for Mac.

Update build scripts in package.json to use `npm run build` directly for Mac and Linux targets..
2025-07-10 17:01:31 +08:00
SuYao
3afa81eb5d fix(Anthropic): content truncation (#7942)
* fix(Anthropic): content truncation

* feat: add start event and fix content truncation

* fix (gemini): some event

* revert: index.tsx

* revert(messageThunk): error block

* fix: ci

* chore: unuse log
2025-07-10 16:58:35 +08:00
SuYao
3350c3e2e5 fix(GeminiAPIClient, mcp-tools): enhance tool call handling and lookup logic (#8009)
* fix(GeminiAPIClient, mcp-tools): enhance tool call handling and lookup logic

* fix: unuse log
2025-07-10 15:16:23 +08:00
SuYao
f85f46c248 fix(middleware): ollama qwen think (#8026)
refactor(AiProvider): comment out unnecessary middleware removal for performance optimization

- Commented out the removal of ThinkingTagExtractionMiddlewareName to prevent potential performance degradation while maintaining existing functionality.
- Retained the removal of ThinkChunkMiddlewareName as part of the existing logic for non-reasoning scenarios.
2025-07-10 15:15:38 +08:00
SuYao
05f3b88f30 fix(Inputbar): update resizeTextArea call to improve functionality (#8010) 2025-07-10 15:15:13 +08:00
自由的世界人
f8c6b5c05f Fix translation key for unlimited backups label (#7987)
Updated the translation key for the 'unlimited' backups option in WebDavSettings to use the correct namespace.
2025-07-10 15:09:59 +08:00
Jason Young
97dbfe492e test: enhance download and fetch utility test coverage with bug fix (#7891)
* test: enhance download and fetch utility test coverage

- Add MIME type handling tests for data URLs in download.test.ts
- Add timestamp generation tests for blob and network downloads
- Add Content-Type header handling test for extensionless files
- Add format parameter tests (markdown/html/text) for fetchWebContent
- Add timeout signal handling tests for fetch operations
- Add combined signal (user + timeout) test for AbortSignal.any

These tests improve coverage of edge cases and ensure critical functionality
is properly tested.

* fix: add missing error handling for fetch in download utility

- Add .catch() handler for network request failures in download()
- Use window.message.error() for user-friendly error notifications
- Update tests to verify error handling behavior
- Ensure proper error messages are shown to users

This fixes a missing error handler that was discovered during test development.

* refactor: improve test structure and add i18n support for download utility

- Unified test structure with two-layer describe blocks (filename -> function name)
- Added afterEach with restoreAllMocks for consistent mock cleanup
- Removed individual mockRestore calls in favor of centralized cleanup
- Added i18n support to download.ts for error messages
- Updated error handling logic to avoid duplicate messages
- Updated test expectations to match new i18n error messages

* test: fix react-i18next mock for Markdown test

Add missing initReactI18next to mock to resolve test failures caused by i18n initialization when download utility imports i18n module.
2025-07-10 14:35:40 +08:00
kangfenmao
186f0ed06f feat(MCPSettings): enhance MCP server management and localization
- Added BuiltinMCPServersSection and McpResourcesSection components to display available MCP servers and resources.
- Updated navigation logic to redirect users to the MCP settings upon adding a server.
- Enhanced localization by adding new keys for built-in servers in multiple languages.
- Improved the SettingsPage layout by reordering menu items for better accessibility.
2025-07-10 12:39:01 +08:00
kangfenmao
daf134f331 refactor(HtmlArtifacts): enhance HTML validation and rendering logic
- Added checks for complete HTML documents based on presence of critical tags.
- Updated unmatched tag detection to include a comprehensive list of HTML5 void elements.
- Improved HTML content rendering with a fixed interval update mechanism.
- Adjusted modal header styles for better layout consistency.
- Enabled editing capabilities in the CodeEditor component for HTML content.
2025-07-10 12:28:25 +08:00
kangfenmao
3f7f78da15 fix(release.yml): add missing environment variables for build jobs 2025-07-10 10:49:10 +08:00
kangfenmao
1d289621fc style(markdown): enhance typography and spacing for improved readability
- Increased line height and adjusted margins for headers and paragraphs to enhance text clarity.
- Added letter and word spacing for better text presentation.
- Updated blockquote and table styles for a more visually appealing layout.
- Improved hover effect for table rows to enhance user interaction.
2025-07-10 10:49:10 +08:00
kangfenmao
d7002cda11 refactor: quick panel remove multi-select mode 2025-07-10 02:45:41 +08:00
kangfenmao
559fcecf77 refactor(CodeBlockView): replace HtmlArtifacts component with HtmlArtifactsCard
- Removed the obsolete HtmlArtifacts component and its associated logic.
- Introduced the new HtmlArtifactsCard component to enhance the rendering of HTML artifacts.
- Updated the CodeBlockView to utilize HtmlArtifactsCard, improving maintainability and user experience.
- Added a new HtmlArtifactsPopup component for better HTML content preview and editing capabilities.
- Enhanced localization by adding translation keys for HTML artifacts in multiple languages.
2025-07-10 02:45:32 +08:00
kangfenmao
1d854c232e refactor(Messages): update message styling and structure for improved clarity
- Simplified the message header and footer components by removing unnecessary props and logic.
- Adjusted the message container styles for better alignment and spacing.
- Enhanced the message tokens display logic and corrected the component name for consistency.
- Removed unused translation keys related to token usage from multiple language files to streamline localization.
2025-07-10 02:45:32 +08:00
kangfenmao
8c6684cbdf refactor(WebSearchButton): simplify web search button logic and improve tooltip behavior
- Removed unused imports and streamlined the logic for enabling web search.
- Updated the tooltip title to reflect the current state of web search functionality.
- Enhanced the handling of quick panel opening based on the assistant's web search settings.
2025-07-10 02:45:32 +08:00
kangfenmao
c7ab71f01f refactor(OpenAISettingsGroup): simplify component structure and remove styled components
- Removed unused imports and the StyledSelect component, replacing it with a standard Selector for improved clarity.
- Streamlined the layout by eliminating unnecessary styles, enhancing maintainability and readability of the code.
2025-07-10 00:42:36 +08:00
SuYao
9b57351d1e fix(McpToolChunkMiddleware): enhance tool call confirmation logic (#8005)
* fix(McpToolChunkMiddleware): enhance tool call confirmation logic

- Added additional condition to confirm tool calls by checking the toolCallId in the confirmed object.
- Included a console log for confirmed tool calls to aid in debugging and tracking tool call execution.

* chore: unuse log
2025-07-09 23:39:58 +08:00
kangfenmao
f9e88fb6ee refactor(Navbar): remove MinAppsPopover component
- Deleted the MinAppsPopover component to streamline the Navbar.
- Updated Navbar to remove references to MinAppsPopover, enhancing code maintainability.
2025-07-09 19:32:36 +08:00
kangfenmao
074ba0ae05 feat(i18n): add "Open Logs" button translations for multiple languages
- Introduced new translation keys for the "Open Logs" button in various languages (en-us, ja-jp, ru-ru, zh-cn, zh-tw, el-gr, es-es, fr-fr, pt-pt).
- Updated the DataSettings component to include a button for opening application logs, enhancing user accessibility to log files.
2025-07-09 19:22:57 +08:00
kangfenmao
4a8a5e8428 feat(i18n): enhance localization for GitHub Copilot settings
- Added new translation keys for error messages and steps in the GitHub Copilot authentication process across multiple languages (en-us, ja-jp, ru-ru, zh-cn, zh-tw).
- Updated the GitHubCopilotSettings component to reflect the new steps for user guidance during the authentication process.
- Improved user experience by providing detailed descriptions and success/error messages related to the authorization flow.
2025-07-09 19:07:32 +08:00
kangfenmao
f7fa665f3a feat(CustomHeaderPopup): add custom headers management for providers
- Introduced a new CustomHeaderPopup component for managing extra headers for providers.
- Integrated the popup into the ProviderSetting component, allowing users to edit headers via a modal.
- Refactored ApiKeyListPopup to use a styled container for improved layout.
2025-07-09 18:15:42 +08:00
beyondkmp
e273ddcfb0 fix(LocalBackupSettings): update input and select styles for better responsiveness (#7977)
refactor(LocalBackupSettings): update input and select styles for better responsiveness

- Adjusted the input field to have a flexible width between 200 and 400 pixels.
- Modified select components to use a minimum width of 120 pixels for improved layout consistency.
- Enhanced onChange handlers for select components to ensure proper value handling.
2025-07-09 18:05:32 +08:00
kangfenmao
41d3a1fd55 refactor(SettingsPage): reorder menu items for improved organization 2025-07-09 17:50:57 +08:00
kangfenmao
7237ba34db docs: short i18n keys 2025-07-09 17:26:42 +08:00
Phantom
fbf89b3f0a fix(translate): prevent translation from being triggered unexpectedly during IME composition (#7968)
fix(translate): 修复在输入法组合文字时意外触发翻译的问题
2025-07-09 13:46:39 +08:00
149 changed files with 13065 additions and 9546 deletions

View File

@@ -77,9 +77,10 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Mac
if: matrix.os == 'macos-latest'
@@ -93,10 +94,11 @@ jobs:
APPLE_ID: ${{ vars.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Windows
if: matrix.os == 'windows-latest'
@@ -105,9 +107,10 @@ jobs:
yarn build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Release
uses: ncipollo/release-action@v1

View File

@@ -1,3 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "editorconfig.editorconfig"]
}

View File

@@ -4,6 +4,7 @@
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
"files.eol": "\n",
"search.exclude": {
"**/dist/**": true,
".yarn/releases/**": true

View File

@@ -117,9 +117,8 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
服务商:新增 NewAPI 服务商支持
绘图:新增 NewAPI 绘图服务商支持
备份:支持 s3 兼容存储备份
服务商:支持多个密钥管理,支持配置自定义请求头
设置:支持禁用硬件加速
其他:性能优化和错误改进
• [新增] MCP 工具调用自动审批流程
• [优化] 输入框快捷弹窗多选交互支持
• [新增] 网页内容生成实时预览功能
• [支持] Grok-4 大语言模型接入
• [修复] Anthropic 模型输出截断缺陷

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.4.9",
"version": "1.4.11",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -27,12 +27,12 @@
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
"build:mac": "dotenv electron-vite build && electron-builder --mac --arm64 --x64",
"build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
"build:linux": "dotenv electron-vite build && electron-builder --linux --x64 --arm64",
"build:linux:arm64": "dotenv electron-vite build && electron-builder --linux --arm64",
"build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64",
"build:mac": "dotenv npm run build && electron-builder --mac --arm64 --x64",
"build:mac:arm64": "dotenv npm run build && electron-builder --mac --arm64",
"build:mac:x64": "dotenv npm run build && electron-builder --mac --x64",
"build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64",
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
"build:npm": "node scripts/build-npm.js",
"release": "node scripts/version.js",
"publish": "yarn build:check && yarn release patch push",
@@ -71,7 +71,7 @@
"notion-helper": "^1.3.22",
"os-proxy-config": "^1.1.2",
"pdfjs-dist": "4.10.38",
"selection-hook": "^1.0.5",
"selection-hook": "^1.0.6",
"turndown": "7.2.0"
},
"devDependencies": {
@@ -92,6 +92,7 @@
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@codemirror/view": "^6.0.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0",
@@ -141,6 +142,8 @@
"@vitest/coverage-v8": "^3.1.4",
"@vitest/ui": "^3.1.4",
"@vitest/web-worker": "^3.1.4",
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch",
"archiver": "^7.0.1",
@@ -225,6 +228,7 @@
"tiny-pinyin": "^1.3.2",
"tokenx": "^1.1.0",
"typescript": "^5.6.2",
"unified": "^11.0.5",
"uuid": "^10.0.0",
"vite": "6.2.6",
"vitest": "^3.1.4",

View File

@@ -147,6 +147,7 @@ export enum IpcChannel {
File_Base64File = 'file:base64File',
File_GetPdfInfo = 'file:getPdfInfo',
Fs_Read = 'fs:read',
File_OpenWithRelativePath = 'file:openWithRelativePath',
// file service
FileService_Upload = 'file-service:upload',

View File

@@ -399,6 +399,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath)
// file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {

View File

@@ -5,26 +5,19 @@ import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-op
import { getInstanceName } from '@main/utils'
import { KnowledgeBaseParams } from '@types'
import { SUPPORTED_DIM_MODELS as VOYAGE_SUPPORTED_DIM_MODELS, VoyageEmbeddings } from './VoyageEmbeddings'
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
import { VoyageEmbeddings } from './VoyageEmbeddings'
export default class EmbeddingsFactory {
static create({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
const batchSize = 10
if (provider === 'voyageai') {
if (VOYAGE_SUPPORTED_DIM_MODELS.includes(model)) {
return new VoyageEmbeddings({
modelName: model,
apiKey,
outputDimension: dimensions,
batchSize: 8
})
} else {
return new VoyageEmbeddings({
modelName: model,
apiKey,
batchSize: 8
})
}
return new VoyageEmbeddings({
modelName: model,
apiKey,
outputDimension: VOYAGE_SUPPORTED_DIM_MODELS.includes(model) ? dimensions : undefined,
batchSize: 8
})
}
if (provider === 'ollama') {
if (baseURL.includes('v1/')) {

View File

@@ -1,27 +1,29 @@
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
/**
* 支持设置嵌入维度的模型
*/
export const SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
export class VoyageEmbeddings extends BaseEmbeddings {
private model: _VoyageEmbeddings
constructor(private readonly configuration?: ConstructorParameters<typeof _VoyageEmbeddings>[0]) {
super()
if (!this.configuration) this.configuration = {}
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
if (!SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
throw new Error(`VoyageEmbeddings only supports ${SUPPORTED_DIM_MODELS.join(', ')}`)
if (!this.configuration) {
throw new Error('Pass in a configuration.')
}
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
this.model = new _VoyageEmbeddings(this.configuration)
if (!VOYAGE_SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
console.error(`VoyageEmbeddings only supports ${VOYAGE_SUPPORTED_DIM_MODELS.join(', ')} to set outputDimension.`)
this.model = new _VoyageEmbeddings({ ...this.configuration, outputDimension: undefined })
} else {
this.model = new _VoyageEmbeddings(this.configuration)
}
}
override async getDimensions(): Promise<number> {
if (!this.configuration?.outputDimension) {
throw new Error('You need to pass in the optional dimensions parameter for this model')
}
return this.configuration?.outputDimension
return this.configuration?.outputDimension ?? (this.configuration?.modelName === 'voyage-code-2' ? 1536 : 1024)
}
override async embedDocuments(texts: string[]): Promise<number[][]> {

View File

@@ -0,0 +1,45 @@
export const VOYAGE_SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
// NOTE: 下面的暂时没用上,但先留着吧
export const OPENAI_SUPPORTED_DIM_MODELS = ['text-embedding-3-small', 'text-embedding-3-large']
export const DASHSCOPE_SUPPORTED_DIM_MODELS = ['text-embedding-v3', 'text-embedding-v4']
export const OPENSOURCE_SUPPORTED_DIM_MODELS = ['qwen3-embedding-0.6B', 'qwen3-embedding-4B', 'qwen3-embedding-8B']
export const GOOGLE_SUPPORTED_DIM_MODELS = ['gemini-embedding-exp-03-07', 'gemini-embedding-exp']
export const SUPPORTED_DIM_MODELS = [
...VOYAGE_SUPPORTED_DIM_MODELS,
...OPENAI_SUPPORTED_DIM_MODELS,
...DASHSCOPE_SUPPORTED_DIM_MODELS,
...OPENSOURCE_SUPPORTED_DIM_MODELS,
...GOOGLE_SUPPORTED_DIM_MODELS
]
/**
* 从模型 ID 中提取基础名称。
* 例如:
* - 'deepseek/deepseek-r1' => 'deepseek-r1'
* - 'deepseek-ai/deepseek/deepseek-r1' => 'deepseek-r1'
* @param {string} id 模型 ID
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
* @returns {string} 基础名称
*/
export const getBaseModelName = (id: string, delimiter: string = '/'): string => {
const parts = id.split(delimiter)
return parts[parts.length - 1]
}
/**
* 从模型 ID 中提取基础名称并转换为小写。
* 例如:
* - 'deepseek/DeepSeek-R1' => 'deepseek-r1'
* - 'deepseek-ai/deepseek/DeepSeek-R1' => 'deepseek-r1'
* @param {string} id 模型 ID
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
* @returns {string} 小写的基础名称
*/
export const getLowerBaseModelName = (id: string, delimiter: string = '/'): string => {
return getBaseModelName(id, delimiter).toLowerCase()
}

View File

@@ -114,7 +114,7 @@ export async function addFileLoader(
// HTML类型处理
loaderReturn = await ragApplication.addLoader(
new WebLoader({
urlOrContent: readTextFileWithAutoEncoding(file.path),
urlOrContent: await readTextFileWithAutoEncoding(file.path),
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
@@ -124,7 +124,7 @@ export async function addFileLoader(
case 'json':
try {
jsonObject = JSON.parse(readTextFileWithAutoEncoding(file.path))
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(file.path))
} catch (error) {
jsonParsed = false
Logger.warn('[KnowledgeBase] failed parsing json file, falling back to text processing:', file.path, error)
@@ -140,7 +140,7 @@ export async function addFileLoader(
// 如果是其他文本类型且尚未读取文件,则读取文件
loaderReturn = await ragApplication.addLoader(
new TextLoader({
text: readTextFileWithAutoEncoding(file.path),
text: await readTextFileWithAutoEncoding(file.path),
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,

View File

@@ -217,7 +217,7 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
* @param filePath 文件路径
*/
private async convertFile(uid: string, filePath: string): Promise<void> {
const fileName = path.basename(filePath).split('.')[0]
const fileName = path.parse(filePath).name
const config = {
...this.createAuthConfig(),
headers: {

View File

@@ -231,7 +231,11 @@ class FileStorage {
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
}
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
public readFile = async (
_: Electron.IpcMainInvokeEvent,
id: string,
detectEncoding: boolean = false
): Promise<string> => {
const filePath = path.join(this.storageDir, id)
const fileExtension = path.extname(filePath)
@@ -259,8 +263,11 @@ class FileStorage {
}
try {
const result = readTextFileWithAutoEncoding(filePath)
return result
if (detectEncoding) {
return readTextFileWithAutoEncoding(filePath)
} else {
return fs.readFileSync(filePath, 'utf-8')
}
} catch (error) {
logger.error(error)
return 'failed to read file'
@@ -417,6 +424,19 @@ class FileStorage {
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
}
/**
* 通过相对路径打开文件,跨设备时使用
* @param file
*/
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
const filePath = path.join(this.storageDir, file.name)
if (fs.existsSync(filePath)) {
shell.openPath(filePath).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
} else {
logger.warn('[IPC - Warning] File does not exist:', filePath)
}
}
public save = async (
_: Electron.IpcMainInvokeEvent,
fileName: string,

View File

@@ -1,7 +1,7 @@
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
import { isDev, isMac, isWin } from '@main/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
import Logger from 'electron-log'
import { join } from 'path'
import type {
@@ -509,54 +509,55 @@ export class SelectionService {
//should set every time the window is shown
this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver')
// [macOS] a series of hacky ways only for macOS
if (isMac) {
// [macOS] a hacky way
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
// so we just don't set `skipTransformProcessType: true` when in self app
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
if (!isSelf) {
// [macOS] an ugly hacky way
// `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
// so we set `focusable: true` before showing, and then set false after showing
this.toolbarWindow!.setFocusable(false)
// [macOS]
// force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again
// set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces`
this.toolbarWindow!.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
skipTransformProcessType: true
})
}
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
this.toolbarWindow!.showInactive()
// [macOS] restore the focusable status
this.toolbarWindow!.setFocusable(true)
if (!isMac) {
this.toolbarWindow!.show()
/**
* [Windows]
* In Windows 10, setOpacity(1) will make the window completely transparent
* It's a strange behavior, so we don't use it for compatibility
*/
// this.toolbarWindow!.setOpacity(1)
this.startHideByMouseKeyListener()
return
}
/**
* The following is for Windows
*/
/************************************************
* [macOS] the following code is only for macOS
*
* WARNING:
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
*************************************************/
this.toolbarWindow!.show()
// [macOS] a hacky way
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
// so we just don't set `skipTransformProcessType: true` when in self app
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
/**
* [Windows]
* In Windows 10, setOpacity(1) will make the window completely transparent
* It's a strange behavior, so we don't use it for compatibility
*/
// this.toolbarWindow!.setOpacity(1)
if (!isSelf) {
// [macOS] an ugly hacky way
// `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
// so we set `focusable: true` before showing, and then set false after showing
this.toolbarWindow!.setFocusable(false)
// [macOS]
// force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again
// set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces`
this.toolbarWindow!.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
skipTransformProcessType: true
})
}
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
this.toolbarWindow!.showInactive()
// [macOS] restore the focusable status
this.toolbarWindow!.setFocusable(true)
this.startHideByMouseKeyListener()
return
}
/**
@@ -911,6 +912,7 @@ export class SelectionService {
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
}
// [macOS] isFullscreen is only available on macOS
this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName)
this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
}
@@ -1218,20 +1220,26 @@ export class SelectionService {
return actionWindow
}
public processAction(actionItem: ActionItem): void {
/**
* Process action item
* @param actionItem Action item to process
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
*/
public processAction(actionItem: ActionItem, isFullScreen: boolean = false): void {
const actionWindow = this.popActionWindow()
actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
this.showActionWindow(actionWindow)
this.showActionWindow(actionWindow, isFullScreen)
}
/**
* Show action window with proper positioning relative to toolbar
* Ensures window stays within screen boundaries
* @param actionWindow Window to position and show
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
*/
private showActionWindow(actionWindow: BrowserWindow): void {
private showActionWindow(actionWindow: BrowserWindow, isFullScreen: boolean = false): void {
let actionWindowWidth = this.ACTION_WINDOW_WIDTH
let actionWindowHeight = this.ACTION_WINDOW_HEIGHT
@@ -1241,11 +1249,14 @@ export class SelectionService {
actionWindowHeight = this.lastActionWindowSize.height
}
//center way
if (!this.isFollowToolbar || !this.toolbarWindow) {
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const workArea = display.workArea
/********************************************
* Setting the position of the action window
********************************************/
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const workArea = display.workArea
// Center of the screen
if (!this.isFollowToolbar || !this.toolbarWindow) {
const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2
const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2
@@ -1255,54 +1266,107 @@ export class SelectionService {
x: Math.round(centerX),
y: Math.round(centerY)
})
} else {
// Follow toolbar position
const toolbarBounds = this.toolbarWindow!.getBounds()
const GAP = 6 // 6px gap from screen edges
//make sure action window is inside screen
if (actionWindowWidth > workArea.width - 2 * GAP) {
actionWindowWidth = workArea.width - 2 * GAP
}
if (actionWindowHeight > workArea.height - 2 * GAP) {
actionWindowHeight = workArea.height - 2 * GAP
}
// Calculate initial position to center action window horizontally below toolbar
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
let posY = Math.round(toolbarBounds.y)
// Ensure action window stays within screen boundaries with a small gap
if (posX + actionWindowWidth > workArea.x + workArea.width) {
posX = workArea.x + workArea.width - actionWindowWidth - GAP
} else if (posX < workArea.x) {
posX = workArea.x + GAP
}
if (posY + actionWindowHeight > workArea.y + workArea.height) {
// If window would go below screen, try to position it above toolbar
posY = workArea.y + workArea.height - actionWindowHeight - GAP
} else if (posY < workArea.y) {
posY = workArea.y + GAP
}
actionWindow.setPosition(posX, posY, false)
//KEY to make window not resize
actionWindow.setBounds({
width: actionWindowWidth,
height: actionWindowHeight,
x: posX,
y: posY
})
}
if (!isMac) {
actionWindow.show()
return
}
//follow toolbar
const toolbarBounds = this.toolbarWindow!.getBounds()
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const workArea = display.workArea
const GAP = 6 // 6px gap from screen edges
/************************************************
* [macOS] the following code is only for macOS
*
* WARNING:
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
*************************************************/
//make sure action window is inside screen
if (actionWindowWidth > workArea.width - 2 * GAP) {
actionWindowWidth = workArea.width - 2 * GAP
// act normally when the app is not in fullscreen mode
if (!isFullScreen) {
actionWindow.show()
return
}
if (actionWindowHeight > workArea.height - 2 * GAP) {
actionWindowHeight = workArea.height - 2 * GAP
}
// [macOS] an UGLY HACKY way for fullscreen override settings
// Calculate initial position to center action window horizontally below toolbar
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
let posY = Math.round(toolbarBounds.y)
// FIXME sometimes the dock will be shown when the action window is shown
// FIXME if actionWindow show on the fullscreen app, switch to other space will cause the mainWindow to be shown
// FIXME When setVisibleOnAllWorkspaces is true, docker icon disappeared when the first action window is shown on the fullscreen app
// use app.dock.show() to show the dock again will cause the action window to be closed when auto hide on blur is enabled
// Ensure action window stays within screen boundaries with a small gap
if (posX + actionWindowWidth > workArea.x + workArea.width) {
posX = workArea.x + workArea.width - actionWindowWidth - GAP
} else if (posX < workArea.x) {
posX = workArea.x + GAP
}
if (posY + actionWindowHeight > workArea.y + workArea.height) {
// If window would go below screen, try to position it above toolbar
posY = workArea.y + workArea.height - actionWindowHeight - GAP
} else if (posY < workArea.y) {
posY = workArea.y + GAP
}
// setFocusable(false) to prevent the action window hide when blur (if auto hide on blur is enabled)
actionWindow.setFocusable(false)
actionWindow.setAlwaysOnTop(true, 'floating')
actionWindow.setPosition(posX, posY, false)
//KEY to make window not resize
actionWindow.setBounds({
width: actionWindowWidth,
height: actionWindowHeight,
x: posX,
y: posY
// `setVisibleOnAllWorkspaces(true)` will cause the dock icon disappeared
// just store the dock icon status, and show it again
const isDockShown = app.dock?.isVisible()
// DO NOT set `skipTransformProcessType: true`,
// it will cause the action window to be shown on other space
actionWindow.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true
})
actionWindow.show()
actionWindow.showInactive()
// show the dock again if last time it was shown
// do not put it after `actionWindow.focus()`, will cause the action window to be closed when auto hide on blur is enabled
if (!app.dock?.isVisible() && isDockShown) {
app.dock?.show()
}
// unset everything
setTimeout(() => {
actionWindow.setVisibleOnAllWorkspaces(false, {
visibleOnFullScreen: true,
skipTransformProcessType: true
})
actionWindow.setAlwaysOnTop(false)
actionWindow.setFocusable(true)
// regain the focus when all the works done
actionWindow.focus()
}, 50)
}
public closeActionWindow(actionWindow: BrowserWindow): void {
@@ -1408,8 +1472,9 @@ export class SelectionService {
configManager.setSelectionAssistantFilterList(filterList)
})
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => {
selectionService?.processAction(actionItem)
// [macOS] only macOS has the available isFullscreen mode
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => {
selectionService?.processAction(actionItem, isFullScreen)
})
ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => {

View File

@@ -44,7 +44,9 @@ export function handleMcpProtocolUrl(url: URL) {
// }
// }
// cherrystudio://mcp/install?servers={base64Encode(JSON.stringify(jsonConfig))}
const data = params.get('servers')
if (data) {
const stringify = Buffer.from(data, 'base64').toString('utf8')
Logger.info('install MCP servers from urlschema: ', stringify)
@@ -63,10 +65,8 @@ export function handleMcpProtocolUrl(url: URL) {
}
}
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.executeJavaScript("window.navigate('/settings/mcp')")
}
windowService.getMainWindow()?.show()
break
}
default:

View File

@@ -1,16 +1,19 @@
import * as fs from 'node:fs'
import * as fsPromises from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { FileTypes } from '@types'
import iconv from 'iconv-lite'
import { detectAll as detectEncodingAll } from 'jschardet'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { detectEncoding, readTextFileWithAutoEncoding } from '../file'
import { readTextFileWithAutoEncoding } from '../file'
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
// Mock dependencies
vi.mock('node:fs')
vi.mock('node:fs/promises')
vi.mock('node:os')
vi.mock('node:path')
vi.mock('uuid', () => ({
@@ -244,102 +247,52 @@ describe('file', () => {
})
})
// 在 describe('file') 块内部添加新的 describe 块
describe('detectEncoding', () => {
const mockFilePath = '/path/to/mock/file.txt'
beforeEach(() => {
vi.mocked(fs.openSync).mockReturnValue(123)
vi.mocked(fs.closeSync).mockImplementation(() => {})
})
it('should correctly detect UTF-8 encoding', () => {
// 准备UTF-8编码的Buffer
const content = '这是UTF-8测试内容'
const buffer = Buffer.from(content, 'utf-8')
// 模拟文件读取
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
const targetBuffer = new Uint8Array(buf.buffer)
const sourceBuffer = new Uint8Array(buffer)
targetBuffer.set(sourceBuffer)
return 1024
})
const encoding = detectEncoding(mockFilePath)
expect(encoding).toBe('UTF-8')
})
it('should correctly detect GB2312 encoding', () => {
// 使用iconv创建GB2312编码内容
const content = '这是一段GB2312编码的测试内容'
const gb2312Buffer = iconv.encode(content, 'GB2312')
// 模拟文件读取
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
const targetBuffer = new Uint8Array(buf.buffer)
const sourceBuffer = new Uint8Array(gb2312Buffer)
targetBuffer.set(sourceBuffer)
return gb2312Buffer.length
})
const encoding = detectEncoding(mockFilePath)
expect(encoding).toMatch(/GB2312|GB18030/i)
})
it('should correctly detect ASCII encoding', () => {
// 准备ASCII编码内容
const content = 'ASCII content'
const buffer = Buffer.from(content, 'ascii')
// 模拟文件读取
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
const targetBuffer = new Uint8Array(buf.buffer)
const sourceBuffer = new Uint8Array(buffer)
targetBuffer.set(sourceBuffer)
return buffer.length
})
const encoding = detectEncoding(mockFilePath)
expect(encoding.toLowerCase()).toBe('ascii')
})
})
describe('readTextFileWithAutoEncoding', () => {
const mockFilePath = '/path/to/mock/file.txt'
beforeEach(() => {
vi.mocked(fs.openSync).mockReturnValue(123)
vi.mocked(fs.closeSync).mockImplementation(() => {})
})
it('should read file with auto encoding', () => {
it('should read file with auto encoding', async () => {
const content = '这是一段GB2312编码的测试内容'
const buffer = iconv.encode(content, 'GB2312')
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
const targetBuffer = new Uint8Array(buf.buffer)
const sourceBuffer = new Uint8Array(buffer)
targetBuffer.set(sourceBuffer)
return buffer.length
})
vi.mocked(fs.readFileSync).mockReturnValue(buffer)
const result = readTextFileWithAutoEncoding(mockFilePath)
// 创建模拟的 FileHandle 对象
const mockFileHandle = {
read: vi.fn().mockResolvedValue({
bytesRead: buffer.byteLength,
buffer: buffer
}),
close: vi.fn().mockResolvedValue(undefined)
}
// 模拟 open 方法
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
const result = await readTextFileWithAutoEncoding(mockFilePath)
expect(result).toBe(content)
})
it('should try to fix bad detected encoding', () => {
it('should try to fix bad detected encoding', async () => {
const content = '这是一段GB2312编码的测试内容'
const buffer = iconv.encode(content, 'GB2312')
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
const targetBuffer = new Uint8Array(buf.buffer)
const sourceBuffer = new Uint8Array(buffer)
targetBuffer.set(sourceBuffer)
return buffer.length
})
vi.mocked(fs.readFileSync).mockReturnValue(buffer)
vi.mocked(vi.fn(detectEncoding)).mockReturnValue('UTF-8')
const result = readTextFileWithAutoEncoding(mockFilePath)
// 创建模拟的 FileHandle 对象
const mockFileHandle = {
read: vi.fn().mockResolvedValue({
bytesRead: buffer.byteLength,
buffer: buffer
}),
close: vi.fn().mockResolvedValue(undefined)
}
// 模拟 fs.open 方法
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
vi.mocked(vi.fn(detectEncodingAll)).mockReturnValue([
{ encoding: 'UTF-8', confidence: 0.9 },
{ encoding: 'GB2312', confidence: 0.8 }
])
const result = await readTextFileWithAutoEncoding(mockFilePath)
expect(result).toBe(content)
})
})

View File

@@ -1,14 +1,15 @@
import * as fs from 'node:fs'
import { open, readFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { isLinux, isPortable } from '@main/constant'
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
import { FileMetadata, FileTypes } from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
import iconv from 'iconv-lite'
import { detect as detectEncoding_, detectAll as detectEncodingAll } from 'jschardet'
import * as jschardet from 'jschardet'
import { v4 as uuidv4 } from 'uuid'
export function initAppDataDir() {
@@ -206,56 +207,48 @@ export function getAppConfigDir(name: string) {
return path.join(getConfigDir(), name)
}
/**
* 使用 jschardet 库检测文件编码格式
* @param filePath - 文件路径
* @returns 返回文件的编码格式,如 UTF-8, ascii, GB2312 等
*/
export function detectEncoding(filePath: string): string {
// 读取文件前1KB来检测编码
const buffer = Buffer.alloc(1024)
const fd = fs.openSync(filePath, 'r')
fs.readSync(fd, buffer, 0, 1024, 0)
fs.closeSync(fd)
const { encoding } = detectEncoding_(buffer)
return encoding
}
/**
* 读取文件内容并自动检测编码格式进行解码
* @param filePath - 文件路径
* @returns 解码后的文件内容
*/
export function readTextFileWithAutoEncoding(filePath: string) {
const encoding = detectEncoding(filePath)
const data = fs.readFileSync(filePath)
const content = iconv.decode(data, encoding)
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
// 读取前1MB以检测编码
const buffer = Buffer.alloc(1 * MB)
const fh = await open(filePath, 'r')
const { buffer: bufferRead } = await fh.read(buffer, 0, 1 * MB, 0)
await fh.close()
if (content.includes('\uFFFD') && encoding !== 'UTF-8') {
Logger.error(`文件 ${filePath} 自动识别编码为 ${encoding},但包含错误字符。尝试其他编码`)
const buffer = Buffer.alloc(1024)
const fd = fs.openSync(filePath, 'r')
fs.readSync(fd, buffer, 0, 1024, 0)
fs.closeSync(fd)
const encodings = detectEncodingAll(buffer)
if (encodings.length > 0) {
for (const item of encodings) {
if (item.encoding === encoding) {
continue
}
Logger.log(`尝试使用 ${item.encoding} 解码文件 ${filePath}`)
const content = iconv.decode(buffer, item.encoding)
if (!content.includes('\uFFFD')) {
Logger.log(`文件 ${filePath} 解码成功,编码为 ${item.encoding}`)
return content
} else {
Logger.error(`文件 ${filePath} 使用 ${item.encoding} 解码失败,尝试下一个编码`)
}
}
}
Logger.error(`文件 ${filePath} 所有可能的编码均解码失败,尝试使用 UTF-8 解码`)
return iconv.decode(buffer, 'UTF-8')
// 获取文件编码格式,最多取前两个可能的编码
const encodings = jschardet
.detectAll(bufferRead)
.map((item) => ({
...item,
encoding: item.encoding === 'ascii' ? 'UTF-8' : item.encoding
}))
.filter((item, index, array) => array.findIndex((prevItem) => prevItem.encoding === item.encoding) === index)
.slice(0, 2)
if (encodings.length === 0) {
Logger.error('Failed to detect encoding. Use utf-8 to decode.')
const data = await readFile(filePath)
return iconv.decode(data, 'UTF-8')
}
return content
const data = await readFile(filePath)
for (const item of encodings) {
const encoding = item.encoding
const content = iconv.decode(data, encoding)
if (content.includes('\uFFFD')) {
Logger.error(
`File ${filePath} was auto-detected as ${encoding} encoding, but contains invalid characters. Trying other encodings`
)
} else {
return content
}
}
Logger.error(`File ${filePath} failed to decode with all possible encodings, trying UTF-8 encoding`)
return iconv.decode(data, 'UTF-8')
}

View File

@@ -115,7 +115,8 @@ const api = {
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
read: (fileId: string, detectEncoding?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
/**
@@ -146,7 +147,8 @@ const api = {
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
getPathForFile: (file: File) => webUtils.getPathForFile(file)
getPathForFile: (file: File) => webUtils.getPathForFile(file),
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file)
},
fs: {
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
@@ -309,7 +311,8 @@ const api = {
ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize),
setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
processAction: (actionItem: ActionItem, isFullScreen: boolean = false) =>
ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem, isFullScreen),
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)

View File

@@ -254,7 +254,7 @@ export abstract class BaseApiClient<
for (const fileBlock of textFileBlocks) {
const file = fileBlock.file
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
const fileNameRow = 'file: ' + file.origin_name + '\n\n'
text = text + fileNameRow + fileContent + divider
}

View File

@@ -49,10 +49,10 @@ import {
LLMWebSearchCompleteChunk,
LLMWebSearchInProgressChunk,
MCPToolCreatedChunk,
TextCompleteChunk,
TextDeltaChunk,
ThinkingCompleteChunk,
ThinkingDeltaChunk
TextStartChunk,
ThinkingDeltaChunk,
ThinkingStartChunk
} from '@renderer/types/chunk'
import { type Message } from '@renderer/types/newMessage'
import {
@@ -231,7 +231,7 @@ export class AnthropicAPIClient extends BaseApiClient<
}
})
} else {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
@@ -519,7 +519,6 @@ export class AnthropicAPIClient extends BaseApiClient<
return () => {
let accumulatedJson = ''
const toolCalls: Record<number, ToolUseBlock> = {}
const ChunkIdTypeMap: Record<number, ChunkType> = {}
return {
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
switch (rawChunk.type) {
@@ -615,16 +614,16 @@ export class AnthropicAPIClient extends BaseApiClient<
break
}
case 'text': {
if (!ChunkIdTypeMap[rawChunk.index]) {
ChunkIdTypeMap[rawChunk.index] = ChunkType.TEXT_DELTA // 用textdelta代表文本块
}
controller.enqueue({
type: ChunkType.TEXT_START
} as TextStartChunk)
break
}
case 'thinking':
case 'redacted_thinking': {
if (!ChunkIdTypeMap[rawChunk.index]) {
ChunkIdTypeMap[rawChunk.index] = ChunkType.THINKING_DELTA // 用thinkingdelta代表思考块
}
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
break
}
}
@@ -661,15 +660,6 @@ export class AnthropicAPIClient extends BaseApiClient<
break
}
case 'content_block_stop': {
if (ChunkIdTypeMap[rawChunk.index] === ChunkType.TEXT_DELTA) {
controller.enqueue({
type: ChunkType.TEXT_COMPLETE
} as TextCompleteChunk)
} else if (ChunkIdTypeMap[rawChunk.index] === ChunkType.THINKING_DELTA) {
controller.enqueue({
type: ChunkType.THINKING_COMPLETE
} as ThinkingCompleteChunk)
}
const toolCall = toolCalls[rawChunk.index]
if (toolCall) {
try {

View File

@@ -41,7 +41,7 @@ import {
ToolCallResponse,
WebSearchSource
} from '@renderer/types'
import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk'
import { ChunkType, LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
GeminiOptions,
@@ -288,7 +288,7 @@ export class GeminiAPIClient extends BaseApiClient<
continue
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
parts.push({
text: file.origin_name + '\n' + fileContent
})
@@ -547,20 +547,34 @@ export class GeminiAPIClient extends BaseApiClient<
}
getResponseChunkTransformer(): ResponseChunkTransformer<GeminiSdkRawChunk> {
const toolCalls: FunctionCall[] = []
let isFirstTextChunk = true
let isFirstThinkingChunk = true
return () => ({
async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
const toolCalls: FunctionCall[] = []
if (chunk.candidates && chunk.candidates.length > 0) {
for (const candidate of chunk.candidates) {
if (candidate.content) {
candidate.content.parts?.forEach((part) => {
const text = part.text || ''
if (part.thought) {
if (isFirstThinkingChunk) {
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
isFirstThinkingChunk = false
}
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: text
})
} else if (part.text) {
if (isFirstTextChunk) {
controller.enqueue({
type: ChunkType.TEXT_START
} as TextStartChunk)
isFirstTextChunk = false
}
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: text
@@ -593,6 +607,13 @@ export class GeminiAPIClient extends BaseApiClient<
}
} as LLMWebSearchCompleteChunk)
}
if (toolCalls.length > 0) {
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: [...toolCalls]
})
toolCalls.length = 0
}
controller.enqueue({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {

View File

@@ -31,7 +31,7 @@ import {
ToolCallResponse,
WebSearchSource
} from '@renderer/types'
import { ChunkType } from '@renderer/types/chunk'
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
OpenAISdkMessageParam,
@@ -307,7 +307,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
@@ -659,6 +659,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
isFinished = true
}
let isFirstThinkingChunk = true
let isFirstTextChunk = true
return (context: ResponseChunkTransformerContext) => ({
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
// 持续更新usage信息
@@ -699,6 +701,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
if (reasoningText) {
if (isFirstThinkingChunk) {
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
isFirstThinkingChunk = false
}
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: reasoningText
@@ -707,6 +715,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// 处理文本内容
if (contentSource.content) {
if (isFirstTextChunk) {
controller.enqueue({
type: ChunkType.TEXT_START
} as TextStartChunk)
isFirstTextChunk = false
}
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: contentSource.content

View File

@@ -89,7 +89,7 @@ export abstract class OpenAIBaseClient<
const data = await sdk.embeddings.create({
model: model.id,
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
encoding_format: 'float'
encoding_format: this.provider.id === 'voyageai' ? undefined : 'float'
})
return data.data[0].embedding.length
}

View File

@@ -39,7 +39,7 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { MB } from '@shared/config/constant'
import { isEmpty } from 'lodash'
import OpenAI from 'openai'
import OpenAI, { AzureOpenAI } from 'openai'
import { ResponseInput } from 'openai/resources/responses/responses'
import { RequestTransformer, ResponseChunkTransformer } from '../types'
@@ -66,6 +66,9 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
*/
public getClient(model: Model) {
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
this.provider = { ...this.provider, apiVersion: 'preview' }
}
return this
} else {
return this.client
@@ -77,15 +80,25 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
return this.sdkInstance
}
return new OpenAI({
dangerouslyAllowBrowser: true,
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers
}
})
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
this.provider = { ...this.provider, apiHost: `${this.provider.apiHost}/openai/v1` }
return new AzureOpenAI({
dangerouslyAllowBrowser: true,
apiKey: this.apiKey,
apiVersion: this.provider.apiVersion,
baseURL: this.provider.apiHost
})
} else {
return new OpenAI({
dangerouslyAllowBrowser: true,
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers
}
})
}
}
override async createCompletions(
@@ -173,7 +186,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
parts.push({
type: 'input_text',
text: file.origin_name + '\n' + fileContent
@@ -354,16 +367,15 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
(m) => (m as OpenAI.Responses.EasyInputMessage).role === 'assistant'
) as OpenAI.Responses.EasyInputMessage
const finalUserMessage = userMessage.pop() as OpenAI.Responses.EasyInputMessage
if (
finalAssistantMessage &&
Array.isArray(finalAssistantMessage.content) &&
finalUserMessage &&
Array.isArray(finalUserMessage.content)
) {
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
if (finalUserMessage && Array.isArray(finalUserMessage.content)) {
if (finalAssistantMessage && Array.isArray(finalAssistantMessage.content)) {
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
} else {
userMessage.push(finalUserMessage)
}
}
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
}
// 4. 最终请求消息
@@ -424,6 +436,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
let hasBeenCollectedToolCalls = false
let hasReasoningSummary = false
let isFirstThinkingChunk = true
let isFirstTextChunk = true
return () => ({
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
// 处理chunk
@@ -435,6 +449,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
switch (output.type) {
case 'message':
if (output.content[0].type === 'output_text') {
if (isFirstTextChunk) {
controller.enqueue({
type: ChunkType.TEXT_START
})
isFirstTextChunk = false
}
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: output.content[0].text
@@ -451,6 +471,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
break
case 'reasoning':
if (isFirstThinkingChunk) {
controller.enqueue({
type: ChunkType.THINKING_START
})
isFirstThinkingChunk = false
}
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: output.summary.map((s) => s.text).join('\n')
@@ -510,6 +536,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
hasReasoningSummary = true
break
case 'response.reasoning_summary_text.delta':
if (isFirstThinkingChunk) {
controller.enqueue({
type: ChunkType.THINKING_START
})
isFirstThinkingChunk = false
}
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: chunk.delta
@@ -535,6 +567,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
})
break
case 'response.output_text.delta': {
if (isFirstTextChunk) {
controller.enqueue({
type: ChunkType.TEXT_START
})
isFirstTextChunk = false
}
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: chunk.delta

View File

@@ -75,11 +75,12 @@ export default class AiProvider {
} else {
// Existing logic for other models
if (!params.enableReasoning) {
builder.remove(ThinkingTagExtractionMiddlewareName)
// 这里注释掉不会影响正常的关闭思考,可忽略不计的性能下降
// builder.remove(ThinkingTagExtractionMiddlewareName)
builder.remove(ThinkChunkMiddlewareName)
}
// 注意用client判断会导致typescript类型收窄
if (!(this.apiClient instanceof OpenAIAPIClient)) {
if (!(this.apiClient instanceof OpenAIAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
builder.remove(ThinkingTagExtractionMiddlewareName)
}
if (!(this.apiClient instanceof AnthropicAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {

View File

@@ -252,7 +252,9 @@ async function executeToolCalls(
('name' in toolCall &&
(toolCall.name?.includes(confirmed.tool.name) || toolCall.name?.includes(confirmed.tool.id))) ||
confirmed.tool.name === toolCall.id ||
confirmed.tool.id === toolCall.id
confirmed.tool.id === toolCall.id ||
('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id) ||
('function' in toolCall && toolCall.function.name.toLowerCase().includes(confirmed.tool.name.toLowerCase()))
)
})
})

View File

@@ -1,5 +1,5 @@
import Logger from '@renderer/config/logger'
import { ChunkType, TextCompleteChunk, TextDeltaChunk } from '@renderer/types/chunk'
import { ChunkType, TextDeltaChunk } from '@renderer/types/chunk'
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
import { CompletionsContext, CompletionsMiddleware } from '../types'
@@ -38,7 +38,6 @@ export const TextChunkMiddleware: CompletionsMiddleware =
// 用于跨chunk的状态管理
let accumulatedTextContent = ''
let hasTextCompleteEventEnqueue = false
const enhancedTextStream = resultFromUpstream.pipeThrough(
new TransformStream<GenericChunk, GenericChunk>({
transform(chunk: GenericChunk, controller) {
@@ -53,18 +52,7 @@ export const TextChunkMiddleware: CompletionsMiddleware =
// 创建新的chunk包含处理后的文本
controller.enqueue(chunk)
} else if (chunk.type === ChunkType.TEXT_COMPLETE) {
const textChunk = chunk as TextCompleteChunk
controller.enqueue({
...textChunk,
text: accumulatedTextContent
})
if (params.onResponse) {
params.onResponse(accumulatedTextContent, true)
}
hasTextCompleteEventEnqueue = true
accumulatedTextContent = ''
} else if (accumulatedTextContent && !hasTextCompleteEventEnqueue) {
} else if (accumulatedTextContent && chunk.type !== ChunkType.TEXT_START) {
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
const finalText = accumulatedTextContent
ctx._internal.customState!.accumulatedText = finalText
@@ -89,7 +77,6 @@ export const TextChunkMiddleware: CompletionsMiddleware =
})
controller.enqueue(chunk)
}
hasTextCompleteEventEnqueue = true
accumulatedTextContent = ''
} else {
// 其他类型的chunk直接传递

View File

@@ -65,17 +65,7 @@ export const ThinkChunkMiddleware: CompletionsMiddleware =
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
}
controller.enqueue(enhancedChunk)
} else if (chunk.type === ChunkType.THINKING_COMPLETE) {
const thinkingCompleteChunk = chunk as ThinkingCompleteChunk
controller.enqueue({
...thinkingCompleteChunk,
text: accumulatedThinkingContent,
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
})
hasThinkingContent = false
accumulatedThinkingContent = ''
thinkingStartTime = 0
} else if (hasThinkingContent && thinkingStartTime > 0) {
} else if (hasThinkingContent && thinkingStartTime > 0 && chunk.type !== ChunkType.THINKING_START) {
// 收到任何非THINKING_DELTA的chunk时如果有累积的思考内容生成THINKING_COMPLETE
const thinkingCompleteChunk: ThinkingCompleteChunk = {
type: ChunkType.THINKING_COMPLETE,

View File

@@ -1,5 +1,11 @@
import { Model } from '@renderer/types'
import { ChunkType, TextDeltaChunk, ThinkingCompleteChunk, ThinkingDeltaChunk } from '@renderer/types/chunk'
import {
ChunkType,
TextDeltaChunk,
ThinkingCompleteChunk,
ThinkingDeltaChunk,
ThinkingStartChunk
} from '@renderer/types/chunk'
import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction'
import Logger from 'electron-log/renderer'
@@ -59,6 +65,8 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
let hasThinkingContent = false
let thinkingStartTime = 0
let isFirstTextChunk = true
const processedStream = resultFromUpstream.pipeThrough(
new TransformStream<GenericChunk, GenericChunk>({
transform(chunk: GenericChunk, controller) {
@@ -87,6 +95,9 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
if (!hasThinkingContent) {
hasThinkingContent = true
thinkingStartTime = Date.now()
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
}
if (extractionResult.content?.trim()) {
@@ -98,6 +109,12 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
controller.enqueue(thinkingDeltaChunk)
}
} else {
if (isFirstTextChunk) {
controller.enqueue({
type: ChunkType.TEXT_START
})
isFirstTextChunk = false
}
// 发送清理后的文本内容
const cleanTextChunk: TextDeltaChunk = {
...textChunk,
@@ -107,7 +124,7 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
}
}
}
} else {
} else if (chunk.type !== ChunkType.TEXT_START) {
// 其他类型的chunk直接传递包括 THINKING_DELTA, THINKING_COMPLETE 等)
controller.enqueue(chunk)
}

View File

@@ -79,6 +79,7 @@ function createToolUseExtractionTransform(
toolCounter += toolUseResponses.length
if (toolUseResponses.length > 0) {
controller.enqueue({ type: ChunkType.TEXT_COMPLETE, text: '' })
// 生成 MCP_TOOL_CREATED chunk
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
type: ChunkType.MCP_TOOL_CREATED,

View File

@@ -44,7 +44,7 @@
--color-reference-text: #ffffff;
--color-reference-background: #0b0e12;
--color-list-item: #222;
--color-list-item: #252525;
--color-list-item-hover: #1e1e1e;
--modal-background: #111111;

View File

@@ -139,7 +139,7 @@ ul {
}
}
.message-content-container {
border-radius: 10px 0 10px 10px;
border-radius: 10px;
padding: 10px 16px 10px 16px;
background-color: var(--chat-background-user);
align-self: self-end;

View File

@@ -19,12 +19,14 @@
h4,
h5,
h6 {
margin: 1em 0 1em 0;
margin: 1.5em 0 1em 0;
line-height: 1.3;
font-weight: bold;
font-family: var(--font-family);
}
h1 {
margin-top: 0;
font-size: 2em;
border-bottom: 0.5px solid var(--color-border);
padding-bottom: 0.3em;
@@ -53,8 +55,9 @@
}
p {
margin: 1em 0;
margin: 1.3em 0;
white-space: pre-wrap;
line-height: 1.6;
&:last-child {
margin-bottom: 5px;
@@ -82,7 +85,7 @@
li {
margin-bottom: 0.5em;
pre {
margin: 1.5em 0;
margin: 1.5em 0 !important;
}
&::marker {
color: var(--color-text-3);
@@ -108,6 +111,7 @@
li code {
background: var(--color-background-mute);
padding: 3px 5px;
margin: 0 2px;
border-radius: 5px;
word-break: keep-all;
white-space: pre;
@@ -122,9 +126,7 @@
overflow-x: auto;
font-family: 'Fira Code', 'Courier New', Courier, monospace;
background-color: var(--color-background-mute);
&:has(.mermaid),
&:has(.plantuml-preview),
&:has(.svg-preview) {
&:has(.special-preview) {
background-color: transparent;
}
&:not(pre pre) {
@@ -148,16 +150,19 @@
}
blockquote {
margin: 1em 0;
padding-left: 1em;
color: var(--color-text-light);
border-left: 4px solid var(--color-border);
font-family: var(--font-family);
margin: 1.5em 0;
padding: 1em 1.5em;
background-color: var(--color-background-soft);
border-left: 4px solid var(--color-primary);
border-radius: 0 8px 8px 0;
font-style: italic;
position: relative;
}
table {
--table-border-radius: 8px;
margin: 1em 0;
margin: 2em 0;
font-size: 0.9em;
width: 100%;
border-radius: var(--table-border-radius);
overflow: hidden;
@@ -182,13 +187,19 @@
th {
background-color: var(--color-background-mute);
font-weight: bold;
font-weight: 600;
font-family: var(--font-family);
text-align: left;
}
tr:hover {
background-color: var(--color-background-soft);
}
img {
max-width: 100%;
height: auto;
margin: 10px 0;
}
a,

View File

@@ -1,24 +1,28 @@
import { Alert } from 'antd'
import { t } from 'i18next'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const LOCALSTORAGE_KEY = 'openai_alert_closed'
const OpenAIAlert = () => {
const { t } = useTranslation()
interface Props {
message?: string
key?: string
}
const OpenAIAlert = ({ message = t('settings.provider.openai.alert'), key = LOCALSTORAGE_KEY }: Props) => {
const [visible, setVisible] = useState(false)
useEffect(() => {
const closed = localStorage.getItem(LOCALSTORAGE_KEY)
const closed = localStorage.getItem(key)
setVisible(!closed)
}, [])
}, [key])
if (!visible) return null
return (
<Alert
style={{ width: '100%', marginTop: 5, marginBottom: 5 }}
message={t('settings.provider.openai.alert')}
message={message}
closable
afterClose={() => {
localStorage.setItem(LOCALSTORAGE_KEY, '1')

View File

@@ -1,4 +1,4 @@
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
import { TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
import { useSettings } from '@renderer/hooks/useSettings'
@@ -12,10 +12,10 @@ import { useTranslation } from 'react-i18next'
import { ThemedToken } from 'shiki/core'
import styled from 'styled-components'
interface CodePreviewProps {
children: string
import { BasicPreviewProps } from './types'
interface CodePreviewProps extends BasicPreviewProps {
language: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
const MAX_COLLAPSE_HEIGHT = 350
@@ -164,19 +164,11 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
}}>
<div
style={{
/*
* FIXME: @tanstack/react-virtual 使用绝对定位,但是会导致
* 有气泡样式 `self-end` 并且气泡中只有代码块时整个代码块收缩
* 到最小宽度(目前应该是工具栏的宽度)。改为相对定位可以保证宽
* 度足够,目前没有发现其他副作用。
* 如果发现破坏虚拟列表功能,或者将来有更好的解决方案,再调整。
*/
position: 'relative',
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
willChange: 'transform'
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`
}}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div key={virtualItem.key} data-index={virtualItem.index} ref={virtualizer.measureElement}>

View File

@@ -0,0 +1,102 @@
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
import { Flex, Spin } from 'antd'
import { debounce } from 'lodash'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
import PreviewError from './PreviewError'
import { BasicPreviewProps } from './types'
// 管理 viz 实例
const vizInitializer = new AsyncInitializer(async () => {
const module = await import('@viz-js/viz')
return await module.instance()
})
/** 预览 Graphviz 图表
* 通过防抖渲染提供比较统一的体验,减少闪烁。
*/
const GraphvizPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const graphvizRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
// 使用通用图像工具
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(graphvizRef, {
imgSelector: 'svg',
prefix: 'graphviz',
enableWheelZoom: true
})
// 使用工具栏
usePreviewTools({
setTools,
handleZoom,
handleCopyImage,
handleDownload
})
// 实际的渲染函数
const renderGraphviz = useCallback(async (content: string) => {
if (!content || !graphvizRef.current) return
try {
setIsLoading(true)
const viz = await vizInitializer.get()
const svgElement = viz.renderSVGElement(content)
// 清空容器并添加新的 SVG
graphvizRef.current.innerHTML = ''
graphvizRef.current.appendChild(svgElement)
// 渲染成功,清除错误记录
setError(null)
} catch (error) {
setError((error as Error).message || 'DOT syntax error or rendering failed')
} finally {
setIsLoading(false)
}
}, [])
// debounce 渲染
const debouncedRender = useMemo(
() =>
debounce((content: string) => {
startTransition(() => renderGraphviz(content))
}, 300),
[renderGraphviz]
)
// 触发渲染
useEffect(() => {
if (children) {
setIsLoading(true)
debouncedRender(children)
} else {
debouncedRender.cancel()
setIsLoading(false)
}
return () => {
debouncedRender.cancel()
}
}, [children, debouncedRender])
return (
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
{error && <PreviewError>{error}</PreviewError>}
<StyledGraphviz ref={graphvizRef} className="graphviz special-preview" />
</Flex>
</Spin>
)
}
const StyledGraphviz = styled.div`
overflow: auto;
`
export default memo(GraphvizPreview)

View File

@@ -1,70 +0,0 @@
import { ExpandOutlined, LinkOutlined } from '@ant-design/icons'
import { AppLogo } from '@renderer/config/env'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { extractTitle } from '@renderer/utils/formats'
import { Button } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
html: string
}
const Artifacts: FC<Props> = ({ html }) => {
const { t } = useTranslation()
const { openMinapp } = useMinappPopup()
/**
* 在应用内打开
*/
const handleOpenInApp = async () => {
const path = await window.api.file.createTempFile('artifacts-preview.html')
await window.api.file.write(path, html)
const filePath = `file://${path}`
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
openMinapp({
id: 'artifacts-preview',
name: title,
logo: AppLogo,
url: filePath
})
}
/**
* 外部链接打开
*/
const handleOpenExternal = async () => {
const path = await window.api.file.createTempFile('artifacts-preview.html')
await window.api.file.write(path, html)
const filePath = `file://${path}`
if (window.api.shell && window.api.shell.openExternal) {
window.api.shell.openExternal(filePath)
} else {
console.error(t('artifacts.preview.openExternal.error.content'))
}
}
return (
<Container>
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
{t('chat.artifacts.button.preview')}
</Button>
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
{t('chat.artifacts.button.openExternal')}
</Button>
</Container>
)
}
const Container = styled.div`
margin: 10px;
display: flex;
flex-direction: row;
gap: 8px;
padding-bottom: 10px;
`
export default Artifacts

View File

@@ -0,0 +1,404 @@
import { CodeOutlined, LinkOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { extractTitle } from '@renderer/utils/formats'
import { Button } from 'antd'
import { Code, Download, Globe, Sparkles } from 'lucide-react'
import { FC, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ClipLoader } from 'react-spinners'
import styled, { keyframes } from 'styled-components'
import HtmlArtifactsPopup from './HtmlArtifactsPopup'
interface Props {
html: string
}
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
const { t } = useTranslation()
const title = extractTitle(html) || 'HTML Artifacts'
const [isPopupOpen, setIsPopupOpen] = useState(false)
const { theme } = useTheme()
const htmlContent = html || ''
const hasContent = htmlContent.trim().length > 0
// 判断是否正在流式生成的逻辑
const isStreaming = useMemo(() => {
if (!hasContent) return false
const trimmedHtml = htmlContent.trim()
// 提前检查:如果包含关键的结束标签,直接判断为完整文档
if (/<\/html\s*>/i.test(trimmedHtml)) {
return false
}
// 如果同时包含 DOCTYPE 和 </body>,通常也是完整文档
if (/<!DOCTYPE\s+html/i.test(trimmedHtml) && /<\/body\s*>/i.test(trimmedHtml)) {
return false
}
// 检查 HTML 是否看起来是完整的
const indicators = {
// 1. 检查常见的 HTML 结构完整性
hasHtmlTag: /<html[^>]*>/i.test(trimmedHtml),
hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml),
// 2. 检查 body 标签完整性
hasBodyTag: /<body[^>]*>/i.test(trimmedHtml),
hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml),
// 3. 检查是否以未闭合的标签结尾
endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml),
// 4. 检查是否有未配对的标签
hasUnmatchedTags: checkUnmatchedTags(trimmedHtml),
// 5. 检查是否以常见的"流式结束"模式结尾
endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml)
}
// 如果有明显的未完成标志,则认为正在生成
if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) {
return true
}
// 如果有 HTML 结构但不完整
if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) {
return true
}
// 如果有 body 结构但不完整
if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) {
return true
}
// 对于简单的 HTML 片段,检查是否看起来是完整的
if (!indicators.hasHtmlTag && !indicators.hasBodyTag) {
// 如果是简单片段且没有明显的结束标志,可能还在生成
return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500
}
return false
}, [htmlContent, hasContent])
// 检查未配对标签的辅助函数
function checkUnmatchedTags(html: string): boolean {
const stack: string[] = []
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
// HTML5 void 元素(自闭合元素)的完整列表
const voidElements = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr'
]
let match
while ((match = tagRegex.exec(html)) !== null) {
const [fullTag, tagName] = match
const isClosing = fullTag.startsWith('</')
const isSelfClosing = fullTag.endsWith('/>') || voidElements.includes(tagName.toLowerCase())
if (isSelfClosing) continue
if (isClosing) {
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
return true // 找到不匹配的闭合标签
}
} else {
stack.push(tagName.toLowerCase())
}
}
return stack.length > 0 // 还有未闭合的标签
}
// 获取格式化的代码预览
function getFormattedCodePreview(html: string): string {
const trimmed = html.trim()
const lines = trimmed.split('\n')
const lastFewLines = lines.slice(-3) // 显示最后3行
return lastFewLines.join('\n')
}
/**
* 在编辑器中打开
*/
const handleOpenInEditor = () => {
setIsPopupOpen(true)
}
/**
* 关闭弹窗
*/
const handleClosePopup = () => {
setIsPopupOpen(false)
}
/**
* 外部链接打开
*/
const handleOpenExternal = async () => {
const path = await window.api.file.createTempFile('artifacts-preview.html')
await window.api.file.write(path, htmlContent)
const filePath = `file://${path}`
if (window.api.shell && window.api.shell.openExternal) {
window.api.shell.openExternal(filePath)
} else {
console.error(t('artifacts.preview.openExternal.error.content'))
}
}
/**
* 下载到本地
*/
const handleDownload = async () => {
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
await window.api.file.save(fileName, htmlContent)
window.message.success({ content: t('message.download.success'), key: 'download' })
}
return (
<>
<Container $isStreaming={isStreaming}>
<Header>
<IconWrapper $isStreaming={isStreaming}>
{isStreaming ? <Sparkles size={20} color="white" /> : <Globe size={20} color="white" />}
</IconWrapper>
<TitleSection>
<Title>{title}</Title>
<TypeBadge>
<Code size={12} />
<span>HTML</span>
</TypeBadge>
</TitleSection>
</Header>
<Content>
{isStreaming && !hasContent ? (
<GeneratingContainer>
<ClipLoader size={20} color="var(--color-primary)" />
<GeneratingText>{t('html_artifacts.generating_content', 'Generating content...')}</GeneratingText>
</GeneratingContainer>
) : isStreaming && hasContent ? (
<>
<TerminalPreview $theme={theme}>
<TerminalContent $theme={theme}>
<TerminalLine>
<TerminalPrompt $theme={theme}>$</TerminalPrompt>
<TerminalCodeLine $theme={theme}>
{getFormattedCodePreview(htmlContent)}
<TerminalCursor $theme={theme} />
</TerminalCodeLine>
</TerminalLine>
</TerminalContent>
</TerminalPreview>
<ButtonContainer>
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary">
{t('chat.artifacts.button.preview')}
</Button>
</ButtonContainer>
</>
) : (
<ButtonContainer>
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary" disabled={!hasContent}>
{t('chat.artifacts.button.preview')}
</Button>
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} disabled={!hasContent}>
{t('chat.artifacts.button.openExternal')}
</Button>
<Button icon={<Download size={16} />} onClick={handleDownload} disabled={!hasContent}>
{t('code_block.download')}
</Button>
</ButtonContainer>
)}
</Content>
</Container>
{/* 弹窗组件 */}
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={handleClosePopup} />
</>
)
}
const shimmer = keyframes`
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
`
const Container = styled.div<{ $isStreaming: boolean }>`
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
margin: 10px 0;
`
const GeneratingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 20px;
min-height: 78px;
`
const GeneratingText = styled.div`
font-size: 14px;
color: var(--color-text-secondary);
`
const Header = styled.div`
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px 16px;
background: var(--color-background-soft);
border-bottom: 1px solid var(--color-border);
position: relative;
border-radius: 8px 8px 0 0;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4);
background-size: 200% 100%;
animation: ${shimmer} 3s ease-in-out infinite;
border-radius: 8px 8px 0 0;
}
`
const IconWrapper = styled.div<{ $isStreaming: boolean }>`
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
border-radius: 12px;
color: white;
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
transition: background 0.3s ease;
${(props) =>
props.$isStreaming &&
`
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */
box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3);
`}
`
const TitleSection = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
`
const Title = styled.h3`
margin: 0 !important;
font-size: 16px;
font-weight: 600;
color: var(--color-text);
line-height: 1.4;
`
const TypeBadge = styled.div`
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
background: var(--color-background-mute);
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 10px;
font-weight: 500;
color: var(--color-text-secondary);
width: fit-content;
`
const Content = styled.div`
padding: 0;
background: var(--color-background);
`
const ButtonContainer = styled.div`
margin: 16px !important;
display: flex;
flex-direction: row;
gap: 8px;
`
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
margin: 16px;
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
border-radius: 8px;
overflow: hidden;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
`
const TerminalContent = styled.div<{ $theme: ThemeMode }>`
padding: 12px;
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
font-size: 13px;
line-height: 1.4;
min-height: 80px;
`
const TerminalLine = styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
`
const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>`
flex: 1;
white-space: pre-wrap;
word-break: break-word;
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
background-color: transparent !important;
`
const TerminalPrompt = styled.span<{ $theme: ThemeMode }>`
color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
font-weight: bold;
flex-shrink: 0;
`
const TerminalCursor = styled.span<{ $theme: ThemeMode }>`
display: inline-block;
width: 2px;
height: 16px;
background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
animation: ${keyframes`
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
`} 1s infinite;
margin-left: 2px;
`
export default HtmlArtifactsCard

View File

@@ -0,0 +1,459 @@
import CodeEditor from '@renderer/components/CodeEditor'
import { isMac } from '@renderer/config/constant'
import { classNames } from '@renderer/utils'
import { Button, Modal } from 'antd'
import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface HtmlArtifactsPopupProps {
open: boolean
title: string
html: string
onClose: () => void
}
type ViewMode = 'split' | 'code' | 'preview'
// 视图模式配置
const VIEW_MODE_CONFIG = {
split: {
key: 'split' as const,
icon: MonitorSpeaker,
i18nKey: 'html_artifacts.split'
},
code: {
key: 'code' as const,
icon: Code,
i18nKey: 'html_artifacts.code'
},
preview: {
key: 'preview' as const,
icon: Monitor,
i18nKey: 'html_artifacts.preview'
}
} as const
// 抽取头部组件
interface ModalHeaderProps {
title: string
isFullscreen: boolean
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
onToggleFullscreen: () => void
onCancel: () => void
}
const ModalHeaderComponent: React.FC<ModalHeaderProps> = ({
title,
isFullscreen,
viewMode,
onViewModeChange,
onToggleFullscreen,
onCancel
}) => {
const { t } = useTranslation()
const viewButtons = useMemo(() => {
return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => (
<ViewButton
key={key}
size="small"
type={viewMode === key ? 'primary' : 'default'}
icon={<Icon size={14} />}
onClick={() => onViewModeChange(key)}>
{t(i18nKey)}
</ViewButton>
))
}, [viewMode, onViewModeChange, t])
return (
<ModalHeader onDoubleClick={onToggleFullscreen} className={classNames({ drag: isFullscreen })}>
<HeaderLeft $isFullscreen={isFullscreen}>
<TitleText>{title}</TitleText>
</HeaderLeft>
<HeaderCenter>
<ViewControls>{viewButtons}</ViewControls>
</HeaderCenter>
<HeaderRight>
<Button
onClick={onToggleFullscreen}
type="text"
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
className="nodrag"
/>
<Button onClick={onCancel} type="text" icon={<X size={16} />} className="nodrag" />
</HeaderRight>
</ModalHeader>
)
}
// 抽取代码编辑器组件
interface CodeSectionProps {
html: string
visible: boolean
onCodeChange: (code: string) => void
}
const CodeSectionComponent: React.FC<CodeSectionProps> = ({ html, visible, onCodeChange }) => {
if (!visible) return null
return (
<CodeSection $visible={visible}>
<CodeEditorWrapper>
<CodeEditor
value={html}
language="html"
editable={true}
onSave={onCodeChange}
style={{ height: '100%' }}
options={{
stream: false,
collapsible: false
}}
/>
</CodeEditorWrapper>
</CodeSection>
)
}
// 抽取预览组件
interface PreviewSectionProps {
html: string
visible: boolean
}
const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible }) => {
const htmlContent = html || ''
const [debouncedHtml, setDebouncedHtml] = useState(htmlContent)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const latestHtmlRef = useRef(htmlContent)
const currentRenderedHtmlRef = useRef(htmlContent)
const { t } = useTranslation()
// 更新最新的HTML内容引用
useEffect(() => {
latestHtmlRef.current = htmlContent
}, [htmlContent])
// 固定频率渲染 HTML 内容每2秒钟检查并更新一次
useEffect(() => {
// 立即设置初始内容
setDebouncedHtml(htmlContent)
currentRenderedHtmlRef.current = htmlContent
// 设置定时器每2秒检查一次内容是否有变化
intervalRef.current = setInterval(() => {
if (latestHtmlRef.current !== currentRenderedHtmlRef.current) {
setDebouncedHtml(latestHtmlRef.current)
currentRenderedHtmlRef.current = latestHtmlRef.current
}
}, 2000) // 2秒固定频率
// 清理函数
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, []) // 只在组件挂载时执行一次
if (!visible) return null
const isHtmlEmpty = !debouncedHtml.trim()
return (
<PreviewSection $visible={visible}>
{isHtmlEmpty ? (
<EmptyPreview>
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
</EmptyPreview>
) : (
<PreviewFrame
key={debouncedHtml} // 强制重新创建iframe当内容变化时
srcDoc={debouncedHtml}
title="HTML Preview"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
)}
</PreviewSection>
)
}
// 主弹窗组件
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
const [viewMode, setViewMode] = useState<ViewMode>('split')
const [currentHtml, setCurrentHtml] = useState(html)
const [isFullscreen, setIsFullscreen] = useState(false)
// 当外部html更新时同步更新内部状态
useEffect(() => {
setCurrentHtml(html)
}, [html])
// 计算视图可见性
const viewVisibility = useMemo(
() => ({
code: viewMode === 'split' || viewMode === 'code',
preview: viewMode === 'split' || viewMode === 'preview'
}),
[viewMode]
)
// 计算Modal属性
const modalProps = useMemo(
() => ({
width: isFullscreen ? '100vw' : '90vw',
height: isFullscreen ? '100vh' : 'auto',
style: { maxWidth: isFullscreen ? '100vw' : '1400px' }
}),
[isFullscreen]
)
const handleOk = useCallback(() => {
onClose()
}, [onClose])
const handleCancel = useCallback(() => {
onClose()
}, [onClose])
const handleClose = useCallback(() => {
onClose()
}, [onClose])
const handleCodeChange = useCallback((newCode: string) => {
setCurrentHtml(newCode)
}, [])
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev)
}, [])
const handleViewModeChange = useCallback((mode: ViewMode) => {
setViewMode(mode)
}, [])
return (
<StyledModal
$isFullscreen={isFullscreen}
title={
<ModalHeaderComponent
title={title}
isFullscreen={isFullscreen}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onToggleFullscreen={toggleFullscreen}
onCancel={handleCancel}
/>
}
open={open}
onOk={handleOk}
onCancel={handleCancel}
afterClose={handleClose}
centered
destroyOnClose
{...modalProps}
footer={null}
closable={false}>
<Container>
<CodeSectionComponent html={currentHtml} visible={viewVisibility.code} onCodeChange={handleCodeChange} />
<PreviewSectionComponent html={currentHtml} visible={viewVisibility.preview} />
</Container>
</StyledModal>
)
}
// 样式组件保持不变
const commonModalBodyStyles = `
padding: 0 !important;
display: flex !important;
flex-direction: column !important;
`
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
${(props) =>
props.$isFullscreen
? `
.ant-modal-wrap {
padding: 0 !important;
}
.ant-modal {
margin: 0 !important;
padding: 0 !important;
max-width: none !important;
}
.ant-modal-body {
height: calc(100vh - 45px) !important;
${commonModalBodyStyles}
max-height: initial !important;
}
`
: `
.ant-modal-body {
height: 80vh !important;
${commonModalBodyStyles}
min-height: 600px !important;
}
`}
.ant-modal-body {
${commonModalBodyStyles}
}
.ant-modal-content {
border-radius: ${(props) => (props.$isFullscreen ? '0px' : '12px')};
overflow: hidden;
height: ${(props) => (props.$isFullscreen ? '100vh' : 'auto')};
padding: 0 !important;
}
.ant-modal-header {
padding: 10px 12px !important;
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 0 !important;
margin-bottom: 0 !important;
}
.ant-modal-title {
margin: 0;
width: 100%;
}
`
const ModalHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
position: relative;
`
const HeaderLeft = styled.div<{ $isFullscreen?: boolean }>`
flex: 1;
min-width: 0;
padding-left: ${(props) => (props.$isFullscreen && isMac ? '65px' : '12px')};
`
const HeaderCenter = styled.div`
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
z-index: 1;
`
const HeaderRight = styled.div`
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
`
const TitleText = styled.span`
font-size: 16px;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`
const ViewControls = styled.div`
display: flex;
width: auto;
gap: 8px;
padding: 4px;
background: var(--color-background-mute);
border-radius: 8px;
border: 1px solid var(--color-border);
-webkit-app-region: no-drag;
`
const ViewButton = styled(Button)`
border: none;
box-shadow: none;
&.ant-btn-primary {
background: var(--color-primary);
color: white;
}
&.ant-btn-default {
background: transparent;
color: var(--color-text-secondary);
&:hover {
background: var(--color-background);
color: var(--color-text);
}
}
`
const Container = styled.div`
display: flex;
height: 100%;
width: 100%;
flex: 1;
background: var(--color-background);
`
const CodeSection = styled.div<{ $visible: boolean }>`
flex: ${(props) => (props.$visible ? '1' : '0')};
min-width: ${(props) => (props.$visible ? '300px' : '0')};
border-right: ${(props) => (props.$visible ? '1px solid var(--color-border)' : 'none')};
overflow: hidden;
display: ${(props) => (props.$visible ? 'flex' : 'none')};
flex-direction: column;
`
const CodeEditorWrapper = styled.div`
flex: 1;
height: 100%;
overflow: hidden;
.monaco-editor {
height: 100% !important;
}
.cm-editor {
height: 100% !important;
}
.cm-scroller {
height: 100% !important;
}
`
const PreviewSection = styled.div<{ $visible: boolean }>`
flex: ${(props) => (props.$visible ? '1' : '0')};
min-width: ${(props) => (props.$visible ? '300px' : '0')};
background: white;
overflow: hidden;
display: ${(props) => (props.$visible ? 'block' : 'none')};
`
const PreviewFrame = styled.iframe`
width: 100%;
height: 100%;
border: none;
background: white;
`
const EmptyPreview = styled.div`
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: var(--color-background-soft);
color: var(--color-text-secondary);
font-size: 14px;
`
export default HtmlArtifactsPopup

View File

@@ -1,5 +1,5 @@
import { nanoid } from '@reduxjs/toolkit'
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { Flex, Spin } from 'antd'
@@ -7,16 +7,14 @@ import { debounce } from 'lodash'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
import PreviewError from './PreviewError'
import { BasicPreviewProps } from './types'
/** 预览 Mermaid 图表
* 通过防抖渲染提供比较统一的体验,减少闪烁。
* FIXME: 等将来容易判断代码块结束位置时再重构。
*/
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
const MermaidPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
const mermaidRef = useRef<HTMLDivElement>(null)
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
@@ -143,8 +141,8 @@ const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
return (
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
<StyledMermaid ref={mermaidRef} className="mermaid" />
{(mermaidError || error) && <PreviewError>{mermaidError || error}</PreviewError>}
<StyledMermaid ref={mermaidRef} className="mermaid special-preview" />
</Flex>
</Spin>
)
@@ -154,14 +152,4 @@ const StyledMermaid = styled.div`
overflow: auto;
`
const StyledError = styled.div`
overflow: auto;
padding: 16px;
color: #ff4d4f;
border: 1px solid #ff4d4f;
border-radius: 4px;
word-wrap: break-word;
white-space: pre-wrap;
`
export default memo(MermaidPreview)

View File

@@ -1,11 +1,13 @@
import { LoadingOutlined } from '@ant-design/icons'
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { Spin } from 'antd'
import pako from 'pako'
import React, { memo, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { BasicPreviewProps } from './types'
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
function encode64(data: Uint8Array) {
let r = ''
@@ -132,12 +134,7 @@ const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagr
)
}
interface PlantUMLProps {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
const PlantUmlPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
@@ -174,7 +171,7 @@ const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
return (
<div ref={containerRef}>
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview" />
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview special-preview" />
</div>
)
}

View File

@@ -0,0 +1,14 @@
import { memo } from 'react'
import { styled } from 'styled-components'
const PreviewError = styled.div`
overflow: auto;
padding: 16px;
color: #ff4d4f;
border: 1px solid #ff4d4f;
border-radius: 4px;
word-wrap: break-word;
white-space: pre-wrap;
`
export default memo(PreviewError)

View File

@@ -1,15 +1,12 @@
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { memo, useEffect, useRef } from 'react'
interface Props {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
import { BasicPreviewProps } from './types'
/**
* 使用 Shadow DOM 渲染 SVG
*/
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
const SvgPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const svgContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -58,7 +55,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
handleDownload
})
return <div ref={svgContainerRef} className="svg-preview" />
return <div ref={svgContainerRef} className="svg-preview special-preview" />
}
export default memo(SvgPreview)

View File

@@ -0,0 +1,20 @@
import GraphvizPreview from './GraphvizPreview'
import MermaidPreview from './MermaidPreview'
import PlantUmlPreview from './PlantUmlPreview'
import SvgPreview from './SvgPreview'
/**
* 特殊视图语言列表
*/
export const SPECIAL_VIEWS = ['mermaid', 'plantuml', 'svg', 'dot', 'graphviz']
/**
* 特殊视图组件映射表
*/
export const SPECIAL_VIEW_COMPONENTS = {
mermaid: MermaidPreview,
plantuml: PlantUmlPreview,
svg: SvgPreview,
dot: GraphvizPreview,
graphviz: GraphvizPreview
} as const

View File

@@ -0,0 +1,2 @@
export * from './types'
export * from './view'

View File

@@ -0,0 +1,14 @@
import { CodeTool } from '@renderer/components/CodeToolbar'
/**
* 预览组件的基本 props
*/
export interface BasicPreviewProps {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
/**
* 视图模式
*/
export type ViewMode = 'source' | 'special' | 'split'

View File

@@ -4,7 +4,7 @@ import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/compon
import { useSettings } from '@renderer/hooks/useSettings'
import { pyodideService } from '@renderer/services/PyodideService'
import { extractTitle } from '@renderer/utils/formats'
import { getExtensionByLanguage, isValidPlantUML } from '@renderer/utils/markdown'
import { getExtensionByLanguage, isHtmlCode, isValidPlantUML } from '@renderer/utils/markdown'
import dayjs from 'dayjs'
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
@@ -12,13 +12,10 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CodePreview from './CodePreview'
import HtmlArtifacts from './HtmlArtifacts'
import MermaidPreview from './MermaidPreview'
import PlantUmlPreview from './PlantUmlPreview'
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
import HtmlArtifactsCard from './HtmlArtifactsCard'
import StatusBar from './StatusBar'
import SvgPreview from './SvgPreview'
type ViewMode = 'source' | 'special' | 'split'
import { ViewMode } from './types'
interface Props {
children: string
@@ -42,9 +39,10 @@ interface Props {
* - quick
* - core
*/
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
const { t } = useTranslation()
const { codeEditor, codeExecution } = useSettings()
const [viewMode, setViewMode] = useState<ViewMode>('special')
const [isRunning, setIsRunning] = useState(false)
const [output, setOutput] = useState('')
@@ -56,7 +54,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
return codeExecution.enabled && language === 'python'
}, [codeExecution.enabled, language])
const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language])
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
const isInSpecialView = useMemo(() => {
return hasSpecialView && viewMode === 'special'
@@ -200,14 +198,16 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
// 特殊视图组件映射
const specialView = useMemo(() => {
if (language === 'mermaid') {
return <MermaidPreview setTools={setTools}>{children}</MermaidPreview>
} else if (language === 'plantuml' && isValidPlantUML(children)) {
return <PlantUmlPreview setTools={setTools}>{children}</PlantUmlPreview>
} else if (language === 'svg') {
return <SvgPreview setTools={setTools}>{children}</SvgPreview>
const SpecialView = SPECIAL_VIEW_COMPONENTS[language as keyof typeof SPECIAL_VIEW_COMPONENTS]
if (!SpecialView) return null
// PlantUML 语法验证
if (language === 'plantuml' && !isValidPlantUML(children)) {
return null
}
return null
return <SpecialView setTools={setTools}>{children}</SpecialView>
}, [children, language])
const renderHeader = useMemo(() => {
@@ -228,27 +228,29 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
)
}, [specialView, sourceView, viewMode])
const renderArtifacts = useMemo(() => {
if (language === 'html') {
return <HtmlArtifacts html={children} />
}
return null
}, [children, language])
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
if (language === 'html' && isHtmlCode(children)) {
return <HtmlArtifactsCard html={children} />
}
return (
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
{renderHeader}
<CodeToolbar tools={tools} />
{renderContent}
{renderArtifacts}
{isExecutable && output && <StatusBar>{output}</StatusBar>}
</CodeBlockWrapper>
)
}
})
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
position: relative;
width: 100%;
/* FIXME:
* CodePreview
* toolbar title
*/
min-width: 45ch;
.code-toolbar {
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
@@ -295,5 +297,3 @@ const SplitViewWrapper = styled.div`
overflow: hidden;
}
`
export default memo(CodeBlockView)

View File

@@ -12,45 +12,111 @@ const linterLoaders: Record<string, () => Promise<any>> = {
}
}
/**
* 特殊语言加载器
*/
const specialLanguageLoaders: Record<string, () => Promise<Extension>> = {
dot: async () => {
const mod = await import('@viz-js/lang-dot')
return mod.dot()
}
}
/**
* 加载语言扩展
*/
async function loadLanguageExtension(language: string, languageMap: Record<string, string>): Promise<Extension | null> {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
// 尝试加载特殊语言
const specialLoader = specialLanguageLoaders[normalizedLang]
if (specialLoader) {
try {
return await specialLoader()
} catch (error) {
console.debug(`Failed to load language ${normalizedLang}`, error)
return null
}
}
// 回退到 uiw/codemirror 包含的语言
try {
const { loadLanguage } = await import('@uiw/codemirror-extensions-langs')
const extension = loadLanguage(normalizedLang as any)
return extension || null
} catch (error) {
console.debug(`Failed to load language ${normalizedLang}`, error)
return null
}
}
/**
* 加载 linter 扩展
*/
async function loadLinterExtension(language: string): Promise<Extension | null> {
const loader = linterLoaders[language]
if (!loader) return null
try {
return await loader()
} catch (error) {
console.debug(`Failed to load linter for ${language}`, error)
return null
}
}
/**
* 加载语言相关扩展
*/
export const useLanguageExtensions = (language: string, lint?: boolean) => {
const { languageMap } = useCodeStyle()
const [extensions, setExtensions] = useState<Extension[]>([])
// 加载语言
useEffect(() => {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
let cancelled = false
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
const loadAllExtensions = async () => {
try {
// 加载所有扩展
const [languageResult, linterResult] = await Promise.allSettled([
loadLanguageExtension(language, languageMap),
lint ? loadLinterExtension(language) : Promise.resolve(null)
])
import('@uiw/codemirror-extensions-langs')
.then(({ loadLanguage }) => {
const extension = loadLanguage(normalizedLang as any)
if (extension) {
setExtensions((prev) => [...prev, extension])
if (cancelled) return
const results: Extension[] = []
// 语言扩展
if (languageResult.status === 'fulfilled' && languageResult.value) {
results.push(languageResult.value)
}
})
.catch((error) => {
console.debug(`Failed to load language: ${normalizedLang}`, error)
})
}, [language, languageMap])
useEffect(() => {
if (!lint) return
// linter 扩展
if (linterResult.status === 'fulfilled' && linterResult.value) {
results.push(linterResult.value)
}
const loader = linterLoaders[language]
if (loader) {
loader()
.then((extension) => {
setExtensions((prev) => [...prev, extension])
})
.catch((error) => {
console.error(`Failed to load linter for ${language}`, error)
})
setExtensions(results)
} catch (error) {
if (!cancelled) {
console.debug('Failed to load language extensions:', error)
setExtensions([])
}
}
}
}, [language, lint])
loadAllExtensions()
return () => {
cancelled = true
}
}, [language, lint, languageMap])
return extensions
}

View File

@@ -42,6 +42,7 @@ interface Props {
extensions?: Extension[]
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
style?: React.CSSProperties
editable?: boolean
}
/**
@@ -62,7 +63,8 @@ const CodeEditor = ({
maxHeight,
options,
extensions,
style
style,
editable = true
}: Props) => {
const {
fontSize,
@@ -190,7 +192,7 @@ const CodeEditor = ({
height={height}
minHeight={minHeight}
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
editable={true}
editable={editable}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
extensions={customExtensions}

View File

@@ -140,7 +140,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
const [isWholeWord, setIsWholeWord] = useState(false)
const [allRanges, setAllRanges] = useState<Range[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const [currentIndex, setCurrentIndex] = useState(-1)
const prevSearchText = useRef('')
const { t } = useTranslation()
@@ -182,15 +182,18 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
[allRanges, currentIndex]
)
const search = useCallback(() => {
const searchText = searchInputRef.current?.value.trim() ?? null
setSearchCompleted(SearchCompletedState.Searched)
if (target && searchText !== null && searchText !== '') {
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
setAllRanges(ranges)
setCurrentIndex(0)
}
}, [target, filter, isCaseSensitive, isWholeWord])
const search = useCallback(
(jump = false) => {
const searchText = searchInputRef.current?.value.trim() ?? null
setSearchCompleted(SearchCompletedState.Searched)
if (target && searchText !== null && searchText !== '') {
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
setAllRanges(ranges)
setCurrentIndex(jump && ranges.length > 0 ? 0 : -1)
}
},
[target, filter, isCaseSensitive, isWholeWord]
)
const implementation = useMemo(
() => ({
@@ -207,7 +210,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
search()
search(false)
})
} else {
requestAnimationFrame(() => {
@@ -231,11 +234,11 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
setSearchCompleted(SearchCompletedState.NotSearched)
},
search: () => {
search()
search(true)
locateByIndex(true)
},
silentSearch: () => {
search()
search(false)
locateByIndex(false)
},
focus: () => {
@@ -302,7 +305,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
useEffect(() => {
if (enableContentSearch && searchInputRef.current?.value.trim()) {
search()
search(true)
}
}, [isCaseSensitive, isWholeWord, enableContentSearch, search])
@@ -365,16 +368,12 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
</InputWrapper>
<Separator></Separator>
<SearchResults>
{searchCompleted !== SearchCompletedState.NotSearched ? (
allRanges.length > 0 ? (
<>
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
<SearchResultSeparator>/</SearchResultSeparator>
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
</>
) : (
<NoResults>{t('common.no_results')}</NoResults>
)
{searchCompleted !== SearchCompletedState.NotSearched && allRanges.length > 0 ? (
<>
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
<SearchResultSeparator>/</SearchResultSeparator>
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
</>
) : (
<SearchResultsPlaceholder>0/0</SearchResultsPlaceholder>
)}
@@ -477,10 +476,6 @@ const SearchResultsPlaceholder = styled.span`
opacity: 0.5;
`
const NoResults = styled.span`
color: var(--color-text-1);
`
const SearchResultCount = styled.span`
color: var(--color-text);
`

View File

@@ -0,0 +1,17 @@
import { MAX_CONTEXT_COUNT } from '@renderer/config/constant'
import { Infinity as InfinityIcon } from 'lucide-react'
import { CSSProperties } from 'react'
type Props = {
maxContext: number
style?: CSSProperties
size?: number
}
export default function MaxContextCount({ maxContext, style, size = 14 }: Props) {
return maxContext === MAX_CONTEXT_COUNT ? (
<InfinityIcon size={size} style={style} aria-label="infinity" />
) : (
<span style={style}>{maxContext.toString()}</span>
)
}

View File

@@ -10,6 +10,7 @@ import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from
import { Trash } from 'lucide-react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { isLlmProvider, useApiKeys } from './hook'
import ApiKeyItem from './item'
@@ -87,7 +88,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
: keys
return (
<>
<ListContainer>
{/* Keys 列表 */}
<Card
size="small"
@@ -122,7 +123,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
)}
</Card>
<Flex align="center" justify="space-between" style={{ marginTop: '0.5rem' }}>
<Flex dir="row" align="center" justify="space-between" style={{ marginTop: 15 }}>
{/* 帮助文本 */}
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
@@ -166,7 +167,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
</Button>
</Space>
</Flex>
</>
</ListContainer>
)
}
@@ -222,3 +223,8 @@ export const DocPreprocessApiKeyList: FC<SpecificApiKeyListProps> = ({
/>
)
}
const ListContainer = styled.div`
padding-top: 15px;
padding-bottom: 15px;
`

View File

@@ -54,7 +54,7 @@ const FloatingSidebar: FC<Props> = ({
style={{
background: 'transparent',
border: 'none',
maxHeight: maxHeight
height: '100%'
}}
/>
</PopoverContent>
@@ -82,6 +82,9 @@ const FloatingSidebar: FC<Props> = ({
const PopoverContent = styled.div<{ maxHeight: number }>`
max-height: ${(props) => props.maxHeight}px;
&.ant-popover-inner-content {
overflow-y: hidden;
}
`
export default FloatingSidebar

View File

@@ -1,82 +0,0 @@
import { Center } from '@renderer/components/Layout'
import { useMinapps } from '@renderer/hooks/useMinapps'
import App from '@renderer/pages/apps/App'
import { Popover } from 'antd'
import { Empty } from 'antd'
import { isEmpty } from 'lodash'
import { FC, useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import styled from 'styled-components'
import Scrollbar from '../Scrollbar'
interface Props {
children: React.ReactNode
}
const MinAppsPopover: FC<Props> = ({ children }) => {
const [open, setOpen] = useState(false)
const { minapps } = useMinapps()
useHotkeys('esc', () => {
setOpen(false)
})
const handleClose = () => {
setOpen(false)
}
const [maxHeight, setMaxHeight] = useState(window.innerHeight - 100)
useEffect(() => {
const handleResize = () => {
setMaxHeight(window.innerHeight - 100)
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
const content = (
<PopoverContent maxHeight={maxHeight}>
<AppsContainer>
{minapps.map((app) => (
<App key={app.id} app={app} onClick={handleClose} size={50} />
))}
{isEmpty(minapps) && (
<Center>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
)}
</AppsContainer>
</PopoverContent>
)
return (
<Popover
open={open}
onOpenChange={setOpen}
content={content}
trigger="click"
placement="bottomRight"
styles={{ body: { padding: 25 } }}>
{children}
</Popover>
)
}
const PopoverContent = styled(Scrollbar)<{ maxHeight: number }>`
max-height: ${(props) => props.maxHeight}px;
overflow-y: auto;
`
const AppsContainer = styled.div`
display: grid;
grid-template-columns: repeat(8, minmax(90px, 1fr));
gap: 18px;
`
export default MinAppsPopover

View File

@@ -0,0 +1,353 @@
import CustomTag from '@renderer/components/CustomTag'
import { TopView } from '@renderer/components/TopView'
import Logger from '@renderer/config/logger'
import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { Message } from '@renderer/types/newMessage'
import {
analyzeMessageContent,
CONTENT_TYPES,
ContentType,
MessageContentStats,
processMessageContent
} from '@renderer/utils/knowledge'
import { Flex, Form, Modal, Select, Tooltip, Typography } from 'antd'
import { Check, CircleHelp } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const { Text } = Typography
// 内容类型配置
const CONTENT_TYPE_CONFIG = {
[CONTENT_TYPES.TEXT]: {
label: 'chat.save.knowledge.content.maintext.title',
description: 'chat.save.knowledge.content.maintext.description'
},
[CONTENT_TYPES.CODE]: {
label: 'chat.save.knowledge.content.code.title',
description: 'chat.save.knowledge.content.code.description'
},
[CONTENT_TYPES.THINKING]: {
label: 'chat.save.knowledge.content.thinking.title',
description: 'chat.save.knowledge.content.thinking.description'
},
[CONTENT_TYPES.TOOL_USE]: {
label: 'chat.save.knowledge.content.tool_use.title',
description: 'chat.save.knowledge.content.tool_use.description'
},
[CONTENT_TYPES.CITATION]: {
label: 'chat.save.knowledge.content.citation.title',
description: 'chat.save.knowledge.content.citation.description'
},
[CONTENT_TYPES.TRANSLATION]: {
label: 'chat.save.knowledge.content.translation.title',
description: 'chat.save.knowledge.content.translation.description'
},
[CONTENT_TYPES.ERROR]: {
label: 'chat.save.knowledge.content.error.title',
description: 'chat.save.knowledge.content.error.description'
},
[CONTENT_TYPES.FILE]: {
label: 'chat.save.knowledge.content.file.title',
description: 'chat.save.knowledge.content.file.description'
}
} as const
// Tag 颜色常量
const TAG_COLORS = {
SELECTED: '#008001',
UNSELECTED: '#8c8c8c'
} as const
interface ContentTypeOption {
type: ContentType
label: string
count: number
enabled: boolean
description?: string
}
interface ShowParams {
message: Message
title?: string
}
interface SaveResult {
success: boolean
savedCount: number
}
interface Props extends ShowParams {
resolve: (data: SaveResult | null) => void
}
const PopupContainer: React.FC<Props> = ({ message, title, resolve }) => {
const [open, setOpen] = useState(true)
const [loading, setLoading] = useState(false)
const [selectedBaseId, setSelectedBaseId] = useState<string>()
const [selectedTypes, setSelectedTypes] = useState<ContentType[]>([])
const [hasInitialized, setHasInitialized] = useState(false)
const { bases } = useKnowledgeBases()
const { addNote, addFiles } = useKnowledge(selectedBaseId || '')
const { t } = useTranslation()
// 分析消息内容统计
const contentStats = useMemo(() => analyzeMessageContent(message), [message])
// 生成内容类型选项(只显示有内容的类型)
const contentTypeOptions: ContentTypeOption[] = useMemo(() => {
return Object.entries(CONTENT_TYPE_CONFIG)
.map(([type, config]) => {
const contentType = type as ContentType
const count = contentStats[contentType as keyof MessageContentStats] || 0
return {
type: contentType,
count,
enabled: count > 0,
label: t(config.label),
description: t(config.description)
}
})
.filter((option) => option.enabled) // 只显示有内容的类型
}, [contentStats, t])
// 知识库选项
const knowledgeBaseOptions = useMemo(
() =>
bases.map((base) => ({
label: base.name,
value: base.id,
disabled: !base.version // 如果知识库没有配置好就禁用
})),
[bases]
)
// 合并状态计算
const formState = useMemo(() => {
const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version
const hasContent = contentTypeOptions.length > 0
const selectedCount = contentTypeOptions
.filter((option) => selectedTypes.includes(option.type))
.reduce((sum, option) => sum + option.count, 0)
return {
hasValidBase,
hasContent,
canSubmit: hasValidBase && selectedTypes.length > 0 && hasContent,
selectedCount,
hasNoSelection: selectedTypes.length === 0 && hasContent
}
}, [selectedBaseId, bases, contentTypeOptions, selectedTypes])
// 默认选择第一个可用的知识库
useEffect(() => {
if (!selectedBaseId) {
const firstAvailableBase = bases.find((base) => base.version)
if (firstAvailableBase) {
setSelectedBaseId(firstAvailableBase.id)
}
}
}, [bases, selectedBaseId])
// 默认选择所有可用的内容类型(仅在初始化时)
useEffect(() => {
if (!hasInitialized && contentTypeOptions.length > 0) {
const availableTypes = contentTypeOptions.map((option) => option.type)
setSelectedTypes(availableTypes)
setHasInitialized(true)
}
}, [contentTypeOptions, hasInitialized])
// 计算UI状态
const uiState = useMemo(() => {
if (!formState.hasContent) {
return { type: 'empty', message: t('chat.save.knowledge.empty.no_content') }
}
if (bases.length === 0) {
return { type: 'empty', message: t('chat.save.knowledge.empty.no_knowledge_base') }
}
return { type: 'form' }
}, [formState.hasContent, bases.length, t])
// 处理内容类型选择切换
const handleContentTypeToggle = (type: ContentType) => {
setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]))
}
const onOk = async () => {
if (!formState.canSubmit) {
return
}
setLoading(true)
let savedCount = 0
try {
const result = processMessageContent(message, selectedTypes)
// 保存文本内容
if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) {
await addNote(result.text)
savedCount++
}
// 保存文件
if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) {
addFiles(result.files)
savedCount += result.files.length
}
setOpen(false)
resolve({ success: true, savedCount })
} catch (error) {
Logger.error('[SaveToKnowledgePopup] save failed:', error)
window.message.error(t('chat.save.knowledge.error.save_failed'))
setLoading(false)
}
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve(null)
}
// 渲染空状态
const renderEmptyState = () => (
<EmptyContainer>
<Text type="secondary">{uiState.message}</Text>
</EmptyContainer>
)
// 渲染表单内容
const renderFormContent = () => (
<>
<Form layout="vertical">
<Form.Item
label={t('chat.save.knowledge.select.base.title')}
help={!formState.hasValidBase && selectedBaseId ? t('chat.save.knowledge.error.invalid_base') : undefined}
validateStatus={!formState.hasValidBase && selectedBaseId ? 'error' : undefined}>
<Select
value={selectedBaseId}
onChange={setSelectedBaseId}
options={knowledgeBaseOptions}
placeholder={t('chat.save.knowledge.select.base.placeholder')}
showSearch
/>
</Form.Item>
<Form.Item label={t('chat.save.knowledge.select.content.title')}>
<Flex gap={8} style={{ flexDirection: 'column' }}>
{contentTypeOptions.map((option) => (
<ContentTypeItem
key={option.type}
align="center"
justify="space-between"
onClick={() => handleContentTypeToggle(option.type)}>
<Flex align="center" gap={8}>
<CustomTag
color={selectedTypes.includes(option.type) ? TAG_COLORS.SELECTED : TAG_COLORS.UNSELECTED}
size={12}>
{option.count}
</CustomTag>
<span>{option.label}</span>
<Tooltip title={option.description} mouseLeaveDelay={0}>
<CircleHelp size={16} style={{ cursor: 'help' }} />
</Tooltip>
</Flex>
{selectedTypes.includes(option.type) && <Check size={16} color={TAG_COLORS.SELECTED} />}
</ContentTypeItem>
))}
</Flex>
</Form.Item>
</Form>
{formState.selectedCount > 0 && (
<InfoContainer>
<Text type="secondary" style={{ fontSize: '12px' }}>
{t('chat.save.knowledge.select.content.tip', { count: formState.selectedCount })}
</Text>
</InfoContainer>
)}
{formState.hasNoSelection && (
<InfoContainer>
<Text type="warning" style={{ fontSize: '12px' }}>
{t('chat.save.knowledge.error.no_content_selected')}
</Text>
</InfoContainer>
)}
</>
)
return (
<Modal
title={title || t('chat.save.knowledge.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
destroyOnClose
centered
width={500}
okText={t('common.save')}
cancelText={t('common.cancel')}
okButtonProps={{
loading,
disabled: !formState.canSubmit
}}>
{uiState.type === 'empty' ? renderEmptyState() : renderFormContent()}
</Modal>
)
}
const TopViewKey = 'SaveToKnowledgePopup'
export default class SaveToKnowledgePopup {
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams): Promise<SaveResult | null> {
return new Promise<SaveResult | null>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(result) => {
resolve(result)
this.hide()
}}
/>,
TopViewKey
)
})
}
}
const EmptyContainer = styled.div`
text-align: center;
padding: 40px 20px;
`
const ContentTypeItem = styled(Flex)`
padding: 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.2s;
position: relative;
&:hover {
border-color: var(--color-primary);
}
`
const InfoContainer = styled.div`
background: var(--color-background-soft);
padding: 12px;
border-radius: 6px;
margin-top: 16px;
`

View File

@@ -27,6 +27,11 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const clearTimer = useRef<NodeJS.Timeout | null>(null)
// 添加更新item选中状态的方法
const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => {
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
}, [])
const open = useCallback((options: QuickPanelOpenOptions) => {
if (clearTimer.current) {
clearTimeout(clearTimer.current)
@@ -77,6 +82,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
() => ({
open,
close,
updateItemSelection,
isVisible,
symbol,
@@ -90,7 +96,21 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
beforeAction,
afterAction
}),
[open, close, isVisible, symbol, list, title, defaultIndex, pageSize, multiple, onClose, beforeAction, afterAction]
[
open,
close,
updateItemSelection,
isVisible,
symbol,
list,
title,
defaultIndex,
pageSize,
multiple,
onClose,
beforeAction,
afterAction
]
)
return <QuickPanelContext value={value}>{children}</QuickPanelContext>

View File

@@ -52,6 +52,7 @@ export type QuickPanelListItem = {
export interface QuickPanelContextType {
readonly open: (options: QuickPanelOpenOptions) => void
readonly close: (action?: QuickPanelCloseAction) => void
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
readonly isVisible: boolean
readonly symbol: string
readonly list: QuickPanelListItem[]

View File

@@ -50,7 +50,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const [isMouseOver, setIsMouseOver] = useState(false)
const scrollTriggerRef = useRef<QuickPanelScrollTrigger>('initial')
const [_index, setIndex] = useState(ctx.defaultIndex)
const [_index, setIndex] = useState(-1)
const index = useDeferredValue(_index)
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
@@ -62,6 +62,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const searchText = useDeferredValue(_searchText)
const searchTextRef = useRef('')
// 跟踪上一次的搜索文本和符号用于判断是否需要重置index
const prevSearchTextRef = useRef('')
const prevSymbolRef = useRef('')
// 处理搜索,过滤列表
const list = useMemo(() => {
if (!ctx.isVisible && !ctx.symbol) return []
@@ -104,7 +108,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
}
})
setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1)
// 只有在搜索文本变化或面板符号变化时才重置index
const isSearchChanged = prevSearchTextRef.current !== searchText
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
if (isSearchChanged || isSymbolChanged) {
setIndex(-1) // 不默认高亮任何项,让用户主动选择
} else {
// 如果当前index超出范围调整到有效范围内
setIndex((prevIndex) => {
if (prevIndex >= newList.length) {
return newList.length > 0 ? newList.length - 1 : -1
}
return prevIndex
})
}
prevSearchTextRef.current = searchText
prevSymbolRef.current = ctx.symbol
return newList
}, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText])
@@ -168,12 +189,33 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
(item: QuickPanelListItem, action?: QuickPanelCloseAction) => {
if (item.disabled) return
// 在多选模式下,先更新选中状态
if (ctx.multiple && !item.isMenu) {
const newSelectedState = !item.isSelected
ctx.updateItemSelection(item, newSelectedState)
// 创建更新后的item对象用于回调
const updatedItem = { ...item, isSelected: newSelectedState }
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
symbol: ctx.symbol,
action,
item: updatedItem,
searchText: searchText,
multiple: ctx.multiple
}
ctx.beforeAction?.(quickPanelCallBackOptions)
item?.action?.(quickPanelCallBackOptions)
ctx.afterAction?.(quickPanelCallBackOptions)
return
}
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
symbol: ctx.symbol,
action,
item,
searchText: searchText,
multiple: isAssistiveKeyPressed
multiple: ctx.multiple
}
ctx.beforeAction?.(quickPanelCallBackOptions)
@@ -200,11 +242,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return
}
if (ctx.multiple && isAssistiveKeyPressed) return
// 多选模式下不关闭面板
if (ctx.multiple) return
handleClose(action)
},
[ctx, searchText, isAssistiveKeyPressed, handleClose, clearSearchText, index]
[ctx, searchText, handleClose, clearSearchText, index]
)
useEffect(() => {
@@ -294,12 +337,16 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'keyboard'
if (isAssistiveKeyPressed) {
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? list.length - 1 : -1
const newIndex = prev - ctx.pageSize
if (prev === 0) return list.length - 1
return newIndex < 0 ? 0 : newIndex
})
} else {
setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1))
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? list.length - 1 : -1
return prev > 0 ? prev - 1 : list.length - 1
})
}
break
@@ -307,18 +354,23 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'keyboard'
if (isAssistiveKeyPressed) {
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? 0 : -1
const newIndex = prev + ctx.pageSize
if (prev + 1 === list.length) return 0
return newIndex >= list.length ? list.length - 1 : newIndex
})
} else {
setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0))
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? 0 : -1
return prev < list.length - 1 ? prev + 1 : 0
})
}
break
case 'PageUp':
scrollTriggerRef.current = 'keyboard'
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? Math.max(0, list.length - ctx.pageSize) : -1
const newIndex = prev - ctx.pageSize
return newIndex < 0 ? 0 : newIndex
})
@@ -327,6 +379,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
case 'PageDown':
scrollTriggerRef.current = 'keyboard'
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? Math.min(ctx.pageSize - 1, list.length - 1) : -1
const newIndex = prev + ctx.pageSize
return newIndex >= list.length ? list.length - 1 : newIndex
})
@@ -421,10 +474,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
(): VirtualizedRowData => ({
list,
focusedIndex: index,
handleItemAction,
setIndex
handleItemAction
}),
[list, index, handleItemAction, setIndex]
[list, index, handleItemAction]
)
return (
@@ -487,15 +539,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
<Flex align="center" gap={4}>
{t('settings.quickPanel.confirm')}
</Flex>
{ctx.multiple && (
<Flex align="center" gap={4}>
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
{ASSISTIVE_KEY}
</span>
+ {t('settings.quickPanel.multiple')}
</Flex>
)}
</QuickPanelFooterTips>
</QuickPanelFooter>
</QuickPanelBody>
@@ -507,7 +550,6 @@ interface VirtualizedRowData {
list: QuickPanelListItem[]
focusedIndex: number
handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void
setIndex: (index: number) => void
}
/**
@@ -515,7 +557,7 @@ interface VirtualizedRowData {
*/
const VirtualizedRow = React.memo(
({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => {
const { list, focusedIndex, handleItemAction, setIndex } = data
const { list, focusedIndex, handleItemAction } = data
const item = list[index]
if (!item) return null
@@ -531,8 +573,7 @@ const VirtualizedRow = React.memo(
onClick={(e) => {
e.stopPropagation()
handleItemAction(item, 'click')
}}
onMouseEnter={() => setIndex(index)}>
}}>
<QuickPanelItemLeft>
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
@@ -651,11 +692,19 @@ const QuickPanelItem = styled.div`
border-radius: 6px;
cursor: pointer;
transition: background-color 0.1s ease;
&:hover:not(.disabled) {
background-color: var(--focused-color);
}
&.selected {
background-color: var(--selected-color);
&.focused {
background-color: var(--selected-color-dark);
}
&:hover:not(.disabled) {
background-color: var(--selected-color-dark);
}
}
&.focused {
background-color: var(--focused-color);

View File

@@ -77,6 +77,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
<Tooltip
placement="top"
title={t('chat.input.translate', { target_language: getLanguageByLangcode(targetLanguage).label() })}
mouseLeaveDelay={0}
arrow>
<ToolbarButton onClick={handleTranslate} disabled={disabled || isTranslating} style={style} type="text">
{isTranslating ? <LoadingOutlined spin /> : <Languages size={18} />}

View File

@@ -122,7 +122,7 @@ describe('QuickPanelView', () => {
}
}
it('should focus on the first item after panel open', () => {
it('should not focus on any item after panel open by default', () => {
const list = createList(100)
render(
@@ -134,11 +134,16 @@ describe('QuickPanelView', () => {
)
)
// 检查第一个 item 是否有 focused
// 检查是否没有任何 focused item
const panel = screen.getByTestId('quick-panel')
const focused = panel.querySelectorAll('.focused')
expect(focused.length).toBe(0)
// 检查第一个 item 存在但没有 focused 类
const item1 = screen.getByText('Item 1')
const focused = item1.closest('.focused')
expect(focused).not.toBeNull()
expect(item1).toBeInTheDocument()
const focusedItem1 = item1.closest('.focused')
expect(focusedItem1).toBeNull()
})
it('should focus on the right item using ArrowUp, ArrowDown', async () => {
@@ -154,10 +159,11 @@ describe('QuickPanelView', () => {
)
const keySequence = [
{ key: 'ArrowUp', expected: 'Item 100' },
{ key: 'ArrowDown', expected: 'Item 1' }, // 从未选中状态按 ArrowDown 会选中第一个
{ key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会循环到最后一个
{ key: 'ArrowUp', expected: 'Item 99' },
{ key: 'ArrowDown', expected: 'Item 100' },
{ key: 'ArrowDown', expected: 'Item 1' }
{ key: 'ArrowDown', expected: 'Item 1' } // 从最后一个按 ArrowDown 会循环到第一个
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
@@ -176,11 +182,11 @@ describe('QuickPanelView', () => {
)
const keySequence = [
{ key: 'PageUp', expected: 'Item 1' }, // 停留在顶部
{ key: 'ArrowUp', expected: 'Item 100' },
{ key: 'PageDown', expected: 'Item 100' }, // 停留在底部
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` },
{ key: 'PageDown', expected: 'Item 100' }
{ key: 'PageDown', expected: `Item ${PAGE_SIZE}` }, // 从未选中状态按 PageDown 会选中第 pageSize 个项目
{ key: 'PageUp', expected: 'Item 1' }, // PageUp 会选中第一个
{ key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会到最后一个
{ key: 'PageDown', expected: 'Item 100' }, // 从最后一个按 PageDown 仍然是最后一个
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` } // PageUp 会向上翻页从索引99到92对应Item 93
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
@@ -199,10 +205,11 @@ describe('QuickPanelView', () => {
)
const keySequence = [
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` },
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' },
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' },
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }, // 从未选中状态按 Ctrl+ArrowDown 会选中第一个
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` }, // Ctrl+ArrowDown 会跳转 pageSize 个位置
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' }, // Ctrl+ArrowUp 会跳转回去
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' }, // 从第一个位置再按 Ctrl+ArrowUp 会循环到最后
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' } // 从最后位置按 Ctrl+ArrowDown 会循环到第一个
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)

View File

@@ -33,3 +33,6 @@ export const THEME_COLOR_PRESETS = [
'#0EA5E9', // Sky Blue
'#0284C7' // Light Blue
]
export const MAX_CONTEXT_COUNT = 100
export const UNLIMITED_CONTEXT_COUNT = 100000

View File

@@ -2487,7 +2487,7 @@ export function isGrokModel(model?: Model): boolean {
return model.id.includes('grok')
}
export function isGrokReasoningModel(model?: Model): boolean {
export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
if (!model) {
return false
}
@@ -2499,7 +2499,16 @@ export function isGrokReasoningModel(model?: Model): boolean {
return false
}
export const isSupportedReasoningEffortGrokModel = isGrokReasoningModel
export function isGrokReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
if (isSupportedReasoningEffortGrokModel(model) || model.id.includes('grok-4')) {
return true
}
return false
}
export function isGeminiReasoningModel(model?: Model): boolean {
if (!model) {

View File

@@ -99,7 +99,8 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
bash: 'shell',
'objective-c++': 'objective-cpp',
svg: 'xml',
vab: 'vb'
vab: 'vb',
graphviz: 'dot'
} as Record<string, string>
}, [])

View File

@@ -145,7 +145,8 @@ export const useKnowledge = (baseId: string) => {
}
}
if (item.type === 'file' && typeof item.content === 'object') {
await window.api.file.deleteDir(item.content.id)
// name: eg. text.pdf
await window.api.file.delete(item.content.name)
}
}
// 刷新项目

View File

@@ -1,4 +1,5 @@
import { createSelector } from '@reduxjs/toolkit'
import NavigationService from '@renderer/services/NavigationService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
@@ -8,8 +9,11 @@ import { IpcChannel } from '@shared/IpcChannel'
window.electron.ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
store.dispatch(setMCPServers(servers))
})
window.electron.ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => {
store.dispatch(addMCPServer(server))
NavigationService.navigate?.('/settings/mcp')
NavigationService.navigate?.('/settings/mcp/settings', { state: { server } })
})
const selectMcpServers = (state) => state.mcp.servers

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -266,7 +266,7 @@ const AgentsGroupList = styled(Scrollbar)`
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
padding: 12px 0;
border-right: 0.5px solid var(--color-border);
border-top-left-radius: inherit;
border-bottom-left-radius: inherit;

View File

@@ -41,6 +41,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [showUndoButton, setShowUndoButton] = useState(false)
const [originalPrompt, setOriginalPrompt] = useState('')
const [tokenCount, setTokenCount] = useState(0)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const knowledgeState = useAppSelector((state) => state.knowledge)
const showKnowledgeIcon = useSidebarIconShow('knowledge')
const knowledgeOptions: SelectProps['options'] = []
@@ -92,8 +93,21 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
const handleCancel = () => {
if (hasUnsavedChanges) {
window.modal.confirm({
title: t('common.confirm'),
content: t('agents.add.unsaved_changes_warning'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: () => {
setOpen(false)
}
})
} else {
setOpen(false)
}
}
const onClose = () => {
@@ -124,6 +138,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
form.setFieldsValue({ prompt: generatedText })
setShowUndoButton(true)
setOriginalPrompt(content)
setHasUnsavedChanges(true)
} catch (error) {
console.error('Error fetching data:', error)
}
@@ -146,7 +161,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
title={t('agents.add.title')}
open={open}
onOk={() => formRef.current?.submit()}
onCancel={onCancel}
onCancel={handleCancel}
maskClosable={false}
afterClose={onClose}
okText={t('agents.add.title')}
@@ -167,9 +182,21 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
setTokenCount(count)
setShowUndoButton(false)
}
const currentValues = form.getFieldsValue()
setHasUnsavedChanges(currentValues.name?.trim() || currentValues.prompt?.trim() || emoji)
}}>
<Form.Item name="name" label="Emoji">
<Popover content={<EmojiPicker onEmojiClick={setEmoji} />} arrow>
<Popover
content={
<EmojiPicker
onEmojiClick={(selectedEmoji) => {
setEmoji(selectedEmoji)
setHasUnsavedChanges(true)
}}
/>
}
arrow>
<Button icon={emoji && <span style={{ fontSize: 20 }}>{emoji}</span>}>{t('common.select')}</Button>
</Popover>
</Form.Item>

View File

@@ -54,7 +54,11 @@ const AttachmentButton: FC<Props> = ({
}))
return (
<Tooltip placement="top" title={couldAddImageFile ? t('chat.input.upload') : t('chat.input.upload.document')} arrow>
<Tooltip
placement="top"
title={couldAddImageFile ? t('chat.input.upload') : t('chat.input.upload.document')}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={onSelectFile} disabled={disabled}>
<Paperclip size={18} style={{ color: files.length ? 'var(--color-primary)' : 'var(--color-icon)' }} />
</ToolbarButton>

View File

@@ -21,6 +21,7 @@ const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEna
title={
isGenerateImageModel(model) ? t('chat.input.generate_image') : t('chat.input.generate_image_not_supported')
}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" disabled={!isGenerateImageModel(model)} onClick={onEnableGenerateImage}>
<Image size={18} color={assistant.enableGenerateImage ? 'var(--color-link)' : 'var(--color-icon)'} />

View File

@@ -240,7 +240,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setText('')
setFiles([])
setTimeout(() => setText(''), 500)
setTimeout(() => resizeTextArea(), 0)
setTimeout(() => resizeTextArea(true), 0)
setExpend(false)
} catch (error) {
console.error('Failed to send message:', error)
@@ -864,7 +864,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
onInput={onInput}
disabled={searching}
onPaste={(e) => onPaste(e.nativeEvent)}
onClick={() => searching && dispatch(setSearching(false))}
onClick={() => {
searching && dispatch(setSearching(false))
quickPanel.close()
}}
/>
<DragHandle onMouseDown={handleDragStart}>
<HolderOutlined />
@@ -906,7 +909,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
/>
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
{loading && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
<Tooltip placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>
<CirclePause style={{ color: 'var(--color-error)', fontSize: 20 }} />
</ToolbarButton>
@@ -952,14 +955,14 @@ const Container = styled.div`
flex-direction: column;
position: relative;
z-index: 2;
padding: 0 16px 16px 16px;
padding: 0 24px 18px 24px;
`
const InputBarContainer = styled.div`
border: 0.5px solid var(--color-border);
transition: all 0.2s ease;
position: relative;
border-radius: 15px;
border-radius: 20px;
padding-top: 8px; // 为拖动手柄留出空间
background-color: var(--color-background-opacity);

View File

@@ -290,7 +290,11 @@ const InputbarTools = ({
key: 'new_topic',
label: t('chat.input.new_topic', { Command: '' }),
component: (
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })} arrow>
<Tooltip
placement="top"
title={t('chat.input.new_topic', { Command: newTopicShortcut })}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={addNewTopic}>
<MessageSquareDiff size={19} />
</ToolbarButton>
@@ -395,7 +399,11 @@ const InputbarTools = ({
key: 'clear_topic',
label: t('chat.input.clear', { Command: '' }),
component: (
<Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} arrow>
<Tooltip
placement="top"
title={t('chat.input.clear', { Command: cleanTopicShortcut })}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={clearTopic}>
<PaintbrushVertical size={18} />
</ToolbarButton>
@@ -406,7 +414,11 @@ const InputbarTools = ({
key: 'toggle_expand',
label: isExpended ? t('chat.input.collapse') : t('chat.input.expand'),
component: (
<Tooltip placement="top" title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<Tooltip
placement="top"
title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
</ToolbarButton>

View File

@@ -65,7 +65,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
title: t('chat.input.knowledge_base'),
list: baseItems,
symbol: '#',
multiple: true,
multiple: false,
afterAction({ item }) {
item.isSelected = !item.isSelected
}
@@ -85,7 +85,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
}))
return (
<Tooltip placement="top" title={t('chat.input.knowledge_base')} arrow>
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel} disabled={disabled}>
<FileSearch size={18} />
</ToolbarButton>

View File

@@ -183,12 +183,15 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
label: t('common.close'),
description: t('settings.mcp.disable.description'),
icon: <CircleX />,
isSelected: !(assistant.mcpServers && assistant.mcpServers.length > 0),
action: () => updateMcpEnabled(false)
isSelected: false,
action: () => {
updateMcpEnabled(false)
quickPanel.close()
}
})
return newList
}, [activedMcpServers, t, assistant.mcpServers, assistantMcpServers, navigate, updateMcpEnabled])
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanel])
const openQuickPanel = useCallback(() => {
quickPanel.open({
@@ -451,7 +454,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
}))
return (
<Tooltip placement="top" title={t('settings.mcp.title')} arrow>
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<SquareTerminal
size={18}

View File

@@ -162,7 +162,7 @@ const MentionModelsButton: FC<Props> = ({
}))
return (
<Tooltip placement="top" title={t('agents.edit.model.select.title')} arrow>
<Tooltip placement="top" title={t('agents.edit.model.select.title')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<AtSign size={18} />
</ToolbarButton>

View File

@@ -16,7 +16,11 @@ const NewContextButton: FC<Props> = ({ onNewContext, ToolbarButton }) => {
useShortcut('toggle_new_context', onNewContext)
return (
<Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow>
<Tooltip
placement="top"
title={t('chat.input.new.context', { Command: newContextShortcut })}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={onNewContext}>
<Eraser size={18} />
</ToolbarButton>

View File

@@ -148,7 +148,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton,
return (
<>
<Tooltip placement="top" title={t('settings.quickPhrase.title')} arrow>
<Tooltip placement="top" title={t('settings.quickPhrase.title')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<Zap size={18} />
</ToolbarButton>

View File

@@ -190,7 +190,7 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
}))
return (
<Tooltip placement="top" title={t('assistants.settings.reasoning_effort')} arrow>
<Tooltip placement="top" title={t('assistants.settings.reasoning_effort')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
{getThinkingIcon()}
</ToolbarButton>

View File

@@ -1,5 +1,6 @@
import { ArrowUpOutlined, MenuOutlined } from '@ant-design/icons'
import { HStack, VStack } from '@renderer/components/Layout'
import MaxContextCount from '@renderer/components/MaxContextCount'
import { useSettings } from '@renderer/hooks/useSettings'
import { Divider, Popover } from 'antd'
import { FC } from 'react'
@@ -21,17 +22,17 @@ const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCou
return null
}
const formatMaxCount = (max: number) => {
return max.toString()
}
const PopoverContent = () => {
return (
<VStack w="185px" background="100%">
<HStack justifyContent="space-between" w="100%">
<Text>{t('chat.input.context_count.tip')}</Text>
<Text>
{contextCount.current} / {contextCount.max}
<HStack style={{ alignItems: 'center' }}>
{contextCount.current}
<SlashSeparatorSpan>/</SlashSeparatorSpan>
<MaxContextCount maxContext={contextCount.max} />
</HStack>
</Text>
</HStack>
<Divider style={{ margin: '5px 0' }} />
@@ -46,10 +47,20 @@ const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCou
return (
<Container>
<Popover content={PopoverContent} arrow={false}>
<MenuOutlined /> {contextCount.current} / {formatMaxCount(contextCount.max)}
<Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} />
<ArrowUpOutlined />
{inputTokenCount} / {estimateTokenCount}
<HStack>
<HStack style={{ alignItems: 'center' }}>
<MenuOutlined /> {contextCount.current}
<SlashSeparatorSpan>/</SlashSeparatorSpan>
<MaxContextCount maxContext={contextCount.max} />
</HStack>
<Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} />
<HStack style={{ alignItems: 'center' }}>
<ArrowUpOutlined />
{inputTokenCount}
<SlashSeparatorSpan>/</SlashSeparatorSpan>
{estimateTokenCount}
</HStack>
</HStack>
</Popover>
</Container>
)
@@ -80,4 +91,9 @@ const Text = styled.div`
color: var(--color-text-1);
`
const SlashSeparatorSpan = styled.span`
margin-left: 2px;
margin-right: 2px;
`
export default TokenCount

View File

@@ -6,10 +6,9 @@ import WebSearchService from '@renderer/services/WebSearchService'
import { Assistant, WebSearchProvider } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { Tooltip } from 'antd'
import { CircleX, Globe, Settings } from 'lucide-react'
import { Globe } from 'lucide-react'
import { FC, memo, useCallback, useImperativeHandle, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
export interface WebSearchButtonRef {
openQuickPanel: () => void
@@ -23,11 +22,12 @@ interface Props {
const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
const { t } = useTranslation()
const navigate = useNavigate()
const quickPanel = useQuickPanel()
const { providers } = useWebSearchProviders()
const { updateAssistant } = useAssistant(assistant.id)
const enableWebSearch = assistant?.webSearchProviderId || assistant.enableWebSearch
const updateSelectedWebSearchProvider = useCallback(
(providerId?: WebSearchProvider['id']) => {
// TODO: updateAssistant有性能问题会导致关闭快捷面板卡顿
@@ -78,42 +78,41 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
})
}
items.push({
label: t('chat.input.web_search.settings'),
icon: <Settings />,
action: () => navigate('/settings/tool/websearch')
})
items.unshift({
label: t('common.close'),
description: t('chat.input.web_search.no_web_search.description'),
icon: <CircleX />,
isSelected: !assistant.enableWebSearch && !assistant.webSearchProviderId,
action: () => {
updateSelectedWebSearchProvider(undefined)
}
})
return items
}, [
assistant.model,
assistant.enableWebSearch,
assistant.webSearchProviderId,
assistant.model,
assistant?.webSearchProviderId,
providers,
t,
updateSelectedWebSearchProvider,
updateSelectedWebSearchBuiltin,
navigate
updateSelectedWebSearchProvider
])
const openQuickPanel = useCallback(() => {
if (assistant.webSearchProviderId) {
return updateSelectedWebSearchProvider(undefined)
}
if (assistant.enableWebSearch) {
return updateSelectedWebSearchBuiltin()
}
quickPanel.open({
title: t('chat.input.web_search'),
list: providerItems,
symbol: '?',
pageSize: 9
})
}, [quickPanel, providerItems, t])
}, [
assistant.webSearchProviderId,
assistant.enableWebSearch,
quickPanel,
t,
providerItems,
updateSelectedWebSearchProvider,
updateSelectedWebSearchBuiltin
])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === '?') {
@@ -128,13 +127,16 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
}))
return (
<Tooltip placement="top" title={t('chat.input.web_search')} arrow>
<Tooltip
placement="top"
title={enableWebSearch ? t('common.close') : t('chat.input.web_search')}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<Globe
size={18}
style={{
color:
assistant?.webSearchProviderId || assistant.enableWebSearch ? 'var(--color-link)' : 'var(--color-icon)'
color: enableWebSearch ? 'var(--color-link)' : 'var(--color-icon)'
}}
/>
</ToolbarButton>

View File

@@ -1,4 +1,4 @@
import CodeBlockView from '@renderer/components/CodeBlockView'
import { CodeBlockView } from '@renderer/components/CodeBlockView'
import React, { memo, useCallback } from 'react'
interface Props {

View File

@@ -17,7 +17,11 @@ vi.mock('@renderer/hooks/useSettings', () => ({
}))
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation()
useTranslation: () => mockUseTranslation(),
initReactI18next: {
type: '3rdParty',
init: vi.fn()
}
}))
// Mock services

View File

@@ -9,9 +9,8 @@ interface Props {
}
const ImageBlock: React.FC<Props> = ({ block }) => {
if (block.status === MessageBlockStatus.STREAMING || block.status === MessageBlockStatus.PROCESSING)
return <Skeleton.Image active style={{ width: 200, height: 200 }} />
if (block.status === MessageBlockStatus.SUCCESS) {
if (block.status === MessageBlockStatus.PENDING) return <Skeleton.Image active style={{ width: 200, height: 200 }} />
if (block.status === MessageBlockStatus.STREAMING || block.status === MessageBlockStatus.SUCCESS) {
const images = block.metadata?.generateImageResponse?.images?.length
? block.metadata?.generateImageResponse?.images
: block?.file?.path

View File

@@ -19,8 +19,6 @@ interface Props {
role: Message['role']
}
const toolUseRegex = /<tool_use>([\s\S]*?)<\/tool_use>/g
const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions = [] }) => {
// Use the passed citationBlockId directly in the selector
const { renderInputMessageAsMarkdown } = useSettings()
@@ -38,10 +36,6 @@ const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions
return withCitationTags(block.content, rawCitations, sourceType)
}, [block.content, block.citationReferences, citationBlockId, rawCitations])
const ignoreToolUse = useMemo(() => {
return processedContent.replace(toolUseRegex, '')
}, [processedContent])
return (
<>
{/* Render mentions associated with the message */}
@@ -57,7 +51,7 @@ const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions
{block.content}
</p>
) : (
<Markdown block={{ ...block, content: ignoreToolUse }} />
<Markdown block={{ ...block, content: processedContent }} />
)}
</>
)

View File

@@ -151,6 +151,7 @@ const ThinkingTimeSeconds = memo(
const CollapseContainer = styled(Collapse)`
margin: 15px 0;
margin-top: 5px;
`
const MessageTitleLabel = styled.div`

View File

@@ -261,51 +261,6 @@ describe('MainTextBlock', () => {
})
describe('content processing', () => {
it('should filter tool_use tags from content', () => {
const testCases = [
{
name: 'single tool_use tag',
content: 'Before <tool_use>tool content</tool_use> after',
expectsFiltering: true
},
{
name: 'multiple tool_use tags',
content: 'Start <tool_use>tool1</tool_use> middle <tool_use>tool2</tool_use> end',
expectsFiltering: true
},
{
name: 'multiline tool_use',
content: `Text before
<tool_use>
multiline
tool content
</tool_use>
text after`,
expectsFiltering: true
},
{
name: 'malformed tool_use',
content: 'Before <tool_use>unclosed tag',
expectsFiltering: false // Should preserve malformed tags
}
]
testCases.forEach(({ content, expectsFiltering }) => {
const block = createMainTextBlock({ content })
const { unmount } = renderMainTextBlock({ block, role: 'assistant' })
const renderedContent = getRenderedMarkdown()
expect(renderedContent).toBeInTheDocument()
if (expectsFiltering) {
// Check that tool_use content is not visible to user
expect(screen.queryByText(/tool content|tool1|tool2|multiline/)).not.toBeInTheDocument()
}
unmount()
})
})
it('should process content through format utilities', () => {
const block = createMainTextBlock({
content: 'Content to process',

View File

@@ -3,6 +3,7 @@
exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
.c0 {
margin: 15px 0;
margin-top: 5px;
}
.c1 {

View File

@@ -188,7 +188,7 @@ const OpenButton = styled(Button)`
display: flex;
align-items: center;
padding: 3px 8px;
margin: 8px 0;
margin-bottom: 8px;
align-self: flex-start;
font-size: 12px;
background-color: var(--color-background-soft);

View File

@@ -47,7 +47,7 @@ const MessageItem: FC<Props> = ({
const { t } = useTranslation()
const { assistant, setModel } = useAssistant(message.assistantId)
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
const { messageFont, fontSize } = useSettings()
const { messageFont, fontSize, messageStyle } = useSettings()
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
const messageContainerRef = useRef<HTMLDivElement>(null)
const { editingMessageId, stopEditing } = useMessageEditing()
@@ -95,7 +95,7 @@ const MessageItem: FC<Props> = ({
stopEditing()
}, [stopEditing])
const isLastMessage = index === 0
const isLastMessage = index === 0 || !!isGrouped
const isAssistantMessage = message.role === 'assistant'
const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing
@@ -136,14 +136,7 @@ const MessageItem: FC<Props> = ({
'message-user': !isAssistantMessage
})}
ref={messageContainerRef}>
<MessageHeader
message={message}
assistant={assistant}
model={model}
key={getModelUniqId(model)}
index={index}
topic={topic}
/>
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} topic={topic} />
{isEditing && (
<MessageEditor
message={message}
@@ -167,7 +160,7 @@ const MessageItem: FC<Props> = ({
</MessageErrorBoundary>
</MessageContentContainer>
{showMenubar && (
<MessageFooter className="MessageFooter">
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
<MessageMenubar
message={message}
assistant={assistant}
@@ -196,7 +189,8 @@ const MessageContainer = styled.div`
transition: background-color 0.3s ease;
transform: translateZ(0);
will-change: transform;
padding: 10px 10px 0 10px;
padding: 10px;
padding-bottom: 0;
border-radius: 10px;
&.message-highlight {
background-color: var(--color-primary-mute);
@@ -224,14 +218,15 @@ const MessageContentContainer = styled(Scrollbar)`
overflow-y: auto;
`
const MessageFooter = styled.div`
const MessageFooter = styled.div<{ $isLastMessage: boolean; $messageStyle: 'plain' | 'bubble' }>`
display: flex;
flex-direction: row;
justify-content: space-between;
flex-direction: ${({ $isLastMessage, $messageStyle }) =>
$isLastMessage && $messageStyle === 'plain' ? 'row-reverse' : 'row'};
align-items: center;
gap: 20px;
justify-content: space-between;
gap: 10px;
margin-left: 46px;
margin-top: 2px;
margin-top: 8px;
`
const NewContextMessage = styled.div`

View File

@@ -14,7 +14,7 @@ const MessageContent: React.FC<Props> = ({ message }) => {
return (
<>
{!isEmpty(message.mentions) && (
<Flex gap="8px" wrap>
<Flex gap="8px" wrap style={{ marginBottom: '10px' }}>
{message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)}
</Flex>
)}

View File

@@ -43,7 +43,7 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
const model = assistant.model || assistant.defaultModel
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
const { pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
const { t } = useTranslation()
const textareaRef = useRef<TextAreaRef>(null)
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
@@ -75,14 +75,14 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
supportExts,
setFiles,
undefined, // 不需要setText
pasteLongTextAsFile,
false, // 不需要 pasteLongTextAsFile
pasteLongTextThreshold,
undefined, // 不需要text
resizeTextArea,
t
)
},
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t]
[model, pasteLongTextThreshold, resizeTextArea, supportExts, t]
)
// 添加全局粘贴事件处理
@@ -256,71 +256,72 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
}, [couldAddImageFile, couldAddTextFile])
return (
<EditorContainer className="message-editor" onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
{editedBlocks
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
.map((block) => (
<Textarea
className={classNames('editing-message', isFileDragging && 'file-dragging')}
key={block.id}
ref={textareaRef}
variant="borderless"
value={block.content}
onChange={(e) => {
handleTextChange(block.id, e.target.value)
resizeTextArea()
}}
onKeyDown={(e) => handleKeyDown(e, block.id)}
autoFocus
spellCheck={enableSpellCheck}
onPaste={(e) => onPaste(e.nativeEvent)}
onFocus={() => {
// 记录当前聚焦的组件
PasteService.setLastFocusedComponent('messageEditor')
}}
onContextMenu={(e) => {
// 阻止事件冒泡,避免触发全局的 Electron contextMenu
e.stopPropagation()
}}
style={{
fontSize,
padding: '0px 15px 8px 15px'
}}>
<TranslateButton onTranslated={onTranslated} />
</Textarea>
))}
{(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) ||
files.length > 0) && (
<FileBlocksContainer>
{editedBlocks
.filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE)
.map(
(block) =>
block.file && (
<CustomTag
key={block.id}
icon={getFileIcon(block.file.ext)}
color="#37a5aa"
closable
onClose={() => handleFileRemove(block.id)}>
<FileNameRender file={block.file} />
</CustomTag>
)
)}
{files.map((file) => (
<CustomTag
key={file.id}
icon={getFileIcon(file.ext)}
color="#37a5aa"
closable
onClose={() => setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}>
<FileNameRender file={file} />
</CustomTag>
<>
<EditorContainer className="message-editor" onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
{editedBlocks
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
.map((block) => (
<Textarea
className={classNames('editing-message', isFileDragging && 'file-dragging')}
key={block.id}
ref={textareaRef}
variant="borderless"
value={block.content}
onChange={(e) => {
handleTextChange(block.id, e.target.value)
resizeTextArea()
}}
onKeyDown={(e) => handleKeyDown(e, block.id)}
autoFocus
spellCheck={enableSpellCheck}
onPaste={(e) => onPaste(e.nativeEvent)}
onFocus={() => {
// 记录当前聚焦的组件
PasteService.setLastFocusedComponent('messageEditor')
}}
onContextMenu={(e) => {
// 阻止事件冒泡,避免触发全局的 Electron contextMenu
e.stopPropagation()
}}
style={{
fontSize,
padding: '0px 15px 8px 15px'
}}>
<TranslateButton onTranslated={onTranslated} />
</Textarea>
))}
</FileBlocksContainer>
)}
{(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) ||
files.length > 0) && (
<FileBlocksContainer>
{editedBlocks
.filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE)
.map(
(block) =>
block.file && (
<CustomTag
key={block.id}
icon={getFileIcon(block.file.ext)}
color="#37a5aa"
closable
onClose={() => handleFileRemove(block.id)}>
<FileNameRender file={block.file} />
</CustomTag>
)
)}
{files.map((file) => (
<CustomTag
key={file.id}
icon={getFileIcon(file.ext)}
color="#37a5aa"
closable
onClose={() => setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}>
<FileNameRender file={file} />
</CustomTag>
))}
</FileBlocksContainer>
)}
</EditorContainer>
<ActionBar>
<ActionBarLeft>
{isUserMessage && (
@@ -355,17 +356,17 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
)}
</ActionBarRight>
</ActionBar>
</EditorContainer>
</>
)
}
const EditorContainer = styled.div`
padding: 8px 0;
padding: 18px 0;
padding-bottom: 5px;
border: 0.5px solid var(--color-border);
transition: all 0.2s ease;
border-radius: 15px;
margin-top: 5px;
margin-bottom: 10px;
margin-top: 18px;
background-color: var(--color-background-opacity);
width: 100%;

View File

@@ -27,7 +27,8 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
const { isMultiSelectMode } = useChatContext(topic)
const [multiModelMessageStyle, setMultiModelMessageStyle] = useState<MultiModelMessageStyle>(
messages[0].multiModelMessageStyle || multiModelMessageStyleSetting
// 对于单模型消息,采用简单的样式,避免 overflow 影响内部的 sticky 效果
messages.length < 2 ? 'fold' : messages[0].multiModelMessageStyle || multiModelMessageStyleSetting
)
const messageLength = messages.length

Some files were not shown because too many files have changed in this diff Show More