Compare commits

...

43 Commits

Author SHA1 Message Date
suyao
2cd7f91fff fix: update zod import to use namespace import 2025-10-14 15:33:57 +08:00
suyao
ad7a043fb3 revert type 2025-10-14 15:12:43 +08:00
suyao
7db628702d Merge branch 'main' into feat/mcp-runjs 2025-10-14 15:09:42 +08:00
beyondkmp
866e8e8734 fix: guard webview search against destroyed webviews (#10704)
* 🐛 fix: guard webview search against destroyed webviews

* delete code

* delete code
2025-10-14 14:04:57 +08:00
George·Dong
80e1784777 chore(ci): switch Claude action to custom endpoint (#10701) 2025-10-14 01:23:34 +08:00
亢奋猫
0d760ffa2e feat: add AgentSettingsTab component and integrate into HomeTabs (#10668)
- Introduced the AgentSettingsTab component for managing agent settings.
- Integrated AgentSettingsTab into HomeTabs, allowing access to agent settings based on the active session or topic.
- Updated AgentEssentialSettings to conditionally render the ModelSetting based on props.
- Adjusted styles in various components for consistency and improved layout.
2025-10-13 22:34:27 +08:00
Pleasure1234
88f7e6a854 fix: add esbuild and update tar-fs dependency (#10671)
Added 'esbuild' with version ^0.25.0 and updated 'tar-fs' to ^2.1.4 in package.json. This also updates related entries in yarn.lock to ensure compatibility and resolve dependency issues.
2025-10-13 21:44:31 +08:00
kangfenmao
de37e2355d chore: update @ai-sdk/google to version 2.0.20 and add corresponding patch
- Updated the @ai-sdk/google dependency to version 2.0.20 in package.json.
- Added a new patch file for the updated version to address specific changes in the library.
2025-10-13 21:00:25 +08:00
Chen Tao
f27b04c5b0 fix: support gemini-2.5-image-flash (#10683) 2025-10-13 17:39:08 +08:00
defi-failure
a02b8f4609 chore: update SiliconFlow logo (#10684) 2025-10-13 16:57:20 +08:00
beyondkmp
7b90dfb46c fix: intercept webview keyboard shortcuts for search functionality (#10641)
* feat: intercept webview keyboard shortcuts for search functionality

Implemented keyboard shortcut interception in webview to enable search functionality (Ctrl/Cmd+F) and navigation (Enter/Escape) within mini app pages. Previously, these shortcuts were consumed by the webview content and not propagated to the host application.

Changes:
- Added Webview_SearchHotkey IPC channel for forwarding keyboard events
- Implemented before-input-event handler in WebviewService to intercept Ctrl/Cmd+F, Escape, and Enter
- Extended preload API with onFindShortcut callback for webview shortcut events
- Updated WebviewSearch component to handle shortcuts from both window and webview
- Added comprehensive test coverage for webview shortcut handling

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

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

* fix lint

* refactor: improve webview hotkey initialization and error handling

Refactored webview keyboard shortcut handler for better code organization and reliability.

Changes:
- Extracted keyboard handler logic into reusable attachKeyboardHandler function
- Added initWebviewHotkeys() to initialize handlers for existing webviews on startup
- Integrated initialization in main app entry point
- Added explanatory comment for event.preventDefault() behavior
- Added warning log when webContentsId is unavailable in WebviewSearch

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

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

* feat: add WebviewKeyEvent type and update related components

- Introduced WebviewKeyEvent type to standardize keyboard event handling for webviews.
- Updated preload index to utilize the new WebviewKeyEvent type in the onFindShortcut callback.
- Refactored WebviewSearch component and its tests to accommodate the new type, enhancing type safety and clarity.

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

* fix lint

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 18:45:37 +08:00
Pleasure1234
26a9dba01a fix: claude-translator.yml (#10588)
* Update claude-translator.yml

* Update claude-translator.yml
2025-10-12 18:20:34 +08:00
SuYao
a176814ad1 fix: update ESLint configuration and dependencies, replace zod import… (#10645)
fix: update ESLint configuration and dependencies, replace zod import style
2025-10-12 17:15:52 +08:00
George·Dong
ea51439aac feat(reasoning): add special handling for Grok 4 fast models & qwen3-omni/qwen3-vl (#10367)
* feat(reasoning): add special handling for Grok 4 fast models

* feat(models): add grok4_fast model and refine grok reasoning

* feat(reasoning): unify Grok reasoning handling and XAI params

* feat(models): Grok/qwen handling and XAI

* feat(models): recognize qwen3-vl thinking models and add sizes

* fix(reasoning): reasoning enabled for QwenAlwaysThink models

* feat(reasoning): enable reasoning for Grok 4 Fast models

* fix(reasoning): rename and correct Grok 4 Fast model checks

* fix: adjust Grok-4 Fast reasoning detection for OpenRouter

* fix(reasoning): exclude non-reasoning models from reasoning detection
2025-10-12 11:34:16 +08:00
Chen Tao
162e33f478 fix: remove LRU for websearch rag (#10631) 2025-10-12 00:01:35 +08:00
kangfenmao
ee4c310725 feat: update migration logic and increment version for store
- Incremented version in the store configuration from 161 to 162.
- Updated migration logic to handle new provider integration and state adjustments.
- Removed deprecated migration logic for version 161.
2025-10-11 16:17:00 +08:00
kangfenmao
a000ff2a1a fix: adjust overflow properties in MessageGroup component
- Changed overflow properties in the GridContainer styled component to improve layout handling. Overflow is now set to hidden for vertical alignment.
2025-10-11 16:07:49 +08:00
kangfenmao
2f9576b2ae feat: remove some minapp and update related configurations
- Introduced new app icon for Stepfun.
- Updated minapps configuration to include Stepfun with its logo and URL.
- Removed Yuewen app from configurations and translations.
- Updated translations for multiple languages to reflect the addition of Stepfun and removal of Yuewen.
- Incremented version in the store configuration and added migration logic for new provider integration.
2025-10-11 16:07:49 +08:00
kangfenmao
92554dd398 chore: update @ai-sdk/google to version 2.0.17 and add corresponding patch 2025-10-11 16:07:49 +08:00
defi-failure
9473ddc762 feature: unified assistant tab (#10590)
* feature: unified assistant tab

* refactor(TagGroup): make TagsContainer component internal by removing export

* refactor(components): migrate styled-components to cn utility classes

Replace styled-components with cn utility classes from @heroui/react for better maintainability and performance

* refactor(AssistantsTab): split AssistantsTab into smaller hooks and components

* fix: click agent item should jump to topic tab

* feat: add AddButton component and refactor usage across tabs

- Introduced a new AddButton component for consistent UI across different tabs.
- Replaced existing button implementations with AddButton in Sessions, Topics, and UnifiedAddButton components.
- Removed unnecessary margin from AssistantsTab's container for improved layout.

---------

Co-authored-by: icarus <eurfelux@gmail.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-10-11 16:07:35 +08:00
SuYao
5f469a71f3 fix: update ai-sdk dependencies to latest versions (#10643) 2025-10-11 15:53:30 +08:00
defi-failure
87bac60afc fix: long dir breaks edit agent layout (#10644) 2025-10-11 14:47:45 +08:00
SuYao
704339e835 fix: increase tool call maxCount (#10642) 2025-10-11 14:21:18 +08:00
ABucket
c8ab7180ba fix: Provider icons are not displayed after selecting SiliconFlow in the "images" page (#10620) 2025-10-11 12:48:26 +08:00
ABucket
11757546c3 fix: Quick Assistant fails to correctly inject variables in prompts (#10617) 2025-10-11 12:45:25 +08:00
ABucket
420b9ec2f2 fix: AI_TypeValidationError when calling Ling-1T model (#10622) 2025-10-11 12:45:00 +08:00
beyondkmp
1c73271e33 fix: support gpt-5-codex for github copilot (#10587)
* fix: support gpt-5-codex for github copilot

- Added patch for @ai-sdk/openai to version 2.0.42 in package.json and yarn.lock.
- Updated editor version for Copilot from v1.97.2 to v1.104.1 in OpenAIBaseClient and providerConfig.
- Enhanced provider configuration to support new model options for Copilot.

* fix: streamline Copilot header management

- Replaced individual header assignments for Copilot with centralized constants in OpenAIBaseClient and providerConfig.
- Enhanced provider configuration to conditionally set response mode for Copilot models, improving routing logic.

* update aisdk

* delete patch

* 🤖 chore: integrate Copilot SDK provider

* use a plugin

* udpate dependency

* fix: remove unused Copilot default headers from OpenAIBaseClient

- Eliminated the import and usage of COPILOT_DEFAULT_HEADERS to streamline header management in the OpenAIBaseClient class.

* update yarn

* fix lint

* format code

* feat: enhance web search tool types in webSearchPlugin

- Added type normalization for web search tools to improve type safety and clarity.
- Updated WebSearchToolInputSchema and WebSearchToolOutputSchema to use normalized types for better consistency across the plugin.
2025-10-11 10:18:09 +08:00
ABucket
acdbe6b9ed feat: allow right click to create note and folder (#10523)
* feat: allow right click to create note and folder

* fix: duplicate menu for notes or folder

* fix: create notes in folder when a folder is selected
2025-10-10 16:58:14 +01:00
beyondkmp
6c201228d9 feat: support search in mini app page (#10609)
*  feat: add webview find-in-page overlay

* 🐛 fix: reset webview search on tab change

* fix clear search issue

* 🐛 fix: rebind webview search events

* 🐛 fix: disable spellcheck in search input

* fix spellcheck

* 🐛 fix: webview search can now reopen after closing

Fixed an issue where the search overlay couldn't be reopened after closing.
The openSearch callback was unnecessarily depending on webviewRef.current,
causing event listener rebinding issues. Removed the redundant webviewRef
check as isWebviewReady is sufficient to ensure webview readiness.

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

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

---------

Co-authored-by: Payne Fu <payne@Paynes-Mac-mini.rcoffice.ringcentral.com>
Co-authored-by: Payne Fu <payne@Paynes-MBP.rcoffice.ringcentral.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-10 07:00:45 -07:00
Tristan Zhang
73b2a375ad fix: insert reasoning block before the content block (#10545)
fix: always insert reasoning block before the content block
2025-10-09 22:32:13 +08:00
Chen Tao
89bb830b60 fix: knowledge base not delete and websearch rag error (#10595)
* fix: knowledge base not  delete

* fix: websearch rag error

* chore: add comment
2025-10-09 22:29:52 +08:00
Tristan Zhang
2399db4944 fix: adding multiple keys to the zhipu model service is not detected properly (#10583) 2025-10-09 20:40:46 +08:00
beyondkmp
62774b34d3 feat: add updating dialog in render (#10569)
* feat: replace update dialog handling with quit and install functionality

* refactor: remove App_ShowUpdateDialog and implement App_QuitAndInstall in IpcChannel
* update ipc.ts to handle quit and install action
* modify AppUpdater to include quitAndInstall method
* adjust preload index to invoke new quit and install action
* enhance AboutSettings to manage update dialog state and trigger quit and install

* fix(AboutSettings): handle null update info in update dialog state management

* fix(UpdateDialog): improve error handling during update installation and enhance release notes processing

* fix(AppUpdater): remove redundant assignment of releaseInfo after update download

* fix(IpcChannel): remove UpdateDownloadedCancelled enum value

* format code

* fix(UpdateDialog): enhance installation process with loading state and error handling

* update i18n

* fix(i18n): Auto update translations for PR #10569

* feat(UpdateAppButton): integrate UpdateDialog and update button functionality for better user experience

* fix(UpdateDialog): update installation handler to support async operation and ensure modal closes after installation

* refactor(AppUpdater.test): remove deprecated formatReleaseNotes tests to streamline test suite

* refactor(update-dialog): simplify dialog close handling

Replace onOpenChange with onClose prop to directly handle dialog closing
Remove redundant handleClose function and simplify button onPress handler

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: icarus <eurfelux@gmail.com>
2025-10-09 15:58:24 +08:00
suyao
44d2cb345f return 2025-10-09 08:50:46 +08:00
suyao
49eec68434 done 2025-10-09 08:46:07 +08:00
icarus
d5ae3e6edc Merge branch 'main' of github.com:CherryHQ/cherry-studio into feat/mcp-runjs 2025-10-09 00:27:30 +08:00
icarus
6eedcc29ba feat(i18n): add untranslated strings for multiple languages
Add placeholder strings marked with "[to be translated]" in Japanese, Russian, Portuguese, Greek, Spanish and French translation files. These include new message placeholders and auto-rename functionality for notes.
2025-10-09 00:20:39 +08:00
suyao
71d35eddf7 Add JavaScript execution safety limits and improve error handling
- Add 1MB code size limit to prevent memory issues
- Improve timeout error handling with proper cleanup logging
- Remove host environment exposure and fix file descriptor leaks in worker
2025-10-08 07:11:39 +08:00
suyao
9f1c8f2c17 fix: more accurate 2025-10-08 07:08:19 +08:00
suyao
651e9a529e Add JavaScript execution support to code blocks
- Register new IPC channel and handler for JavaScript code execution
- Extend code block execution to support JavaScript, TypeScript, and JS languages
- Add JavaScript service with sandboxed execution using quickJs
- Update UI to show JavaScript execution option alongside Python
2025-10-08 07:07:13 +08:00
suyao
f68f6e9896 chore: i18n 2025-10-08 06:56:21 +08:00
suyao
2dcc68da87 chore: yarn lock 2025-10-08 06:54:14 +08:00
suyao
1db259cd3e Add JavaScript MCP server with QuickJS WASM sandbox execution
- Implement JsServer MCP server with `run_javascript_code` tool for secure code execution
- Add JsService worker management with timeout handling and error formatting
- Include QuickJS WASM binary and integrate with WASI for sandboxed execution
- Update UI with i18n support and JSON result formatting improvements
2025-10-08 06:48:14 +08:00
112 changed files with 3455 additions and 1369 deletions

View File

@@ -16,10 +16,13 @@ on:
jobs:
translate:
if: |
(github.event_name == 'issues') ||
(github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') ||
(github.event_name == 'pull_request_review' && github.event.sender.type != 'Bot') ||
(github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot')
(github.event_name == 'issues')
|| (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot')
|| (
(github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment')
&& github.event.sender.type != 'Bot'
&& github.event.pull_request.head.repo.fork == false
)
runs-on: ubuntu-latest
permissions:
contents: read
@@ -42,7 +45,7 @@ jobs:
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md
github_token: ${{ secrets.TOKEN_GITHUB_WRITE }}
allowed_non_write_users: "*"
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)"
prompt: |
你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件:
@@ -105,3 +108,5 @@ jobs:
使用以下命令获取完整信息:
gh issue view ${{ github.event.issue.number }} --json title,body,comments
env:
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}

View File

@@ -1,8 +1,8 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {

View File

@@ -2,6 +2,7 @@ import tseslint from '@electron-toolkit/eslint-config-ts'
import eslint from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config'
import importZod from 'eslint-plugin-import-zod'
import oxlint from 'eslint-plugin-oxlint'
import reactHooks from 'eslint-plugin-react-hooks'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
@@ -15,7 +16,8 @@ export default defineConfig([
{
plugins: {
'simple-import-sort': simpleImportSort,
'unused-imports': unusedImports
'unused-imports': unusedImports,
'import-zod': importZod
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
@@ -25,6 +27,7 @@ export default defineConfig([
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error',
'import-zod/prefer-zod-namespace': 'error'
}
},
// Configuration for ensuring compatibility with the original ESLint(8.x) rules

View File

@@ -99,10 +99,10 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.29",
"@ai-sdk/google-vertex": "^3.0.33",
"@ai-sdk/mistral": "^2.0.17",
"@ai-sdk/perplexity": "^2.0.11",
"@ai-sdk/amazon-bedrock": "^3.0.35",
"@ai-sdk/google-vertex": "^3.0.40",
"@ai-sdk/mistral": "^2.0.19",
"@ai-sdk/perplexity": "^2.0.13",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
@@ -152,6 +152,7 @@
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-node": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opeoginni/github-copilot-openai-compatible": "0.1.18",
"@playwright/test": "^1.52.0",
"@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5",
@@ -219,7 +220,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.59",
"ai": "^5.0.68",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
@@ -256,6 +257,7 @@
"emoji-picker-element": "^1.22.1",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"eslint": "^9.22.0",
"eslint-plugin-import-zod": "^1.2.0",
"eslint-plugin-oxlint": "^1.15.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
@@ -294,7 +296,7 @@
"notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"oxlint": "^1.15.0",
"oxlint": "^1.22.0",
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
"pdf-lib": "^1.17.1",
@@ -370,6 +372,7 @@
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
"esbuild": "^0.25.0",
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"node-abi": "4.12.0",
@@ -377,10 +380,11 @@
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"tar-fs": "^2.1.4",
"undici": "6.21.2",
"vite": "npm:rolldown-vite@latest",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
"@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -36,14 +36,14 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.22",
"@ai-sdk/azure": "^2.0.42",
"@ai-sdk/deepseek": "^1.0.20",
"@ai-sdk/openai": "^2.0.42",
"@ai-sdk/openai-compatible": "^1.0.19",
"@ai-sdk/anthropic": "^2.0.27",
"@ai-sdk/azure": "^2.0.49",
"@ai-sdk/deepseek": "^1.0.23",
"@ai-sdk/openai": "^2.0.48",
"@ai-sdk/openai-compatible": "^1.0.22",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.10",
"@ai-sdk/xai": "^2.0.23",
"@ai-sdk/provider-utils": "^3.0.12",
"@ai-sdk/xai": "^2.0.26",
"zod": "^4.1.5"
},
"devDependencies": {

View File

@@ -1,7 +1,7 @@
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { openai } from '@ai-sdk/openai'
import { InferToolInput, InferToolOutput } from 'ai'
import { InferToolInput, InferToolOutput, type Tool } from 'ai'
import { ProviderOptionsMap } from '../../../options/types'
import { OpenRouterSearchConfig } from './openrouter'
@@ -15,6 +15,13 @@ export type AnthropicSearchConfig = NonNullable<Parameters<typeof anthropic.tool
export type GoogleSearchConfig = NonNullable<Parameters<typeof google.tools.googleSearch>[0]>
export type XAISearchConfig = NonNullable<ProviderOptionsMap['xai']['searchParameters']>
type NormalizeTool<T> = T extends Tool<infer INPUT, infer OUTPUT> ? Tool<INPUT, OUTPUT> : Tool<any, any>
type AnthropicWebSearchTool = NormalizeTool<ReturnType<typeof anthropic.tools.webSearch_20250305>>
type OpenAIWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearch>>
type OpenAIChatWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearchPreview>>
type GoogleWebSearchTool = NormalizeTool<ReturnType<typeof google.tools.googleSearch>>
/**
* 插件初始化时接收的完整配置对象
*
@@ -59,7 +66,7 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
export type WebSearchToolOutputSchema = {
// Anthropic 工具 - 手动定义
anthropic: InferToolOutput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
anthropic: InferToolOutput<AnthropicWebSearchTool>
// OpenAI 工具 - 基于实际输出
// TODO: 上游定义不规范,是unknown
@@ -82,8 +89,8 @@ export type WebSearchToolOutputSchema = {
}
export type WebSearchToolInputSchema = {
anthropic: InferToolInput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
openai: InferToolInput<ReturnType<typeof openai.tools.webSearch>>
google: InferToolInput<ReturnType<typeof google.tools.googleSearch>>
'openai-chat': InferToolInput<ReturnType<typeof openai.tools.webSearchPreview>>
anthropic: InferToolInput<AnthropicWebSearchTool>
openai: InferToolInput<OpenAIWebSearchTool>
google: InferToolInput<GoogleWebSearchTool>
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
}

View File

@@ -13,7 +13,7 @@ import { LanguageModelV2 } from '@ai-sdk/provider'
import { createXai } from '@ai-sdk/xai'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import { customProvider, Provider } from 'ai'
import { z } from 'zod'
import * as z from 'zod'
/**
* 基础 Provider IDs

View File

@@ -5,8 +5,8 @@ export enum IpcChannel {
App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_QuitAndInstall = 'app:quit-and-install',
App_Reload = 'app:reload',
App_Quit = 'app:quit',
App_Info = 'app:info',
@@ -53,6 +53,7 @@ export enum IpcChannel {
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
Webview_SearchHotkey = 'webview:search-hotkey',
// Open
Open_Path = 'open:path',
@@ -95,6 +96,9 @@ export enum IpcChannel {
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
AgentMessage_GetHistory = 'agent-message:get-history',
// JavaScript
Js_Execute = 'js:execute',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
@@ -234,7 +238,6 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
UpdateAvailable = 'update-available',

View File

@@ -22,3 +22,12 @@ export type MCPProgressEvent = {
callId: string
progress: number // 0-1 range
}
export type WebviewKeyEvent = {
webviewId: number
key: string
control: boolean
meta: boolean
shift: boolean
alt: boolean
}

Binary file not shown.

View File

@@ -30,6 +30,7 @@ import selectionService, { initSelectionService } from './services/SelectionServ
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { initWebviewHotkeys } from './services/WebviewService'
const logger = loggerService.withContext('MainEntry')
@@ -108,6 +109,7 @@ if (!app.requestSingleInstanceLock()) {
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
initWebviewHotkeys()
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')

View File

@@ -37,6 +37,7 @@ import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService'
import { fileStorage as fileManager } from './services/FileStorage'
import FileService from './services/FileSystemService'
import { jsService } from './services/JsService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import MemoryService from './services/memory/MemoryService'
@@ -142,7 +143,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
@@ -741,6 +742,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
)
// Register JavaScript execution handler
ipcMain.handle(IpcChannel.Js_Execute, async (_, code: string, timeout?: number) => {
return await jsService.executeScript(code, { timeout })
})
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
@@ -786,7 +792,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
setOpenLinkExternal(webviewId, isExternal)
)
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
const webview = webContents.fromId(webviewId)
if (!webview) return

View File

@@ -3,7 +3,7 @@ import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { net } from 'electron'
import { z } from 'zod'
import * as z from 'zod'
const logger = loggerService.withContext('DifyKnowledgeServer')

View File

@@ -6,6 +6,7 @@ import BraveSearchServer from './brave-search'
import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
import JsServer from './js'
import MemoryServer from './memory'
import PythonServer from './python'
import ThinkingServer from './sequentialthinking'
@@ -42,6 +43,9 @@ export function createInMemoryMCPServer(
case BuiltinMCPServerNames.python: {
return new PythonServer().server
}
case BuiltinMCPServerNames.js: {
return new JsServer().server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@@ -5,7 +5,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
import { net } from 'electron'
import { JSDOM } from 'jsdom'
import TurndownService from 'turndown'
import { z } from 'zod'
import * as z from 'zod'
export const RequestPayloadSchema = z.object({
url: z.url(),

View File

@@ -8,7 +8,7 @@ import fs from 'fs/promises'
import { minimatch } from 'minimatch'
import os from 'os'
import path from 'path'
import { z } from 'zod'
import * as z from 'zod'
const logger = loggerService.withContext('MCP:FileSystemServer')

139
src/main/mcpServers/js.ts Normal file
View File

@@ -0,0 +1,139 @@
// port from https://github.com/jlucaso1/mcp-javascript-sandbox
import { loggerService } from '@logger'
import { jsService } from '@main/services/JsService'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types'
import * as z from 'zod'
const TOOL_NAME = 'run_javascript_code'
const DEFAULT_TIMEOUT = 60_000
export const RequestPayloadSchema = z.object({
javascript_code: z.string().min(1).describe('The JavaScript code to execute in the sandbox.'),
timeout: z
.number()
.int()
.positive()
.max(5 * 60_000)
.optional()
.describe('Execution timeout in milliseconds (default 60000, max 300000).')
})
const logger = loggerService.withContext('MCPServer:JavaScript')
function formatExecutionResult(result: {
stdout: string
stderr: string
error?: string | undefined
exitCode: number
}) {
let combinedOutput = ''
if (result.stdout) {
combinedOutput += result.stdout
}
if (result.stderr) {
combinedOutput += `--- stderr ---\n${result.stderr}\n--- stderr ---\n`
}
if (result.error) {
combinedOutput += `--- Execution Error ---\n${result.error}\n--- Execution Error ---\n`
}
const isError = Boolean(result.error) || Boolean(result.stderr?.trim()) || result.exitCode !== 0
return {
combinedOutput: combinedOutput.trim(),
isError
}
}
class JsServer {
public server: Server
constructor() {
this.server = new Server(
{
name: 'MCP QuickJS Runner',
version: '1.0.0',
description: 'An MCP server that provides a tool to execute JavaScript code in a QuickJS WASM sandbox.'
},
{
capabilities: {
resources: {},
tools: {}
}
}
)
this.setupHandlers()
}
private setupHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: TOOL_NAME,
description:
'Executes the provided JavaScript code in a secure WASM sandbox (QuickJS). Returns stdout and stderr. Non-zero exit code indicates an error.',
inputSchema: z.toJSONSchema(RequestPayloadSchema)
}
]
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
if (name !== TOOL_NAME) {
return {
content: [{ type: 'text', text: `Tool not found: ${name}` }],
isError: true
}
}
const parseResult = RequestPayloadSchema.safeParse(args)
if (!parseResult.success) {
return {
content: [{ type: 'text', text: `Invalid arguments: ${parseResult.error.message}` }],
isError: true
}
}
const { javascript_code, timeout } = parseResult.data
try {
logger.debug('Executing JavaScript code via JsService')
const result = await jsService.executeScript(javascript_code, {
timeout: timeout ?? DEFAULT_TIMEOUT
})
const { combinedOutput, isError } = formatExecutionResult(result)
return {
content: [
{
type: 'text',
text: combinedOutput
}
],
isError
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error(`JavaScript execution failed: ${message}`)
return {
content: [
{
type: 'text',
text: `Server error during tool execution: ${message}`
}
],
isError: true
}
}
})
}
}
export default JsServer

View File

@@ -1,17 +1,15 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { locales } from '@main/utils/locales'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog, net } from 'electron'
import { app, net } from 'electron'
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import semver from 'semver'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
@@ -26,7 +24,6 @@ const LANG_MARKERS = {
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
@@ -66,7 +63,6 @@ export default class AppUpdater {
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
this.releaseInfo = processedReleaseInfo
logger.info('update downloaded', processedReleaseInfo)
})
@@ -247,37 +243,9 @@ export default class AppUpdater {
}
}
public async showUpdateDialog(mainWindow: BrowserWindow) {
if (!this.releaseInfo) {
return
}
const locale = locales[configManager.getLanguage()]
const { update: updateLocale } = locale.translation
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
if (detail === '') {
detail = updateLocale.noReleaseNotes
}
dialog
.showMessageBox({
type: 'info',
title: updateLocale.title,
icon,
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
detail,
buttons: [updateLocale.later, updateLocale.install],
defaultId: 1,
cancelId: 0
})
.then(({ response }) => {
if (response === 1) {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
}
})
public quitAndInstall() {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
}
/**
@@ -349,38 +317,9 @@ export default class AppUpdater {
return processedInfo
}
/**
* Format release notes for display
* @param releaseNotes - Release notes in various formats
* @returns Formatted string for display
*/
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return ''
}
if (typeof releaseNotes === 'string') {
// Check if it contains multi-language markers
if (this.hasMultiLanguageMarkers(releaseNotes)) {
return this.parseMultiLangReleaseNotes(releaseNotes)
}
return releaseNotes
}
if (Array.isArray(releaseNotes)) {
return releaseNotes.map((note) => note.note).join('\n')
}
return ''
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null
}

View File

@@ -0,0 +1,115 @@
import { loggerService } from '@logger'
import type { JsExecutionResult } from './workers/JsWorker'
// oxlint-disable-next-line default
import createJsWorker from './workers/JsWorker?nodeWorker'
interface ExecuteScriptOptions {
timeout?: number
}
type WorkerResponse =
| {
success: true
result: JsExecutionResult
}
| {
success: false
error: string
}
const DEFAULT_TIMEOUT = 60_000
const logger = loggerService.withContext('JsService')
export class JsService {
private static instance: JsService | null = null
private constructor() {}
public static getInstance(): JsService {
if (!JsService.instance) {
JsService.instance = new JsService()
}
return JsService.instance
}
public async executeScript(code: string, options: ExecuteScriptOptions = {}): Promise<JsExecutionResult> {
const { timeout = DEFAULT_TIMEOUT } = options
if (!code || typeof code !== 'string') {
throw new Error('JavaScript code must be a non-empty string')
}
// Limit code size to 1MB to prevent memory issues
const MAX_CODE_SIZE = 1_000_000
if (code.length > MAX_CODE_SIZE) {
throw new Error(`JavaScript code exceeds maximum size of ${MAX_CODE_SIZE / 1_000_000}MB`)
}
return new Promise<JsExecutionResult>((resolve, reject) => {
const worker = createJsWorker({
workerData: { code },
argv: [],
trackUnmanagedFds: false
})
let settled = false
let timeoutId: NodeJS.Timeout | null = null
const cleanup = async () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
try {
await worker.terminate()
} catch {
// ignore termination errors
}
}
const settleSuccess = async (result: JsExecutionResult) => {
if (settled) return
settled = true
await cleanup()
resolve(result)
}
const settleError = async (error: Error) => {
if (settled) return
settled = true
await cleanup()
reject(error)
}
worker.once('message', async (message: WorkerResponse) => {
if (message.success) {
await settleSuccess(message.result)
} else {
await settleError(new Error(message.error))
}
})
worker.once('error', async (error) => {
logger.error(`JsWorker error: ${error instanceof Error ? error.message : String(error)}`)
await settleError(error instanceof Error ? error : new Error(String(error)))
})
worker.once('exit', async (exitCode) => {
if (!settled && exitCode !== 0) {
await settleError(new Error(`JsWorker exited with code ${exitCode}`))
}
})
timeoutId = setTimeout(() => {
logger.warn(`JavaScript execution timed out after ${timeout}ms`)
settleError(new Error('JavaScript execution timed out')).catch((err) => {
logger.error('Error during timeout cleanup:', err instanceof Error ? err : new Error(String(err)))
})
}, timeout)
})
}
}
export const jsService = JsService.getInstance()

View File

@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
import { fileStorage } from '@main/services/FileStorage'
import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils'
import { getAllFiles } from '@main/utils/file'
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant'
import type { LoaderReturn } from '@shared/config/types'
@@ -147,11 +147,16 @@ class KnowledgeService {
}
}
private getDbPath = (id: string): string => {
// 消除网络搜索requestI d中的特殊字符
return path.join(this.storageDir, sanitizeFilename(id, '_'))
}
/**
* Delete knowledge base file
*/
private deleteKnowledgeFile = (id: string): boolean => {
const dbPath = path.join(this.storageDir, id)
const dbPath = this.getDbPath(id)
if (fs.existsSync(dbPath)) {
try {
fs.rmSync(dbPath, { recursive: true })
@@ -244,7 +249,8 @@ class KnowledgeService {
dimensions
})
try {
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
const dbPath = this.getDbPath(id)
const libSqlDb = new LibSqlDb({ path: dbPath })
// Save database instance for later closing
this.dbInstances.set(id, libSqlDb)

View File

@@ -1,4 +1,5 @@
import { session, shell, webContents } from 'electron'
import { IpcChannel } from '@shared/IpcChannel'
import { app, session, shell, webContents } from 'electron'
/**
* init the useragent of the webview session
@@ -36,3 +37,61 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
}
})
}
const attachKeyboardHandler = (contents: Electron.WebContents) => {
if (contents.getType?.() !== 'webview') {
return
}
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
if (!input) {
return
}
const key = input.key?.toLowerCase()
if (!key) {
return
}
const isFindShortcut = (input.control || input.meta) && key === 'f'
const isEscape = key === 'escape'
const isEnter = key === 'enter'
if (!isFindShortcut && !isEscape && !isEnter) {
return
}
// Prevent default to override the guest page's native find dialog
// and keep shortcuts routed to our custom search overlay
event.preventDefault()
const host = contents.hostWebContents
if (!host || host.isDestroyed()) {
return
}
host.send(IpcChannel.Webview_SearchHotkey, {
webviewId: contents.id,
key,
control: Boolean(input.control),
meta: Boolean(input.meta),
shift: Boolean(input.shift),
alt: Boolean(input.alt)
})
}
contents.on('before-input-event', handleBeforeInput)
contents.once('destroyed', () => {
contents.removeListener('before-input-event', handleBeforeInput)
})
}
export function initWebviewHotkeys() {
webContents.getAllWebContents().forEach((contents) => {
if (contents.isDestroyed()) return
attachKeyboardHandler(contents)
})
app.on('web-contents-created', (_, contents) => {
attachKeyboardHandler(contents)
})
}

View File

@@ -274,46 +274,4 @@ describe('AppUpdater', () => {
expect(result.releaseNotes).toBeNull()
})
})
describe('formatReleaseNotes', () => {
it('should format string release notes with markers', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('English')
})
it('should format string release notes without markers', () => {
const notes = 'Simple notes'
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Simple notes')
})
it('should format array release notes', () => {
const notes = [
{ version: '1.0.0', note: 'Note 1' },
{ version: '1.0.1', note: 'Note 2' }
]
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Note 1\nNote 2')
})
it('should handle null release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(null)
expect(result).toBe('')
})
it('should handle undefined release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(undefined)
expect(result).toBe('')
})
})
})

View File

@@ -4,7 +4,7 @@ import {
OAuthTokens
} from '@modelcontextprotocol/sdk/shared/auth.js'
import EventEmitter from 'events'
import { z } from 'zod'
import * as z from 'zod'
export interface OAuthStorageData {
clientInfo?: OAuthClientInformation

View File

@@ -1,7 +1,7 @@
import { loadOcrImage } from '@main/utils/ocr'
import { ImageFileMetadata, isImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types'
import { net } from 'electron'
import { z } from 'zod'
import * as z from 'zod'
import { OcrBaseService } from './OcrBaseService'

View File

@@ -0,0 +1,118 @@
import { mkdtemp, open, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { WASI } from 'node:wasi'
import { parentPort, workerData } from 'node:worker_threads'
import loadWasm from '../../../../resources/wasm/qjs-wasi.wasm?loader'
interface WorkerPayload {
code: string
}
export interface JsExecutionResult {
stdout: string
stderr: string
error?: string
exitCode: number
}
if (!parentPort) {
throw new Error('JsWorker requires a parent port')
}
async function runQuickJsInSandbox(jsCode: string): Promise<JsExecutionResult> {
let tempDir: string | undefined
let stdoutHandle: Awaited<ReturnType<typeof open>> | undefined
let stderrHandle: Awaited<ReturnType<typeof open>> | undefined
let stdoutPath: string | undefined
let stderrPath: string | undefined
try {
tempDir = await mkdtemp(join(tmpdir(), 'quickjs-wasi-'))
stdoutPath = join(tempDir, 'stdout.log')
stderrPath = join(tempDir, 'stderr.log')
stdoutHandle = await open(stdoutPath, 'w')
stderrHandle = await open(stderrPath, 'w')
const wasi = new WASI({
version: 'preview1',
args: ['qjs', '-e', jsCode],
env: {}, // Empty environment for security - don't expose host env vars
stdin: 0,
stdout: stdoutHandle.fd,
stderr: stderrHandle.fd,
returnOnExit: true
})
const instance = await loadWasm(wasi.getImportObject() as WebAssembly.Imports)
let exitCode = 0
try {
exitCode = wasi.start(instance)
} catch (wasiError: any) {
return {
stdout: '',
stderr: `WASI start error: ${wasiError?.message ?? String(wasiError)}`,
error: `Sandbox execution failed during start: ${wasiError?.message ?? String(wasiError)}`,
exitCode: -1
}
}
// Close handles before reading files to prevent descriptor leak
const _stdoutHandle = stdoutHandle
stdoutHandle = undefined
await _stdoutHandle.close()
const _stderrHandle = stderrHandle
stderrHandle = undefined
await _stderrHandle.close()
const capturedStdout = await readFile(stdoutPath, 'utf8')
const capturedStderr = await readFile(stderrPath, 'utf8')
let executionError: string | undefined
if (exitCode !== 0) {
executionError = `QuickJS process exited with code ${exitCode}. Check stderr for details.`
}
return {
stdout: capturedStdout,
stderr: capturedStderr,
error: executionError,
exitCode
}
} catch (error: any) {
return {
stdout: '',
stderr: '',
error: `Sandbox setup or execution failed: ${error?.message ?? String(error)}`,
exitCode: -1
}
} finally {
if (stdoutHandle) await stdoutHandle.close()
if (stderrHandle) await stderrHandle.close()
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
}
}
}
async function execute(code: string) {
return runQuickJsInSandbox(code)
}
const payload = workerData as WorkerPayload | undefined
if (!payload?.code || typeof payload.code !== 'string') {
parentPort.postMessage({ success: false, error: 'JavaScript code must be provided to the worker' })
} else {
execute(payload.code)
.then((result) => {
parentPort?.postMessage({ success: true, result })
})
.catch((error: any) => {
const errorMessage = error instanceof Error ? error.message : String(error)
parentPort?.postMessage({ success: false, error: errorMessage })
})
}

View File

@@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent } from '@shared/config/types'
import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import type { Notification } from '@types'
import {
@@ -51,7 +51,7 @@ const api = {
setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
@@ -223,7 +223,7 @@ const api = {
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
add: ({
base,
item,
@@ -345,6 +345,9 @@ const api = {
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout)
},
js: {
execute: (code: string, timeout?: number) => ipcRenderer.invoke(IpcChannel.Js_Execute, code, timeout)
},
shell: {
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
},
@@ -390,7 +393,16 @@ const api = {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
callback(payload)
}
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
return () => {
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
}
}
},
storeSync: {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),

View File

@@ -166,9 +166,7 @@ export abstract class OpenAIBaseClient<
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers,
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}),
...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {})
...this.provider.extra_headers
}
}) as TSdkInstance
}

View File

@@ -23,6 +23,7 @@ import { CherryWebSearchConfig } from '@renderer/store/websearch'
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
import { replacePromptVariables } from '@renderer/utils/prompt'
import type { ModelMessage, Tool } from 'ai'
import { stepCountIs } from 'ai'
@@ -159,14 +160,14 @@ export async function buildStreamTextParams(
abortSignal: options.requestOptions?.signal,
headers: options.requestOptions?.headers,
providerOptions,
stopWhen: stepCountIs(10),
stopWhen: stepCountIs(20),
maxRetries: 0
}
if (tools) {
params.tools = tools
}
if (assistant.prompt) {
params.system = assistant.prompt
params.system = await replacePromptVariables(assistant.prompt, model.name)
}
logger.debug('params', params)
return {

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@renderer/services/LoggerService', () => ({
loggerService: {
withContext: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
})
}
}))
vi.mock('@renderer/services/AssistantService', () => ({
getProviderByModel: vi.fn()
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({ copilot: { defaultHeaders: {} } })
}
}))
import type { Model, Provider } from '@renderer/types'
import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants'
import { providerToAiSdkConfig } from '../providerConfig'
const createWindowKeyv = () => {
const store = new Map<string, string>()
return {
get: (key: string) => store.get(key),
set: (key: string, value: string) => {
store.set(key, value)
}
}
}
const createCopilotProvider = (): Provider => ({
id: 'copilot',
type: 'openai',
name: 'GitHub Copilot',
apiKey: 'test-key',
apiHost: 'https://api.githubcopilot.com',
models: [],
isSystem: true
})
const createModel = (id: string, name = id): Model => ({
id,
name,
provider: 'copilot',
group: 'copilot'
})
describe('Copilot responses routing', () => {
beforeEach(() => {
;(globalThis as any).window = {
...(globalThis as any).window,
keyv: createWindowKeyv()
}
})
it('detects official GPT-5 Codex identifiers case-insensitively', () => {
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'gpt-5-codex'))).toBe(true)
expect(isCopilotResponsesModel(createModel('GPT-5-CODEX', 'GPT-5-CODEX'))).toBe(true)
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'custom-name'))).toBe(true)
expect(isCopilotResponsesModel(createModel('custom-id', 'custom-name'))).toBe(false)
})
it('configures gpt-5-codex with the Copilot provider', () => {
const provider = createCopilotProvider()
const config = providerToAiSdkConfig(provider, createModel('gpt-5-codex', 'GPT-5-CODEX'))
expect(config.providerId).toBe('github-copilot-openai-compatible')
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_EDITOR_VERSION)
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
expect(config.options.headers?.['copilot-vision-request']).toBe('true')
})
it('uses the Copilot provider for other models and keeps headers', () => {
const provider = createCopilotProvider()
const config = providerToAiSdkConfig(provider, createModel('gpt-4'))
expect(config.providerId).toBe('github-copilot-openai-compatible')
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_DEFAULT_HEADERS['Editor-Version'])
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
})
})

View File

@@ -0,0 +1,25 @@
import type { Model } from '@renderer/types'
export const COPILOT_EDITOR_VERSION = 'vscode/1.104.1'
export const COPILOT_PLUGIN_VERSION = 'copilot-chat/0.26.7'
export const COPILOT_INTEGRATION_ID = 'vscode-chat'
export const COPILOT_USER_AGENT = 'GitHubCopilotChat/0.26.7'
export const COPILOT_DEFAULT_HEADERS = {
'Copilot-Integration-Id': COPILOT_INTEGRATION_ID,
'User-Agent': COPILOT_USER_AGENT,
'Editor-Version': COPILOT_EDITOR_VERSION,
'Editor-Plugin-Version': COPILOT_PLUGIN_VERSION,
'editor-version': COPILOT_EDITOR_VERSION,
'editor-plugin-version': COPILOT_PLUGIN_VERSION,
'copilot-vision-request': 'true'
} as const
// Models that require the OpenAI Responses endpoint when routed through GitHub Copilot (#10560)
const COPILOT_RESPONSES_MODEL_IDS = ['gpt-5-codex']
export function isCopilotResponsesModel(model: Model): boolean {
const normalizedId = model.id?.trim().toLowerCase()
const normalizedName = model.name?.trim().toLowerCase()
return COPILOT_RESPONSES_MODEL_IDS.some((target) => normalizedId === target || normalizedName === target)
}

View File

@@ -28,7 +28,8 @@ const STATIC_PROVIDER_MAPPING: Record<string, ProviderId> = {
gemini: 'google', // Google Gemini -> google
'azure-openai': 'azure', // Azure OpenAI -> azure
'openai-response': 'openai', // OpenAI Responses -> openai
grok: 'xai' // Grok -> xai
grok: 'xai', // Grok -> xai
copilot: 'github-copilot-openai-compatible'
}
/**

View File

@@ -21,6 +21,7 @@ import { formatApiHost } from '@renderer/utils/api'
import { cloneDeep, trim } from 'lodash'
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
import { COPILOT_DEFAULT_HEADERS } from './constants'
import { getAiSdkProviderId } from './factory'
const logger = loggerService.withContext('ProviderConfigProcessor')
@@ -109,6 +110,9 @@ function formatProviderApiHost(provider: Provider): Provider {
if (!formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatted.apiHost
}
} else if (formatted.id === 'copilot') {
const trimmed = trim(formatted.apiHost)
formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
} else if (formatted.type === 'gemini') {
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
} else {
@@ -151,6 +155,26 @@ export function providerToAiSdkConfig(
baseURL: trim(actualProvider.apiHost),
apiKey: getRotatedApiKey(actualProvider)
}
const isCopilotProvider = actualProvider.id === 'copilot'
if (isCopilotProvider) {
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
headers: {
...COPILOT_DEFAULT_HEADERS,
...storedHeaders,
...actualProvider.extra_headers
},
name: actualProvider.id,
includeUsage: true
})
return {
providerId: 'github-copilot-openai-compatible',
options
}
}
// 处理OpenAI模式
const extraOptions: any = {}
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
@@ -172,15 +196,6 @@ export function providerToAiSdkConfig(
}
}
}
// copilot
if (actualProvider.id === 'copilot') {
extraOptions.headers = {
...extraOptions.headers,
'editor-version': 'vscode/1.97.2',
'copilot-vision-request': 'true'
}
}
// azure
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
extraOptions.apiVersion = actualProvider.apiVersion
@@ -229,7 +244,6 @@ export function providerToAiSdkConfig(
}
}
// 如果AI SDK支持该provider使用原生配置
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions)
return {
@@ -277,9 +291,17 @@ export async function prepareSpecialProviderConfig(
) {
switch (provider.id) {
case 'copilot': {
const defaultHeaders = store.getState().copilot.defaultHeaders
const { token } = await window.api.copilot.getToken(defaultHeaders)
const defaultHeaders = store.getState().copilot.defaultHeaders ?? {}
const headers = {
...COPILOT_DEFAULT_HEADERS,
...defaultHeaders
}
const { token } = await window.api.copilot.getToken(headers)
config.options.apiKey = token
config.options.headers = {
...headers,
...config.options.headers
}
break
}
case 'cherryai': {

View File

@@ -32,6 +32,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
supportsImageGeneration: true,
aliases: ['vertexai-anthropic']
},
{
id: 'github-copilot-openai-compatible',
name: 'GitHub Copilot OpenAI Compatible',
import: () => import('@opeoginni/github-copilot-openai-compatible'),
creatorFunctionName: 'createGitHubCopilotOpenAICompatible',
supportsImageGeneration: false,
aliases: ['copilot', 'github-copilot']
},
{
id: 'bedrock',
name: 'Amazon Bedrock',

View File

@@ -4,7 +4,7 @@ import type { Assistant, KnowledgeReference } from '@renderer/types'
import { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { isEmpty } from 'lodash'
import { z } from 'zod'
import * as z from 'zod'
/**
* 知识库搜索工具

View File

@@ -1,7 +1,7 @@
import store from '@renderer/store'
import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
import * as z from 'zod'
import { MemoryProcessor } from '../../services/MemoryProcessor'

View File

@@ -3,7 +3,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
import * as z from 'zod'
/**
* 使用预提取关键词的网络搜索工具

View File

@@ -6,6 +6,7 @@ import {
getThinkModelType,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
isGrok4FastReasoningModel,
isGrokReasoningModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
@@ -52,7 +53,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
return {}
}
// Don't disable reasoning for models that require it
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
if (
isGrokReasoningModel(model) ||
isOpenAIReasoningModel(model) ||
isQwenAlwaysThinkModel(model) ||
model.id.includes('seed-oss')
) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
@@ -100,6 +106,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// reasoningEffort有效的情况
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
// 不同的 provider 有不同的思考控制方式,在这里统一解决
if (isDeepSeekHybridInferenceModel(model)) {
if (isSystemProvider(provider)) {
switch (provider.id) {
@@ -142,6 +149,16 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// OpenRouter models
if (model.provider === SystemProviderIds.openrouter) {
// Grok 4 Fast doesn't support effort levels, always use enabled: true
if (isGrok4FastReasoningModel(model)) {
return {
reasoning: {
enabled: true // Ignore effort level, just enable reasoning
}
}
}
// Other OpenRouter models that support effort levels
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return {
reasoning: {
@@ -412,6 +429,13 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
return {}
}
/**
* Get XAI-specific reasoning parameters
* This function should only be called for XAI provider models
* @param assistant - The assistant configuration
* @param model - The model being used
* @returns XAI-specific reasoning parameters
*/
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isSupportedReasoningEffortGrokModel(model)) {
return {}
@@ -419,6 +443,11 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
if (!reasoningEffort) {
return {}
}
// For XAI provider Grok models, use reasoningEffort parameter directly
return {
reasoningEffort
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -87,7 +87,8 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
const [tools, setTools] = useState<ActionTool[]>([])
const isExecutable = useMemo(() => {
return codeExecution.enabled && language === 'python'
const executableLanguages = ['python', 'py', 'javascript', 'js']
return codeExecution.enabled && executableLanguages.includes(language.toLowerCase())
}, [codeExecution.enabled, language])
const sourceViewRef = useRef<CodeEditorHandles>(null)
@@ -152,21 +153,49 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
setIsRunning(true)
setExecutionResult(null)
pyodideService
.runScript(children, {}, codeExecution.timeoutMinutes * 60000)
.then((result) => {
setExecutionResult(result)
})
.catch((error) => {
logger.error('Unexpected error:', error)
setExecutionResult({
text: `Unexpected error: ${error.message || 'Unknown error'}`
const isPython = ['python', 'py'].includes(language.toLowerCase())
const isJavaScript = ['javascript', 'js'].includes(language.toLowerCase())
if (isPython) {
pyodideService
.runScript(children, {}, codeExecution.timeoutMinutes * 60000)
.then((result) => {
setExecutionResult(result)
})
})
.finally(() => {
setIsRunning(false)
})
}, [children, codeExecution.timeoutMinutes])
.catch((error) => {
logger.error('Unexpected error:', error)
setExecutionResult({
text: `Unexpected error: ${error.message || 'Unknown error'}`
})
})
.finally(() => {
setIsRunning(false)
})
} else if (isJavaScript) {
window.api.js
.execute(children, codeExecution.timeoutMinutes * 60000)
.then((result) => {
if (result.error) {
setExecutionResult({
text: `Error: ${result.error}\n${result.stderr || ''}`
})
} else {
setExecutionResult({
text: result.stdout || (result.stderr ? `stderr: ${result.stderr}` : 'Execution completed')
})
}
})
.catch((error) => {
logger.error('Unexpected error:', error)
setExecutionResult({
text: `Unexpected error: ${error.message || 'Unknown error'}`
})
})
.finally(() => {
setIsRunning(false)
})
}
}, [children, codeExecution.timeoutMinutes, language])
const showPreviewTools = useMemo(() => {
return viewMode !== 'source' && hasSpecialView

View File

@@ -549,7 +549,7 @@ const MinappPopupContainer: React.FC = () => {
{/* 在所有小程序中显示GoogleLoginTip */}
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
{!isReady && (
<EmptyView>
<EmptyView style={{ backgroundColor: 'var(--color-background-soft)' }}>
<Avatar
src={currentAppInfo?.logo}
size={80}

View File

@@ -10,6 +10,7 @@ interface ShowParams {
providerId: string
title?: string
showHealthCheck?: boolean
providerType?: 'llm' | 'webSearch' | 'preprocess'
}
interface Props extends ShowParams {
@@ -19,7 +20,7 @@ interface Props extends ShowParams {
/**
* API Key 列表弹窗容器组件
*/
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true }) => {
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true, providerType }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
@@ -32,14 +33,20 @@ const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealt
}
const ListComponent = useMemo(() => {
if (isWebSearchProviderId(providerId)) {
return <WebSearchApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
const type =
providerType ||
(isWebSearchProviderId(providerId) ? 'webSearch' : isPreprocessProviderId(providerId) ? 'preprocess' : 'llm')
switch (type) {
case 'webSearch':
return <WebSearchApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
case 'preprocess':
return <DocPreprocessApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
case 'llm':
default:
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
if (isPreprocessProviderId(providerId)) {
return <DocPreprocessApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}, [providerId, showHealthCheck])
}, [providerId, showHealthCheck, providerType])
return (
<Modal

View File

@@ -0,0 +1,101 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger'
import { handleSaveData } from '@renderer/store'
import { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Markdown from 'react-markdown'
const logger = loggerService.withContext('UpdateDialog')
interface UpdateDialogProps {
isOpen: boolean
onClose: () => void
releaseInfo: UpdateInfo | null
}
const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInfo }) => {
const { t } = useTranslation()
const [isInstalling, setIsInstalling] = useState(false)
useEffect(() => {
if (isOpen && releaseInfo) {
logger.info('Update dialog opened', { version: releaseInfo.version })
}
}, [isOpen, releaseInfo])
const handleInstall = async () => {
setIsInstalling(true)
try {
await handleSaveData()
await window.api.quitAndInstall()
} catch (error) {
logger.error('Failed to save data before update', error as Error)
setIsInstalling(false)
window.toast.error(t('update.saveDataError'))
}
}
const releaseNotes = releaseInfo?.releaseNotes
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
base: 'max-h-[85vh]',
header: 'border-b border-divider',
footer: 'border-t border-divider'
}}>
<ModalContent>
{(onModalClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h3 className="font-semibold text-lg">{t('update.title')}</h3>
<p className="text-default-500 text-small">
{t('update.message').replace('{{version}}', releaseInfo?.version || '')}
</p>
</ModalHeader>
<ModalBody>
<ScrollShadow className="max-h-[450px]" hideScrollBar>
<div className="markdown rounded-lg bg-default-50 p-4">
<Markdown>
{typeof releaseNotes === 'string'
? releaseNotes
: Array.isArray(releaseNotes)
? releaseNotes
.map((note: ReleaseNoteInfo) => note.note)
.filter(Boolean)
.join('\n\n')
: t('update.noReleaseNotes')}
</Markdown>
</div>
</ScrollShadow>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onModalClose} isDisabled={isInstalling}>
{t('update.later')}
</Button>
<Button
color="primary"
onPress={async () => {
await handleInstall()
onModalClose()
}}
isLoading={isInstalling}>
{t('update.install')}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)
}
export default UpdateDialog

View File

@@ -22,8 +22,6 @@ import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?
import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url'
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
import GrokXAppLogo from '@renderer/assets/images/apps/grok-x.png?url'
import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg?url'
import KimiAppLogo from '@renderer/assets/images/apps/kimi.webp?url'
import LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?url'
import LeChatLogo from '@renderer/assets/images/apps/lechat.png?url'
@@ -32,13 +30,13 @@ import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
import n8nLogo from '@renderer/assets/images/apps/n8n.svg?url'
import NamiAiLogo from '@renderer/assets/images/apps/nm.png?url'
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm-search.webp?url'
import NotebookLMAppLogo from '@renderer/assets/images/apps/notebooklm.svg?url'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.webp?url'
import StepfunAppLogo from '@renderer/assets/images/apps/stepfun.png?url'
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
@@ -46,7 +44,6 @@ import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?url'
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
import ZaiAppLogo from '@renderer/assets/images/apps/zai.png?url'
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
@@ -150,9 +147,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
},
{
id: 'stepfun',
name: i18n.t('minapps.yuewen'),
url: 'https://yuewen.cn/chats/new',
logo: YuewenAppLogo,
name: i18n.t('minapps.stepfun'),
url: 'https://stepfun.com',
logo: StepfunAppLogo,
bodered: true
},
{
@@ -263,13 +260,6 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://www.tiangong.cn/',
bodered: true
},
{
id: 'hugging-chat',
name: 'HuggingChat',
logo: HuggingChatLogo,
url: 'https://huggingface.co/chat/',
bodered: true
},
{
id: 'Felo',
name: 'Felo',
@@ -297,13 +287,6 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://bot.n.cn/',
bodered: true
},
{
id: 'nm-search',
name: i18n.t('minapps.nami-ai-search'),
logo: NamiAiSearchLogo,
url: 'https://www.n.cn/',
bodered: true
},
{
id: 'thinkany',
name: 'ThinkAny',
@@ -314,13 +297,6 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
padding: 5
}
},
{
id: 'hika',
name: 'Hika',
logo: HikaLogo,
url: 'https://hika.fyi/',
bodered: true
},
{
id: 'github-copilot',
name: 'GitHub Copilot',

View File

@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
// Default quick assistant model
glm45FlashModel
],
// cherryin: [],
cherryin: [],
vertexai: [],
'302ai': [
{

View File

@@ -14,7 +14,7 @@ import { GEMINI_FLASH_MODEL_REGEX } from './websearch'
// Reasoning models
export const REASONING_REGEX =
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4)(?:-[\w-]+)?\b.*)$/i
/^(?!.*-non-reasoning\b)(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4|4-fast)(?:-[\w-]+)?\b.*)$/i
// 模型类型到支持的reasoning_effort的映射表
// TODO: refactor this. too many identical options
@@ -24,6 +24,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
gpt5_codex: ['low', 'medium', 'high'] as const,
grok: ['low', 'high'] as const,
grok4_fast: ['auto'] as const,
gemini: ['low', 'medium', 'high', 'auto'] as const,
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
qwen: ['low', 'medium', 'high'] as const,
@@ -43,6 +44,7 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
grok4_fast: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
@@ -66,6 +68,8 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
}
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
thinkingModelType = 'o'
} else if (isGrok4FastReasoningModel(model)) {
thinkingModelType = 'grok4_fast'
} else if (isSupportedThinkingTokenGeminiModel(model)) {
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
thinkingModelType = 'gemini'
@@ -142,19 +146,46 @@ export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
}
const modelId = getLowerBaseModelName(model.id)
const providerId = model.provider.toLowerCase()
if (modelId.includes('grok-3-mini')) {
return true
}
if (providerId === 'openrouter' && modelId.includes('grok-4-fast')) {
return true
}
return false
}
/**
* Checks if the model is Grok 4 Fast reasoning version
* Explicitly excludes non-reasoning variants (models with 'non-reasoning' in their ID)
*
* Note: XAI official uses different model IDs for reasoning vs non-reasoning
* Third-party providers like OpenRouter expose a single ID with reasoning parameters, while first-party providers require separate IDs. Only the OpenRouter variant supports toggling.
*
* @param model - The model to check
* @returns true if the model is a reasoning-enabled Grok 4 Fast model
*/
export function isGrok4FastReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('grok-4-fast') && !modelId.includes('non-reasoning')
}
export function isGrokReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
if (isSupportedReasoningEffortGrokModel(model) || modelId.includes('grok-4')) {
if (
isSupportedReasoningEffortGrokModel(model) ||
(modelId.includes('grok-4') && !modelId.includes('non-reasoning'))
) {
return true
}
@@ -265,7 +296,11 @@ export function isQwenAlwaysThinkModel(model?: Model): boolean {
return false
}
const modelId = getLowerBaseModelName(model.id, '/')
return modelId.startsWith('qwen3') && modelId.includes('thinking')
// 包括 qwen3 开头的 thinking 模型和 qwen3-vl 的 thinking 模型
return (
(modelId.startsWith('qwen3') && modelId.includes('thinking')) ||
(modelId.includes('qwen3-vl') && modelId.includes('thinking'))
)
}
// Doubao 支持思考模式的模型正则
@@ -329,7 +364,10 @@ export const isPerplexityReasoningModel = (model?: Model): boolean => {
}
const modelId = getLowerBaseModelName(model.id, '/')
return isSupportedReasoningEffortPerplexityModel(model) || modelId.includes('reasoning')
return (
isSupportedReasoningEffortPerplexityModel(model) ||
(modelId.includes('reasoning') && !modelId.includes('non-reasoning'))
)
}
export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean => {
@@ -443,6 +481,8 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
// qwen-plus-x 系列自 qwen-plus-2025-07-28 后模型最长思维链变为 81_920, qwen-plus 模型于 2025.9.16 同步变更
'qwen3-235b-a22b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-30b-a3b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-vl-235b-a22b-thinking$': { min: 0, max: 81_920 },
'qwen3-vl-30b-a3b-thinking$': { min: 0, max: 81_920 },
'qwen-plus-2025-07-14$': { min: 0, max: 38_912 },
'qwen-plus-2025-04-28$': { min: 0, max: 38_912 },
'qwen3-1\\.7b$': { min: 0, max: 30_720 },

View File

@@ -24,7 +24,7 @@ const visionAllowedModels = [
'qwen2.5-vl',
'qwen3-vl',
'qwen2.5-omni',
'qwen3-omni',
'qwen3-omni(?:-[\\w-]+)?',
'qvq',
'internvl2',
'grok-vision-beta',
@@ -82,14 +82,14 @@ export const IMAGE_ENHANCEMENT_MODELS = [
'grok-2-image(?:-[\\w-]+)?',
'qwen-image-edit',
'gpt-image-1',
'gemini-2.5-flash-image-preview',
'gemini-2.5-flash-image',
'gemini-2.0-flash-preview-image-generation'
]
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
// Models that should auto-enable image generation button when selected
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image-preview', ...DEDICATED_IMAGE_MODELS]
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
'o3',
@@ -107,7 +107,7 @@ export const GENERATE_IMAGE_MODELS = [
'gemini-2.0-flash-exp',
'gemini-2.0-flash-exp-image-generation',
'gemini-2.0-flash-preview-image-generation',
'gemini-2.5-flash-image-preview',
'gemini-2.5-flash-image',
...DEDICATED_IMAGE_MODELS
]

View File

@@ -82,16 +82,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
}
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
// cherryin: {
// id: 'cherryin',
// name: 'CherryIN',
// type: 'openai',
// apiKey: '',
// apiHost: 'https://open.cherryin.ai',
// models: [],
// isSystem: true,
// enabled: true
// },
cherryin: {
id: 'cherryin',
name: 'CherryIN',
type: 'openai',
apiKey: '',
apiHost: 'https://open.cherryin.net',
models: [],
isSystem: true,
enabled: true
},
silicon: {
id: 'silicon',
name: 'Silicon',
@@ -742,17 +742,17 @@ type ProviderUrls = {
}
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
// cherryin: {
// api: {
// url: 'https://open.cherryin.ai'
// },
// websites: {
// official: 'https://open.cherryin.ai',
// apiKey: 'https://open.cherryin.ai/console/token',
// docs: 'https://open.cherryin.ai',
// models: 'https://open.cherryin.ai/pricing'
// }
// },
cherryin: {
api: {
url: 'https://open.cherryin.net'
},
websites: {
official: 'https://open.cherryin.ai',
apiKey: 'https://open.cherryin.ai/console/token',
docs: 'https://open.cherryin.ai',
models: 'https://open.cherryin.ai/pricing'
}
},
ph8: {
api: {
url: 'https://ph8.co'

View File

@@ -360,7 +360,7 @@ export const useKnowledgeBases = () => {
const deleteKnowledgeBase = (baseId: string) => {
const base = bases.find((b) => b.id === baseId)
if (!base) return
dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) }))
dispatch(deleteBase({ baseId }))
// remove assistant knowledge_base
const _assistants = assistants.map((assistant) => {

View File

@@ -329,7 +329,8 @@ const builtInMcpDescriptionKeyMap: Record<BuiltinMCPServerName, string> = {
[BuiltinMCPServerNames.fetch]: 'settings.mcp.builtinServersDescriptions.fetch',
[BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem',
[BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge',
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python'
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python',
[BuiltinMCPServerNames.js]: 'settings.mcp.builtinServersDescriptions.js'
} as const
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -3571,6 +3571,7 @@
"dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key",
"fetch": "MCP server for retrieving URL web content",
"filesystem": "A Node.js server implementing the Model Context Protocol (MCP) for file system operations. Requires configuration of directories allowed for access.",
"js": "Execute JavaScript code in a secure sandbox environment. Run JavaScript using QuickJS, supporting most standard libraries and popular third-party libraries.",
"mcp_auto_install": "Automatically install MCP service (beta)",
"memory": "Persistent memory implementation based on a local knowledge graph. This enables the model to remember user-related information across different conversations. Requires configuring the MEMORY_FILE_PATH environment variable.",
"no": "No description",
@@ -4641,6 +4642,7 @@
"later": "Later",
"message": "New version {{version}} is ready, do you want to install it now?",
"noReleaseNotes": "No release notes",
"saveDataError": "Failed to save data, please try again.",
"title": "Update"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "纳米AI搜索",
"qwen": "通义千问",
"sensechat": "商量",
"stepfun": "阶跃AI",
"tencent-yuanbao": "腾讯元宝",
"tiangong-ai": "天工AI",
"wanzhi": "万知",
"wenxin": "文心一言",
"wps-copilot": "WPS灵犀",
"xiaoyi": "小艺",
"yuewen": "跃问",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -3571,6 +3571,7 @@
"dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key",
"fetch": "用于获取 URL 网页内容的 MCP 服务器",
"filesystem": "实现文件系统操作的模型上下文协议MCP的 Node.js 服务器。需要配置允许访问的目录",
"js": "在安全的沙盒环境中执行 JavaScript 代码。使用 quickJs 运行 JavaScript支持大多数标准库和流行的第三方库",
"mcp_auto_install": "自动安装 MCP 服务(测试版)",
"memory": "基于本地知识图谱的持久性记忆基础实现。这使得模型能够在不同对话间记住用户的相关信息。需要配置 MEMORY_FILE_PATH 环境变量。",
"no": "无描述",
@@ -4641,6 +4642,7 @@
"later": "稍后",
"message": "发现新版本 {{version}},是否立即安装?",
"noReleaseNotes": "暂无更新日志",
"saveDataError": "保存数据失败,请重试",
"title": "更新提示"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "納米AI搜索",
"qwen": "通義千問",
"sensechat": "商量",
"stepfun": "階躍AI",
"tencent-yuanbao": "騰訊元寶",
"tiangong-ai": "天工AI",
"wanzhi": "萬知",
"wenxin": "文心一言",
"wps-copilot": "WPS靈犀",
"xiaoyi": "小藝",
"yuewen": "躍問",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -3571,6 +3571,7 @@
"dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key",
"fetch": "用於獲取 URL 網頁內容的 MCP 伺服器",
"filesystem": "實現文件系統操作的模型上下文協議MCP的 Node.js 伺服器。需要配置允許訪問的目錄",
"js": "在安全的沙盒環境中執行 JavaScript 程式碼。使用 quickJs 執行 JavaScript支援大多數標準函式庫和流行的第三方函式庫",
"mcp_auto_install": "自動安裝 MCP 服務(測試版)",
"memory": "基於本地知識圖譜的持久性記憶基礎實現。這使得模型能夠在不同對話間記住使用者的相關資訊。需要配置 MEMORY_FILE_PATH 環境變數。",
"no": "無描述",
@@ -4641,6 +4642,7 @@
"later": "稍後",
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "保存數據失敗,請重試",
"title": "更新提示"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -4641,6 +4641,7 @@
"later": "Μετά",
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
"noReleaseNotes": "Χωρίς σημειώσεις",
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
"title": "Ενημέρωση"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -4641,6 +4641,7 @@
"later": "Más tarde",
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
"noReleaseNotes": "Sin notas de la versión",
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
"title": "Actualización"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -4641,6 +4641,7 @@
"later": "Plus tard",
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
"noReleaseNotes": "Aucune note de version",
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
"title": "Mise à jour"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "通義千問",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "騰訊元宝",
"tiangong-ai": "Skywork",
"wanzhi": "万知",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "小藝",
"yuewen": "躍問",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -4641,6 +4641,7 @@
"later": "後で",
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
"title": "更新"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -4641,6 +4641,7 @@
"later": "Mais tarde",
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
"noReleaseNotes": "Sem notas de versão",
"saveDataError": "Falha ao salvar os dados, tente novamente",
"title": "Atualização"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Tencent Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -4641,6 +4641,7 @@
"later": "Позже",
"message": "Новая версия {{version}} готова, установить сейчас?",
"noReleaseNotes": "Нет заметок об обновлении",
"saveDataError": "Ошибка сохранения данных, повторите попытку",
"title": "Обновление"
},
"warning": {

View File

@@ -2,7 +2,7 @@ import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { Tooltip } from 'antd'
import React, { memo, useCallback, useMemo } from 'react'
import styled from 'styled-components'
import { z } from 'zod'
import * as z from 'zod'
export const CitationSchema = z.object({
url: z.url(),

View File

@@ -359,8 +359,7 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
&.vertical {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 8px;
overflow-y: auto;
overflow-x: hidden;
overflow: hidden;
}
&.grid {
grid-template-columns: repeat(

View File

@@ -535,7 +535,30 @@ const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ i
}
const highlight = async () => {
const result = await highlightCode(resultString, 'json')
// 处理转义字符
let processedString = resultString
try {
// 尝试解析字符串以处理可能的转义
const parsed = JSON.parse(resultString)
if (typeof parsed === 'string') {
// 如果解析后是字符串,再次尝试解析(处理双重转义)
try {
const doubleParsed = JSON.parse(parsed)
processedString = JSON.stringify(doubleParsed, null, 2)
} catch {
// 不是有效的 JSON使用解析后的字符串
processedString = parsed
}
} else {
// 重新格式化 JSON
processedString = JSON.stringify(parsed, null, 2)
}
} catch {
// 解析失败,使用原始字符串
processedString = resultString
}
const result = await highlightCode(processedString, 'json')
setStyledResult(result)
}

View File

@@ -0,0 +1,40 @@
import { Button, Divider } from '@heroui/react'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { AgentSettingsPopup } from '@renderer/pages/settings/AgentSettings'
import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings'
import AgentEssentialSettings from '@renderer/pages/settings/AgentSettings/AgentEssentialSettings'
import { GetAgentResponse } from '@renderer/types/agent'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
agent: GetAgentResponse | undefined | null
update: ReturnType<typeof useUpdateAgent>['updateAgent']
}
const AgentSettingsTab: FC<Props> = ({ agent, update }) => {
const { t } = useTranslation()
const onMoreSetting = () => {
if (agent?.id) {
AgentSettingsPopup.show({ agentId: agent.id! })
}
}
if (!agent) {
return null
}
return (
<div className="w-[var(--assistants-width)] p-2 px-3 pt-4">
<AgentEssentialSettings agent={agent} update={update} showModelSetting={false} />
<AdvancedSettings agentBase={agent} update={update} />
<Divider className="my-2" />
<Button size="sm" fullWidth onPress={onMoreSetting}>
{t('settings.moresetting.label')}
</Button>
</div>
)
}
export default AgentSettingsTab

View File

@@ -1,10 +1,26 @@
import { Alert, Spinner } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar'
import { Assistant } from '@renderer/types'
import { FC, useRef } from 'react'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAssistantsTabSortType } from '@renderer/hooks/useStore'
import { useTags } from '@renderer/hooks/useTags'
import { useAppDispatch } from '@renderer/store'
import { addIknowAction } from '@renderer/store/runtime'
import { Assistant, AssistantsSortType } from '@renderer/types'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { AgentSection } from './components/AgentSection'
import Assistants from './components/Assistants'
import UnifiedAddButton from './components/UnifiedAddButton'
import { UnifiedList } from './components/UnifiedList'
import { UnifiedTagGroups } from './components/UnifiedTagGroups'
import { useActiveAgent } from './hooks/useActiveAgent'
import { useUnifiedGrouping } from './hooks/useUnifiedGrouping'
import { useUnifiedItems } from './hooks/useUnifiedItems'
import { useUnifiedSorting } from './hooks/useUnifiedSorting'
interface AssistantsTabProps {
activeAssistant: Assistant
@@ -13,12 +29,143 @@ interface AssistantsTabProps {
onCreateDefaultAssistant: () => void
}
const ALERT_KEY = 'enable_api_server_to_use_agent'
const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props
const containerRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
const { apiServer } = useSettings()
const { iknow, chat } = useRuntime()
const dispatch = useAppDispatch()
// Agent related hooks
const { agents, deleteAgent, isLoading: agentsLoading, error: agentsError } = useAgents()
const { activeAgentId } = chat
const { setActiveAgentId } = useActiveAgent()
// Assistant related hooks
const { assistants, removeAssistant, copyAssistant, updateAssistants } = useAssistants()
const { addAssistantPreset } = useAssistantPresets()
const { collapsedTags, toggleTagCollapse } = useTags()
const { assistantsTabSortType = 'list', setAssistantsTabSortType } = useAssistantsTabSortType()
const [dragging, setDragging] = useState(false)
// Unified items management
const { unifiedItems, handleUnifiedListReorder } = useUnifiedItems({
agents,
assistants,
apiServerEnabled: apiServer.enabled,
agentsLoading,
agentsError,
updateAssistants
})
// Sorting
const { sortByPinyinAsc, sortByPinyinDesc } = useUnifiedSorting({
unifiedItems,
updateAssistants
})
// Grouping
const { groupedUnifiedItems, handleUnifiedGroupReorder } = useUnifiedGrouping({
unifiedItems,
assistants,
agents,
apiServerEnabled: apiServer.enabled,
agentsLoading,
agentsError,
updateAssistants
})
useEffect(() => {
if (!agentsLoading && agents.length > 0 && !activeAgentId && apiServer.enabled) {
setActiveAgentId(agents[0].id)
}
}, [agentsLoading, agents, activeAgentId, setActiveAgentId, apiServer.enabled])
const onDeleteAssistant = useCallback(
(assistant: Assistant) => {
const remaining = assistants.filter((a) => a.id !== assistant.id)
if (assistant.id === activeAssistant?.id) {
const newActive = remaining[remaining.length - 1]
newActive ? setActiveAssistant(newActive) : onCreateDefaultAssistant()
}
removeAssistant(assistant.id)
},
[activeAssistant, assistants, removeAssistant, setActiveAssistant, onCreateDefaultAssistant]
)
const handleSortByChange = useCallback(
(sortType: AssistantsSortType) => {
setAssistantsTabSortType(sortType)
},
[setAssistantsTabSortType]
)
return (
<Container className="assistants-tab" ref={containerRef}>
<AgentSection />
<Assistants {...props} />
{!apiServer.enabled && !iknow[ALERT_KEY] && (
<Alert
color="warning"
title={t('agent.warning.enable_server')}
isClosable
onClose={() => {
dispatch(addIknowAction(ALERT_KEY))
}}
/>
)}
{agentsLoading && <Spinner />}
{apiServer.enabled && agentsError && <Alert color="danger" title={t('agent.list.error.failed')} />}
{assistantsTabSortType === 'tags' ? (
<UnifiedTagGroups
groupedItems={groupedUnifiedItems}
activeAssistantId={activeAssistant.id}
activeAgentId={activeAgentId}
sortBy={assistantsTabSortType}
collapsedTags={collapsedTags}
onGroupReorder={handleUnifiedGroupReorder}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}
onToggleTagCollapse={toggleTagCollapse}
onAssistantSwitch={setActiveAssistant}
onAssistantDelete={onDeleteAssistant}
onAgentDelete={deleteAgent}
onAgentPress={setActiveAgentId}
addPreset={addAssistantPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
) : (
<UnifiedList
items={unifiedItems}
activeAssistantId={activeAssistant.id}
activeAgentId={activeAgentId}
sortBy={assistantsTabSortType}
onReorder={handleUnifiedListReorder}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}
onAssistantSwitch={setActiveAssistant}
onAssistantDelete={onDeleteAssistant}
onAgentDelete={deleteAgent}
onAgentPress={setActiveAgentId}
addPreset={addAssistantPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
)}
<UnifiedAddButton onCreateAssistant={onCreateAssistant} />
{!dragging && <div style={{ minHeight: 10 }}></div>}
</Container>
)
}
@@ -27,7 +174,6 @@ const Container = styled(Scrollbar)`
display: flex;
flex-direction: column;
padding: 10px;
margin-top: 3px;
`
export default AssistantsTab

View File

@@ -0,0 +1,24 @@
import { Button, ButtonProps, cn } from '@heroui/react'
import { PlusIcon } from 'lucide-react'
import { FC } from 'react'
interface Props extends ButtonProps {
children: React.ReactNode
}
const AddButton: FC<Props> = ({ children, className, ...props }) => {
return (
<Button
{...props}
onPress={props.onPress}
className={cn(
'h-9 w-[calc(var(--assistants-width)-20px)] justify-start rounded-lg bg-transparent px-3 text-[13px] text-[var(--color-text-2)] hover:bg-[var(--color-list-item)]',
className
)}
startContent={<PlusIcon size={16} className="shrink-0" />}>
{children}
</Button>
)
}
export default AddButton

View File

@@ -1,11 +1,14 @@
import { Button, Chip, cn } from '@heroui/react'
import { cn } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useSettings } from '@renderer/hooks/useSettings'
import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings/AgentSettingsPopup'
import { AgentLabel } from '@renderer/pages/settings/AgentSettings/shared'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { AgentEntity } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import { FC, memo } from 'react'
import { Bot } from 'lucide-react'
import { FC, memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
// const logger = loggerService.withContext('AgentItem')
@@ -20,81 +23,100 @@ interface AgentItemProps {
const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) => {
const { t } = useTranslation()
const { sessions } = useSessions(agent.id)
const { clickAssistantToShowTopic, topicPosition } = useSettings()
const handlePress = useCallback(() => {
// Show session sidebar if setting is enabled (reusing the assistant setting for consistency)
if (clickAssistantToShowTopic) {
if (topicPosition === 'left') {
EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
}
}
onPress()
}, [clickAssistantToShowTopic, topicPosition, onPress])
return (
<>
<ContextMenu modal={false}>
<ContextMenuTrigger>
<ButtonContainer onPress={onPress} className={isActive ? 'active' : ''}>
<AssistantNameRow className="name flex w-full justify-between" title={agent.name ?? agent.id}>
<ContextMenu modal={false}>
<ContextMenuTrigger>
<Container onClick={handlePress} isActive={isActive}>
<AssistantNameRow className="name" title={agent.name ?? agent.id}>
<AgentNameWrapper>
<AgentLabel agent={agent} />
{isActive && (
<Chip
variant="bordered"
size="sm"
radius="full"
className="aspect-square h-5 w-5 items-center justify-center border-[0.5px] bg-background text-[10px]">
{sessions.length}
</Chip>
)}
</AssistantNameRow>
</ButtonContainer>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
key="edit"
onClick={async () => {
// onOpen()
await AgentSettingsPopup.show({
agentId: agent.id
})
}}>
<EditIcon size={14} />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuItem
key="delete"
className="text-danger"
onClick={() => {
window.modal.confirm({
title: t('agent.delete.title'),
content: t('agent.delete.content'),
centered: true,
okButtonProps: { danger: true },
onOk: () => onDelete(agent)
})
}}>
<DeleteIcon size={14} className="lucide-custom text-danger" />
<span className="text-danger">{t('common.delete')}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* <AgentModal isOpen={isOpen} onClose={onClose} agent={agent} /> */}
</>
</AgentNameWrapper>
</AssistantNameRow>
<MenuButton>
{isActive ? <SessionCount>{sessions.length}</SessionCount> : <Bot size={14} className="text-primary" />}
</MenuButton>
</Container>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem key="edit" onClick={() => AgentSettingsPopup.show({ agentId: agent.id })}>
<EditIcon size={14} />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuItem
key="delete"
className="text-danger"
onClick={() => {
window.modal.confirm({
title: t('agent.delete.title'),
content: t('agent.delete.content'),
centered: true,
okButtonProps: { danger: true },
onOk: () => onDelete(agent)
})
}}>
<DeleteIcon size={14} className="lucide-custom text-danger" />
<span className="text-danger">{t('common.delete')}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
const ButtonContainer: React.FC<React.ComponentProps<typeof Button>> = ({ className, children, ...props }) => (
<Button
{...props}
export const Container: React.FC<{ isActive?: boolean } & React.HTMLAttributes<HTMLDivElement>> = ({
className,
isActive,
...props
}) => (
<div
className={cn(
'relative mb-2 flex h-[37px] flex-row justify-between p-2.5',
'rounded-[var(--list-item-border-radius)]',
'border-[0.5px] border-transparent',
'w-[calc(var(--assistants-width)_-_20px)]',
'bg-transparent hover:bg-[var(--color-list-item)] hover:shadow-sm',
'cursor-pointer',
className?.includes('active') && 'bg-[var(--color-list-item)] shadow-sm',
'relative flex h-[37px] w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-[var(--list-item-border-radius)] border border-transparent px-2 hover:bg-[var(--color-list-item-hover)]',
isActive && 'bg-[var(--color-list-item)] shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]',
className
)}>
{children}
</Button>
)}
{...props}
/>
)
const AssistantNameRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
export const AssistantNameRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn('flex min-w-0 flex-1 flex-row items-center gap-2 text-[13px] text-[var(--color-text)]', className)}
{...props}
/>
)
export const AgentNameWrapper: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div className={cn('min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap', className)} {...props} />
)
export const MenuButton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'absolute top-[6px] right-[9px] flex h-[22px] min-h-[22px] w-[22px] flex-row items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-background)] px-[5px]',
className
)}
{...props}
/>
)
export const SessionCount: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'flex flex-row items-center justify-center rounded-full text-[10px] text-[var(--color-text)]',
className
)}
{...props}
className={cn('text-[13px] text-[var(--color-text)]', 'flex flex-row items-center gap-2', className)}
/>
)

View File

@@ -1,39 +0,0 @@
import { Alert } from '@heroui/react'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { addIknowAction } from '@renderer/store/runtime'
import { useTranslation } from 'react-i18next'
import { Agents } from './Agents'
import { SectionName } from './SectionName'
const ALERT_KEY = 'enable_api_server_to_use_agent'
export const AgentSection = () => {
const { t } = useTranslation()
const { apiServer } = useSettings()
const { iknow } = useRuntime()
const dispatch = useAppDispatch()
if (!apiServer.enabled) {
if (iknow[ALERT_KEY]) return null
return (
<Alert
color="warning"
title={t('agent.warning.enable_server')}
isClosable
onClose={() => {
dispatch(addIknowAction(ALERT_KEY))
}}
/>
)
}
return (
<div className="agents-tab mb-2 h-full w-full">
<SectionName name={t('common.agent_other')} />
<Agents />
</div>
)
}

View File

@@ -1,3 +1,4 @@
import { cn } from '@heroui/react'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import EmojiIcon from '@renderer/components/EmojiIcon'
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons'
@@ -28,9 +29,8 @@ import {
Tag,
Tags
} from 'lucide-react'
import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'
import { FC, memo, PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import * as tinyPinyin from 'tiny-pinyin'
import AssistantTagsPopup from './AssistantTagsPopup'
@@ -46,6 +46,8 @@ interface AssistantItemProps {
copyAssistant: (assistant: Assistant) => void
onTagClick?: (tag: string) => void
handleSortByChange?: (sortType: AssistantsSortType) => void
sortByPinyinAsc?: () => void
sortByPinyinDesc?: () => void
}
const AssistantItem: FC<AssistantItemProps> = ({
@@ -56,7 +58,9 @@ const AssistantItem: FC<AssistantItemProps> = ({
onDelete,
addPreset,
copyAssistant,
handleSortByChange
handleSortByChange,
sortByPinyinAsc: externalSortByPinyinAsc,
sortByPinyinDesc: externalSortByPinyinDesc
}) => {
const { t } = useTranslation()
const { allTags } = useTags()
@@ -78,14 +82,19 @@ const AssistantItem: FC<AssistantItemProps> = ({
setIsPending(hasPending)
}, [isActive, assistant.topics])
const sortByPinyinAsc = useCallback(() => {
// Local sort functions
const localSortByPinyinAsc = useCallback(() => {
updateAssistants(sortAssistantsByPinyin(assistants, true))
}, [assistants, updateAssistants])
const sortByPinyinDesc = useCallback(() => {
const localSortByPinyinDesc = useCallback(() => {
updateAssistants(sortAssistantsByPinyin(assistants, false))
}, [assistants, updateAssistants])
// Use external sort functions if provided, otherwise use local ones
const sortByPinyinAsc = externalSortByPinyinAsc || localSortByPinyinAsc
const sortByPinyinDesc = externalSortByPinyinDesc || localSortByPinyinDesc
const menuItems = useMemo(
() =>
getMenuItems({
@@ -145,7 +154,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
menu={{ items: menuItems }}
trigger={['contextMenu']}
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
<Container onClick={handleSwitch} className={isActive ? 'active' : ''}>
<Container onClick={handleSwitch} isActive={isActive}>
<AssistantNameRow className="name" title={fullAssistantName}>
{assistantIconType === 'model' ? (
<ModelAvatar
@@ -380,65 +389,75 @@ function getMenuItems({
]
}
const Container = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0 8px;
height: 37px;
position: relative;
border-radius: var(--list-item-border-radius);
border: 0.5px solid transparent;
width: calc(var(--assistants-width) - 20px);
cursor: pointer;
const Container = ({
children,
isActive,
className,
...props
}: PropsWithChildren<{ isActive?: boolean } & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn(
'relative flex h-[37px] w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-[var(--list-item-border-radius)] border-[0.5px] border-transparent px-2 hover:bg-[var(--color-list-item-hover)]',
isActive && 'bg-[var(--color-list-item)] shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]',
className
)}>
{children}
</div>
)
&:hover {
background-color: var(--color-list-item-hover);
}
&.active {
background-color: var(--color-list-item);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
`
const AssistantNameRow = ({
children,
className,
...props
}: PropsWithChildren<{} & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn('flex min-w-0 flex-1 flex-row items-center gap-2 text-[13px] text-[var(--color-text)]', className)}>
{children}
</div>
)
const AssistantNameRow = styled.div`
color: var(--color-text);
font-size: 13px;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
`
const AssistantName = ({
children,
className,
...props
}: PropsWithChildren<{} & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn('min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[13px]', className)}>
{children}
</div>
)
const AssistantName = styled.div`
font-size: 13px;
`
const MenuButton = ({
children,
className,
...props
}: PropsWithChildren<{} & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn(
'absolute top-[6px] right-[9px] flex h-[22px] min-h-[22px] min-w-[22px] flex-row items-center justify-center rounded-[11px] border-[0.5px] border-[var(--color-border)] bg-[var(--color-background)] px-[5px]',
className
)}>
{children}
</div>
)
const MenuButton = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
min-width: 22px;
height: 22px;
min-height: 22px;
border-radius: 11px;
position: absolute;
background-color: var(--color-background);
right: 9px;
top: 6px;
padding: 0 5px;
border: 0.5px solid var(--color-border);
`
const TopicCount = styled.div`
color: var(--color-text);
font-size: 10px;
border-radius: 10px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
`
const TopicCount = ({
children,
className,
...props
}: PropsWithChildren<{} & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn(
'flex flex-row items-center justify-center rounded-[10px] text-[10px] text-[var(--color-text)]',
className
)}>
{children}
</div>
)
export default memo(AssistantItem)

View File

@@ -1,4 +1,4 @@
import { Alert, Button, Spinner } from '@heroui/react'
import { Alert, Spinner } from '@heroui/react'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
@@ -13,10 +13,10 @@ import {
import { CreateSessionForm } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus } from 'lucide-react'
import { memo, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import AddButton from './AddButton'
import SessionItem from './SessionItem'
// const logger = loggerService.withContext('SessionsTab')
@@ -115,12 +115,9 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
transition={{ duration: 0.3 }}
className="sessions-tab flex h-full w-full flex-col p-2">
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
<Button
onPress={handleCreateSession}
className="mb-2 w-full justify-start bg-transparent text-foreground-500 hover:bg-accent">
<Plus size={16} className="mr-1 shrink-0" />
<AddButton onPress={handleCreateSession} className="mb-2">
{t('agent.session.add.title')}
</Button>
</AddButton>
</motion.div>
<AnimatePresence>
{/* h-9 */}

View File

@@ -0,0 +1,63 @@
import { DownOutlined, RightOutlined } from '@ant-design/icons'
import { cn } from '@heroui/react'
import { Tooltip } from 'antd'
import { FC, ReactNode } from 'react'
interface TagGroupProps {
tag: string
isCollapsed: boolean
onToggle: (tag: string) => void
showTitle?: boolean
children: ReactNode
}
export const TagGroup: FC<TagGroupProps> = ({ tag, isCollapsed, onToggle, showTitle = true, children }) => {
return (
<TagsContainer>
{showTitle && (
<GroupTitle onClick={() => onToggle(tag)}>
<Tooltip title={tag}>
<GroupTitleName>
{isCollapsed ? (
<RightOutlined style={{ fontSize: '10px', marginRight: '5px' }} />
) : (
<DownOutlined style={{ fontSize: '10px', marginRight: '5px' }} />
)}
{tag}
</GroupTitleName>
</Tooltip>
<GroupTitleDivider />
</GroupTitle>
)}
{!isCollapsed && <div>{children}</div>}
</TagsContainer>
)
}
const TagsContainer: FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, ...props }) => (
<div className={cn('flex flex-col gap-2')} {...props}>
{children}
</div>
)
const GroupTitle: FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, ...props }) => (
<div
className={cn(
'my-1 flex h-6 cursor-pointer flex-row items-center justify-between font-medium text-[var(--color-text-2)] text-xs'
)}
{...props}>
{children}
</div>
)
const GroupTitleName: FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, ...props }) => (
<div
className={cn('mr-1 box-border flex max-w-[50%] truncate px-1 text-[13px] text-[var(--color-text)] leading-6')}
{...props}>
{children}
</div>
)
const GroupTitleDivider: FC<React.HTMLAttributes<HTMLDivElement>> = (props) => (
<div className={cn('flex-1 border-[var(--color-border)] border-t')} {...props} />
)

View File

@@ -41,7 +41,6 @@ import {
PackagePlus,
PinIcon,
PinOffIcon,
PlusIcon,
Save,
Sparkles,
UploadIcon,
@@ -52,6 +51,8 @@ import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'
import AddButton from './AddButton'
interface Props {
assistant: Assistant
activeTopic: Topic
@@ -497,13 +498,12 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
className="topics-tab"
list={sortedTopics}
onUpdate={updateTopics}
style={{ height: '100%', padding: '13px 0 10px 10px' }}
style={{ height: '100%', padding: '11px 0 10px 10px' }}
itemContainerStyle={{ paddingBottom: '8px' }}
header={
<AddTopicButton onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
<PlusIcon size={16} />
<AddButton onPress={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} className="mb-2">
{t('chat.add.topic.title')}
</AddTopicButton>
</AddButton>
}>
{(topic) => {
const isActive = topic.id === activeTopic?.id
@@ -740,31 +740,6 @@ const FulfilledIndicator = styled.div.attrs({
background-color: var(--color-status-success);
`
const AddTopicButton = styled.div`
display: flex;
align-items: center;
gap: 6px;
width: calc(100% - 10px);
padding: 7px 12px;
margin-bottom: 8px;
background: transparent;
color: var(--color-text-2);
font-size: 13px;
border-radius: var(--list-item-border-radius);
cursor: pointer;
transition: all 0.2s;
margin-top: -5px;
&:hover {
background-color: var(--color-list-item-hover);
color: var(--color-text-1);
}
.anticon {
font-size: 12px;
}
`
const TopicPromptText = styled.div`
color: var(--color-text-2);
font-size: 12px;

View File

@@ -0,0 +1,61 @@
import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react'
import { AgentModal } from '@renderer/components/Popups/agent/AgentModal'
import { Bot, MessageSquare } from 'lucide-react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AddButton from './AddButton'
interface UnifiedAddButtonProps {
onCreateAssistant: () => void
}
const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => {
const { t } = useTranslation()
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const [isAgentModalOpen, setIsAgentModalOpen] = useState(false)
const handleAddAssistant = () => {
setIsPopoverOpen(false)
onCreateAssistant()
}
const handleAddAgent = () => {
setIsPopoverOpen(false)
setIsAgentModalOpen(true)
}
return (
<div className="mb-1">
<Popover
isOpen={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
placement="bottom"
classNames={{ content: 'p-0 min-w-[200px]' }}>
<PopoverTrigger>
<AddButton>{t('chat.add.assistant.title')}</AddButton>
</PopoverTrigger>
<PopoverContent>
<div className="flex w-full flex-col gap-1 p-1">
<Button
onPress={handleAddAssistant}
className="w-full justify-start bg-transparent hover:bg-[var(--color-list-item)]"
startContent={<MessageSquare size={16} className="shrink-0" />}>
{t('chat.add.assistant.title')}
</Button>
<Button
onPress={handleAddAgent}
className="w-full justify-start bg-transparent hover:bg-[var(--color-list-item)]"
startContent={<Bot size={16} className="shrink-0" />}>
{t('agent.add.title')}
</Button>
</div>
</PopoverContent>
</Popover>
<AgentModal isOpen={isAgentModalOpen} onClose={() => setIsAgentModalOpen(false)} />
</div>
)
}
export default UnifiedAddButton

View File

@@ -0,0 +1,108 @@
import { DraggableList } from '@renderer/components/DraggableList'
import { Assistant, AssistantsSortType } from '@renderer/types'
import { FC, useCallback } from 'react'
import { UnifiedItem } from '../hooks/useUnifiedItems'
import AgentItem from './AgentItem'
import AssistantItem from './AssistantItem'
interface UnifiedListProps {
items: UnifiedItem[]
activeAssistantId: string
activeAgentId: string | null
sortBy: AssistantsSortType
onReorder: (newList: UnifiedItem[]) => void
onDragStart: () => void
onDragEnd: () => void
onAssistantSwitch: (assistant: Assistant) => void
onAssistantDelete: (assistant: Assistant) => void
onAgentDelete: (agentId: string) => void
onAgentPress: (agentId: string) => void
addPreset: (assistant: Assistant) => void
copyAssistant: (assistant: Assistant) => void
onCreateDefaultAssistant: () => void
handleSortByChange: (sortType: AssistantsSortType) => void
sortByPinyinAsc: () => void
sortByPinyinDesc: () => void
}
export const UnifiedList: FC<UnifiedListProps> = (props) => {
const {
items,
activeAssistantId,
activeAgentId,
sortBy,
onReorder,
onDragStart,
onDragEnd,
onAssistantSwitch,
onAssistantDelete,
onAgentDelete,
onAgentPress,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
handleSortByChange,
sortByPinyinAsc,
sortByPinyinDesc
} = props
const renderUnifiedItem = useCallback(
(item: UnifiedItem) => {
if (item.type === 'agent') {
return (
<AgentItem
key={`agent-${item.data.id}`}
agent={item.data}
isActive={item.data.id === activeAgentId}
onDelete={() => onAgentDelete(item.data.id)}
onPress={() => onAgentPress(item.data.id)}
/>
)
} else {
return (
<AssistantItem
key={`assistant-${item.data.id}`}
assistant={item.data}
isActive={item.data.id === activeAssistantId}
sortBy={sortBy}
onSwitch={onAssistantSwitch}
onDelete={onAssistantDelete}
addPreset={addPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
)
}
},
[
activeAgentId,
activeAssistantId,
sortBy,
onAssistantSwitch,
onAssistantDelete,
onAgentDelete,
onAgentPress,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
handleSortByChange,
sortByPinyinAsc,
sortByPinyinDesc
]
)
return (
<DraggableList
list={items}
itemKey={(item) => `${item.type}-${item.data.id}`}
onUpdate={onReorder}
onDragStart={onDragStart}
onDragEnd={onDragEnd}>
{renderUnifiedItem}
</DraggableList>
)
}

View File

@@ -0,0 +1,132 @@
import { DraggableList } from '@renderer/components/DraggableList'
import { Assistant, AssistantsSortType } from '@renderer/types'
import { FC, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { UnifiedItem } from '../hooks/useUnifiedItems'
import AgentItem from './AgentItem'
import AssistantItem from './AssistantItem'
import { TagGroup } from './TagGroup'
interface GroupedItems {
tag: string
items: UnifiedItem[]
}
interface UnifiedTagGroupsProps {
groupedItems: GroupedItems[]
activeAssistantId: string
activeAgentId: string | null
sortBy: AssistantsSortType
collapsedTags: Record<string, boolean>
onGroupReorder: (tag: string, newList: UnifiedItem[]) => void
onDragStart: () => void
onDragEnd: () => void
onToggleTagCollapse: (tag: string) => void
onAssistantSwitch: (assistant: Assistant) => void
onAssistantDelete: (assistant: Assistant) => void
onAgentDelete: (agentId: string) => void
onAgentPress: (agentId: string) => void
addPreset: (assistant: Assistant) => void
copyAssistant: (assistant: Assistant) => void
onCreateDefaultAssistant: () => void
handleSortByChange: (sortType: AssistantsSortType) => void
sortByPinyinAsc: () => void
sortByPinyinDesc: () => void
}
export const UnifiedTagGroups: FC<UnifiedTagGroupsProps> = (props) => {
const {
groupedItems,
activeAssistantId,
activeAgentId,
sortBy,
collapsedTags,
onGroupReorder,
onDragStart,
onDragEnd,
onToggleTagCollapse,
onAssistantSwitch,
onAssistantDelete,
onAgentDelete,
onAgentPress,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
handleSortByChange,
sortByPinyinAsc,
sortByPinyinDesc
} = props
const { t } = useTranslation()
const renderUnifiedItem = useCallback(
(item: UnifiedItem) => {
if (item.type === 'agent') {
return (
<AgentItem
key={`agent-${item.data.id}`}
agent={item.data}
isActive={item.data.id === activeAgentId}
onDelete={() => onAgentDelete(item.data.id)}
onPress={() => onAgentPress(item.data.id)}
/>
)
} else {
return (
<AssistantItem
key={`assistant-${item.data.id}`}
assistant={item.data}
isActive={item.data.id === activeAssistantId}
sortBy={sortBy}
onSwitch={onAssistantSwitch}
onDelete={onAssistantDelete}
addPreset={addPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
)
}
},
[
activeAgentId,
activeAssistantId,
sortBy,
onAssistantSwitch,
onAssistantDelete,
onAgentDelete,
onAgentPress,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
handleSortByChange,
sortByPinyinAsc,
sortByPinyinDesc
]
)
return (
<div>
{groupedItems.map((group) => (
<TagGroup
key={group.tag}
tag={group.tag}
isCollapsed={collapsedTags[group.tag]}
onToggle={onToggleTagCollapse}
showTitle={group.tag !== t('assistants.tags.untagged')}>
<DraggableList
list={group.items}
itemKey={(item) => `${item.type}-${item.data.id}`}
onUpdate={(newList) => onGroupReorder(group.tag, newList)}
onDragStart={onDragStart}
onDragEnd={onDragEnd}>
{renderUnifiedItem}
</DraggableList>
</TagGroup>
))}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { useAgentSessionInitializer } from '@renderer/hooks/agents/useAgentSessionInitializer'
import { useAppDispatch } from '@renderer/store'
import { setActiveAgentId as setActiveAgentIdAction } from '@renderer/store/runtime'
import { useCallback } from 'react'
export const useActiveAgent = () => {
const dispatch = useAppDispatch()
const { initializeAgentSession } = useAgentSessionInitializer()
const setActiveAgentId = useCallback(
async (id: string) => {
dispatch(setActiveAgentIdAction(id))
await initializeAgentSession(id)
},
[dispatch, initializeAgentSession]
)
return { setActiveAgentId }
}

View File

@@ -0,0 +1,140 @@
import { useAppDispatch } from '@renderer/store'
import { setUnifiedListOrder } from '@renderer/store/assistants'
import { AgentEntity, Assistant } from '@renderer/types'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { UnifiedItem } from './useUnifiedItems'
interface UseUnifiedGroupingOptions {
unifiedItems: UnifiedItem[]
assistants: Assistant[]
agents: AgentEntity[]
apiServerEnabled: boolean
agentsLoading: boolean
agentsError: Error | null
updateAssistants: (assistants: Assistant[]) => void
}
export const useUnifiedGrouping = (options: UseUnifiedGroupingOptions) => {
const { unifiedItems, assistants, agents, apiServerEnabled, agentsLoading, agentsError, updateAssistants } = options
const { t } = useTranslation()
const dispatch = useAppDispatch()
// Group unified items by tags
const groupedUnifiedItems = useMemo(() => {
const groups = new Map<string, UnifiedItem[]>()
unifiedItems.forEach((item) => {
if (item.type === 'agent') {
// Agents go to untagged group
const groupKey = t('assistants.tags.untagged')
if (!groups.has(groupKey)) {
groups.set(groupKey, [])
}
groups.get(groupKey)!.push(item)
} else {
// Assistants use their tags
const tags = item.data.tags?.length ? item.data.tags : [t('assistants.tags.untagged')]
tags.forEach((tag) => {
if (!groups.has(tag)) {
groups.set(tag, [])
}
groups.get(tag)!.push(item)
})
}
})
// Sort groups: untagged first, then tagged groups
const untaggedKey = t('assistants.tags.untagged')
const sortedGroups = Array.from(groups.entries()).sort(([tagA], [tagB]) => {
if (tagA === untaggedKey) return -1
if (tagB === untaggedKey) return 1
return 0
})
return sortedGroups.map(([tag, items]) => ({ tag, items }))
}, [unifiedItems, t])
const handleUnifiedGroupReorder = useCallback(
(tag: string, newGroupList: UnifiedItem[]) => {
// Extract only assistants from the new list for updating
const newAssistants = newGroupList.filter((item) => item.type === 'assistant').map((item) => item.data)
// Update assistants state
let insertIndex = 0
const updatedAssistants = assistants.map((a) => {
const tags = a.tags?.length ? a.tags : [t('assistants.tags.untagged')]
if (tags.includes(tag)) {
const replaced = newAssistants[insertIndex]
insertIndex += 1
return replaced || a
}
return a
})
updateAssistants(updatedAssistants)
// Rebuild unified order and save to Redux
const newUnifiedItems: UnifiedItem[] = []
const availableAgents = new Map<string, AgentEntity>()
const availableAssistants = new Map<string, Assistant>()
if (apiServerEnabled && !agentsLoading && !agentsError) {
agents.forEach((agent) => availableAgents.set(agent.id, agent))
}
updatedAssistants.forEach((assistant) => availableAssistants.set(assistant.id, assistant))
// Reconstruct order based on current groupedUnifiedItems structure
groupedUnifiedItems.forEach((group) => {
if (group.tag === tag) {
// Use the new group list for this tag
newGroupList.forEach((item) => {
newUnifiedItems.push(item)
if (item.type === 'agent') {
availableAgents.delete(item.data.id)
} else {
availableAssistants.delete(item.data.id)
}
})
} else {
// Keep existing order for other tags
group.items.forEach((item) => {
newUnifiedItems.push(item)
if (item.type === 'agent') {
availableAgents.delete(item.data.id)
} else {
availableAssistants.delete(item.data.id)
}
})
}
})
// Add any remaining items
availableAgents.forEach((agent) => newUnifiedItems.push({ type: 'agent', data: agent }))
availableAssistants.forEach((assistant) => newUnifiedItems.push({ type: 'assistant', data: assistant }))
// Save to Redux
const orderToSave = newUnifiedItems.map((item) => ({
type: item.type,
id: item.data.id
}))
dispatch(setUnifiedListOrder(orderToSave))
},
[
assistants,
t,
updateAssistants,
apiServerEnabled,
agentsLoading,
agentsError,
agents,
groupedUnifiedItems,
dispatch
]
)
return {
groupedUnifiedItems,
handleUnifiedGroupReorder
}
}

View File

@@ -0,0 +1,73 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setUnifiedListOrder } from '@renderer/store/assistants'
import { AgentEntity, Assistant } from '@renderer/types'
import { useCallback, useMemo } from 'react'
export type UnifiedItem = { type: 'agent'; data: AgentEntity } | { type: 'assistant'; data: Assistant }
interface UseUnifiedItemsOptions {
agents: AgentEntity[]
assistants: Assistant[]
apiServerEnabled: boolean
agentsLoading: boolean
agentsError: Error | null
updateAssistants: (assistants: Assistant[]) => void
}
export const useUnifiedItems = (options: UseUnifiedItemsOptions) => {
const { agents, assistants, apiServerEnabled, agentsLoading, agentsError, updateAssistants } = options
const dispatch = useAppDispatch()
const unifiedListOrder = useAppSelector((state) => state.assistants.unifiedListOrder || [])
// Create unified items list (agents + assistants) with saved order
const unifiedItems = useMemo(() => {
const items: UnifiedItem[] = []
// Collect all available items
const availableAgents = new Map<string, AgentEntity>()
const availableAssistants = new Map<string, Assistant>()
if (apiServerEnabled && !agentsLoading && !agentsError) {
agents.forEach((agent) => availableAgents.set(agent.id, agent))
}
assistants.forEach((assistant) => availableAssistants.set(assistant.id, assistant))
// Apply saved order
unifiedListOrder.forEach((item) => {
if (item.type === 'agent' && availableAgents.has(item.id)) {
items.push({ type: 'agent', data: availableAgents.get(item.id)! })
availableAgents.delete(item.id)
} else if (item.type === 'assistant' && availableAssistants.has(item.id)) {
items.push({ type: 'assistant', data: availableAssistants.get(item.id)! })
availableAssistants.delete(item.id)
}
})
// Add new items (not in saved order) to the end
availableAgents.forEach((agent) => items.push({ type: 'agent', data: agent }))
availableAssistants.forEach((assistant) => items.push({ type: 'assistant', data: assistant }))
return items
}, [agents, assistants, apiServerEnabled, agentsLoading, agentsError, unifiedListOrder])
const handleUnifiedListReorder = useCallback(
(newList: UnifiedItem[]) => {
// Save the unified order to Redux
const orderToSave = newList.map((item) => ({
type: item.type,
id: item.data.id
}))
dispatch(setUnifiedListOrder(orderToSave))
// Extract and update assistants order
const newAssistants = newList.filter((item) => item.type === 'assistant').map((item) => item.data)
updateAssistants(newAssistants)
},
[dispatch, updateAssistants]
)
return {
unifiedItems,
handleUnifiedListReorder
}
}

View File

@@ -0,0 +1,56 @@
import { useAppDispatch } from '@renderer/store'
import { setUnifiedListOrder } from '@renderer/store/assistants'
import { Assistant } from '@renderer/types'
import { useCallback } from 'react'
import * as tinyPinyin from 'tiny-pinyin'
import { UnifiedItem } from './useUnifiedItems'
interface UseUnifiedSortingOptions {
unifiedItems: UnifiedItem[]
updateAssistants: (assistants: Assistant[]) => void
}
export const useUnifiedSorting = (options: UseUnifiedSortingOptions) => {
const { unifiedItems, updateAssistants } = options
const dispatch = useAppDispatch()
const sortUnifiedItemsByPinyin = useCallback((items: UnifiedItem[], isAscending: boolean) => {
return [...items].sort((a, b) => {
const nameA = a.type === 'agent' ? a.data.name || a.data.id : a.data.name
const nameB = b.type === 'agent' ? b.data.name || b.data.id : b.data.name
const pinyinA = tinyPinyin.convertToPinyin(nameA, '', true)
const pinyinB = tinyPinyin.convertToPinyin(nameB, '', true)
return isAscending ? pinyinA.localeCompare(pinyinB) : pinyinB.localeCompare(pinyinA)
})
}, [])
const sortByPinyinAsc = useCallback(() => {
const sorted = sortUnifiedItemsByPinyin(unifiedItems, true)
const orderToSave = sorted.map((item) => ({
type: item.type,
id: item.data.id
}))
dispatch(setUnifiedListOrder(orderToSave))
// Also update assistants order
const newAssistants = sorted.filter((item) => item.type === 'assistant').map((item) => item.data)
updateAssistants(newAssistants)
}, [unifiedItems, sortUnifiedItemsByPinyin, dispatch, updateAssistants])
const sortByPinyinDesc = useCallback(() => {
const sorted = sortUnifiedItemsByPinyin(unifiedItems, false)
const orderToSave = sorted.map((item) => ({
type: item.type,
id: item.data.id
}))
dispatch(setUnifiedListOrder(orderToSave))
// Also update assistants order
const newAssistants = sorted.filter((item) => item.type === 'assistant').map((item) => item.data)
updateAssistants(newAssistants)
}, [unifiedItems, sortUnifiedItemsByPinyin, dispatch, updateAssistants])
return {
sortByPinyinAsc,
sortByPinyinDesc
}
}

View File

@@ -1,4 +1,6 @@
import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
@@ -11,6 +13,7 @@ import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AgentSettingsTab from './AgentSettingsTab'
import Assistants from './AssistantsTab'
import Settings from './SettingsTab'
import Topics from './TopicsTab'
@@ -41,11 +44,12 @@ const HomeTabs: FC<Props> = ({
const { defaultAssistant } = useDefaultAssistant()
const { toggleShowTopics } = useShowTopics()
const { isLeftNavbar } = useNavbarPosition()
const { t } = useTranslation()
const { chat } = useRuntime()
const { activeTopicOrSession } = chat
const { activeTopicOrSession, activeAgentId } = chat
const { agent } = useAgent(activeAgentId)
const { updateAgent } = useUpdateAgent()
const isSessionView = activeTopicOrSession === 'session'
const isTopicView = activeTopicOrSession === 'topic'
@@ -61,7 +65,6 @@ const HomeTabs: FC<Props> = ({
}
const showTab = position === 'left' && topicPosition === 'left'
const shouldShowSettingsTab = !isSessionView
const onCreateAssistant = async () => {
const assistant = await AddAssistantPopup.show()
@@ -104,12 +107,6 @@ const HomeTabs: FC<Props> = ({
}
}, [position, tab, topicPosition, forceToSeeAllTab])
useEffect(() => {
if (activeTopicOrSession === 'session' && tab === 'settings') {
setTab('topic')
}
}, [activeTopicOrSession, tab])
return (
<Container
style={{ ...border, ...style }}
@@ -120,13 +117,11 @@ const HomeTabs: FC<Props> = ({
{t('assistants.abbr')}
</TabItem>
<TabItem active={tab === 'topic'} onClick={() => setTab('topic')}>
{isTopicView ? t('common.topics') : t('agent.session.label_other')}
{t('common.topics')}
</TabItem>
<TabItem active={tab === 'settings'} onClick={() => setTab('settings')}>
{t('settings.title')}
</TabItem>
{shouldShowSettingsTab && (
<TabItem active={tab === 'settings'} onClick={() => setTab('settings')}>
{t('settings.title')}
</TabItem>
)}
</CustomTabs>
)}
@@ -158,7 +153,8 @@ const HomeTabs: FC<Props> = ({
position={position}
/>
)}
{tab === 'settings' && shouldShowSettingsTab && <Settings assistant={activeAssistant} />}
{tab === 'settings' && isTopicView && <Settings assistant={activeAssistant} />}
{tab === 'settings' && isSessionView && <AgentSettingsTab agent={agent} update={updateAgent} />}
</TabContent>
</Container>
)
@@ -214,7 +210,7 @@ const CustomTabs = styled.div`
const TabItem = styled.button<{ active: boolean }>`
flex: 1;
height: 32px;
height: 30px;
border: none;
background: transparent;
color: ${(props) => (props.active ? 'var(--color-text)' : 'var(--color-text-secondary)')};
@@ -239,7 +235,7 @@ const TabItem = styled.button<{ active: boolean }>`
&::after {
content: '';
position: absolute;
bottom: -9px;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: ${(props) => (props.active ? '30px' : '0')};

View File

@@ -1,4 +1,6 @@
import { SyncOutlined } from '@ant-design/icons'
import { useDisclosure } from '@heroui/react'
import UpdateDialog from '@renderer/components/UpdateDialog'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { Button } from 'antd'
@@ -10,6 +12,7 @@ const UpdateAppButton: FC = () => {
const { update } = useRuntime()
const { autoCheckUpdate } = useSettings()
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
if (!update) {
return null
@@ -23,13 +26,15 @@ const UpdateAppButton: FC = () => {
<Container>
<UpdateButton
className="nodrag"
onClick={() => window.api.showUpdateDialog()}
onClick={onOpen}
icon={<SyncOutlined />}
color="orange"
variant="outlined"
size="small">
{t('button.update_available')}
</UpdateButton>
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={update.info || null} />
</Container>
)
}

View File

@@ -14,6 +14,7 @@ import styled from 'styled-components'
// Tab 模式下新的页面壳,不再直接创建 WebView而是依赖全局 MinAppTabsPool
import MinimalToolbar from './components/MinimalToolbar'
import WebviewSearch from './components/WebviewSearch'
const logger = loggerService.withContext('MinAppPage')
@@ -184,6 +185,7 @@ const MinAppPage: FC = () => {
onOpenDevTools={handleOpenDevTools}
/>
</ToolbarWrapper>
<WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
{!isReady && (
<LoadingMask>
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />

View File

@@ -0,0 +1,377 @@
import { Button, Input } from '@heroui/react'
import { loggerService } from '@logger'
import type { WebviewTag } from 'electron'
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
type FoundInPageResult = Electron.FoundInPageResult
interface WebviewSearchProps {
webviewRef: React.RefObject<WebviewTag | null>
isWebviewReady: boolean
appId: string
}
const logger = loggerService.withContext('WebviewSearch')
const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, appId }) => {
const { t } = useTranslation()
const [isVisible, setIsVisible] = useState(false)
const [query, setQuery] = useState('')
const [matchCount, setMatchCount] = useState(0)
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const focusFrameRef = useRef<number | null>(null)
const lastAppIdRef = useRef<string>(appId)
const attachedWebviewRef = useRef<WebviewTag | null>(null)
const activeWebview = webviewRef.current ?? null
const focusInput = useCallback(() => {
if (focusFrameRef.current !== null) {
window.cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
focusFrameRef.current = window.requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
}, [])
const resetSearchState = useCallback((options?: { keepQuery?: boolean }) => {
if (!options?.keepQuery) {
setQuery('')
}
setMatchCount(0)
setActiveIndex(0)
}, [])
const ensureWebviewReady = useCallback(
(candidate: WebviewTag | null) => {
if (!candidate) return null
try {
const webContentsId = candidate.getWebContentsId?.()
if (!webContentsId) {
logger.debug('WebviewSearch: missing webContentsId before action', { appId })
return null
}
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed before action', { appId, error })
return null
}
return candidate
},
[appId]
)
const stopFindOnWebview = useCallback(
(webview: WebviewTag | null) => {
const usable = ensureWebviewReady(webview)
if (!usable) return false
try {
usable.stopFindInPage('clearSelection')
return true
} catch (error) {
logger.debug('stopFindInPage failed', { appId, error })
return false
}
},
[appId, ensureWebviewReady]
)
const getUsableWebview = useCallback(() => {
const candidates = [webviewRef.current, attachedWebviewRef.current]
for (const candidate of candidates) {
const usable = ensureWebviewReady(candidate)
if (usable) {
return usable
}
}
return null
}, [ensureWebviewReady, webviewRef])
const stopSearch = useCallback(() => {
const target = getUsableWebview()
if (!target) return
stopFindOnWebview(target)
}, [getUsableWebview, stopFindOnWebview])
const closeSearch = useCallback(() => {
setIsVisible(false)
stopSearch()
resetSearchState({ keepQuery: true })
}, [resetSearchState, stopSearch])
const performSearch = useCallback(
(text: string, options?: Electron.FindInPageOptions) => {
const target = getUsableWebview()
if (!target) {
logger.debug('Skip performSearch: webview not attached')
return
}
if (!text) {
stopSearch()
resetSearchState({ keepQuery: true })
return
}
try {
target.findInPage(text, options)
} catch (error) {
logger.error('findInPage failed', { error })
window.toast?.error(t('common.error'))
}
},
[getUsableWebview, resetSearchState, stopSearch, t]
)
const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
if (!event.result) return
const { activeMatchOrdinal, matches } = event.result
if (matches !== undefined) {
setMatchCount(matches)
}
if (activeMatchOrdinal !== undefined) {
setActiveIndex(activeMatchOrdinal)
}
}, [])
const openSearch = useCallback(() => {
if (!isWebviewReady) {
logger.debug('Skip openSearch: webview not ready')
return
}
setIsVisible(true)
focusInput()
}, [focusInput, isWebviewReady])
const goToNext = useCallback(() => {
if (!query) return
performSearch(query, { forward: true, findNext: true })
}, [performSearch, query])
const goToPrevious = useCallback(() => {
if (!query) return
performSearch(query, { forward: false, findNext: true })
}, [performSearch, query])
useEffect(() => {
attachedWebviewRef.current = activeWebview
if (!activeWebview) {
return
}
const handle = handleFoundInPage
activeWebview.addEventListener('found-in-page', handle)
return () => {
activeWebview.removeEventListener('found-in-page', handle)
if (attachedWebviewRef.current === activeWebview) {
stopFindOnWebview(activeWebview)
attachedWebviewRef.current = null
}
}
}, [activeWebview, handleFoundInPage, stopFindOnWebview])
useEffect(() => {
if (!activeWebview) return
if (!isWebviewReady) return
const onFindShortcut = window.api?.webview?.onFindShortcut
if (!onFindShortcut) return
let webContentsId: number | undefined
try {
webContentsId = activeWebview.getWebContentsId?.()
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed', { appId, error })
return
}
if (!webContentsId) {
logger.warn('WebviewSearch: missing webContentsId', { appId })
return
}
const unsubscribe = onFindShortcut(({ webviewId, key, control, meta, shift }) => {
if (webviewId !== webContentsId) return
if ((control || meta) && key === 'f') {
openSearch()
return
}
if (!isVisible) return
if (key === 'escape') {
closeSearch()
return
}
if (key === 'enter') {
if (shift) {
goToPrevious()
} else {
goToNext()
}
}
})
return () => {
unsubscribe?.()
}
}, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, isWebviewReady, openSearch])
useEffect(() => {
if (!isVisible) return
focusInput()
}, [focusInput, isVisible])
useEffect(() => {
if (!isVisible) return
if (!query) {
performSearch('')
return
}
performSearch(query)
}, [activeWebview, isVisible, performSearch, query])
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {
event.preventDefault()
openSearch()
return
}
if (!isVisible) return
if (event.key === 'Escape') {
event.preventDefault()
closeSearch()
return
}
if (event.key === 'Enter') {
event.preventDefault()
if (event.shiftKey) {
goToPrevious()
} else {
goToNext()
}
}
}
window.addEventListener('keydown', handleKeydown, true)
return () => {
window.removeEventListener('keydown', handleKeydown, true)
}
}, [closeSearch, goToNext, goToPrevious, isVisible, openSearch])
useEffect(() => {
if (!isWebviewReady) {
setIsVisible(false)
resetSearchState()
stopSearch()
return
}
}, [isWebviewReady, resetSearchState, stopSearch])
useEffect(() => {
if (!appId) return
if (lastAppIdRef.current === appId) return
lastAppIdRef.current = appId
setIsVisible(false)
resetSearchState()
stopSearch()
}, [appId, resetSearchState, stopSearch])
useEffect(() => {
return () => {
stopSearch()
if (focusFrameRef.current !== null) {
window.cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}
}, [stopSearch])
if (!isVisible) {
return null
}
const matchLabel = `${matchCount > 0 ? Math.max(activeIndex, 1) : 0}/${matchCount}`
const noResultTitle = matchCount === 0 && query ? t('common.no_results') : undefined
const disableNavigation = !query || matchCount === 0
return (
<div className="pointer-events-auto absolute top-3 right-3 z-50 flex items-center gap-2 rounded-xl border border-default-200 bg-background px-2 py-1 shadow-lg">
<Input
ref={inputRef}
autoFocus
value={query}
onValueChange={setQuery}
spellCheck={'false'}
placeholder={t('common.search')}
size="sm"
radius="sm"
variant="flat"
classNames={{
base: 'w-[240px]',
inputWrapper:
'h-8 bg-transparent border border-transparent shadow-none hover:border-transparent hover:bg-transparent focus:border-transparent data-[hover=true]:border-transparent data-[focus=true]:border-transparent data-[focus-visible=true]:outline-none data-[focus-visible=true]:ring-0',
input: 'text-small focus:outline-none focus-visible:outline-none',
innerWrapper: 'gap-0'
}}
/>
<span
className="min-w-[44px] text-center text-default-500 text-small tabular-nums"
title={noResultTitle}
role="status"
aria-live="polite"
aria-atomic="true">
{matchLabel}
</span>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToPrevious}
isDisabled={disableNavigation}
aria-label="Previous match"
className="text-default-500 hover:text-default-900">
<ChevronUp size={16} />
</Button>
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToNext}
isDisabled={disableNavigation}
aria-label="Next match"
className="text-default-500 hover:text-default-900">
<ChevronDown size={16} />
</Button>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={closeSearch}
aria-label={t('common.close')}
className="text-default-500 hover:text-default-900">
<X size={16} />
</Button>
</div>
)
}
export default WebviewSearch

View File

@@ -0,0 +1,396 @@
import type { WebviewKeyEvent } from '@shared/config/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { WebviewTag } from 'electron'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import WebviewSearch from '../WebviewSearch'
const translations: Record<string, string> = {
'common.close': 'Close',
'common.error': 'Error',
'common.no_results': 'No results',
'common.search': 'Search'
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => translations[key] ?? key
})
}))
const createWebviewMock = () => {
const listeners = new Map<string, Set<(event: Event & { result?: Electron.FoundInPageResult }) => void>>()
const findInPageMock = vi.fn()
const stopFindInPageMock = vi.fn()
const webview = {
addEventListener: vi.fn(
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
if (!listeners.has(type)) {
listeners.set(type, new Set())
}
listeners.get(type)!.add(listener)
}
),
removeEventListener: vi.fn(
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
listeners.get(type)?.delete(listener)
}
),
getWebContentsId: vi.fn(() => 1),
findInPage: findInPageMock as unknown as WebviewTag['findInPage'],
stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage']
} as unknown as WebviewTag
const emit = (type: string, result?: Electron.FoundInPageResult) => {
listeners.get(type)?.forEach((listener) => {
const event = new CustomEvent(type) as Event & { result?: Electron.FoundInPageResult }
event.result = result
listener(event)
})
}
return {
emit,
findInPageMock,
stopFindInPageMock,
webview
}
}
const openSearchOverlay = async () => {
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true }))
})
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
}
const originalRAF = window.requestAnimationFrame
const originalCAF = window.cancelAnimationFrame
const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
callback(0)
return 1
})
const cancelAnimationFrameMock = vi.fn()
beforeAll(() => {
Object.defineProperty(window, 'requestAnimationFrame', {
value: requestAnimationFrameMock,
writable: true
})
Object.defineProperty(window, 'cancelAnimationFrame', {
value: cancelAnimationFrameMock,
writable: true
})
})
afterAll(() => {
Object.defineProperty(window, 'requestAnimationFrame', {
value: originalRAF
})
Object.defineProperty(window, 'cancelAnimationFrame', {
value: originalCAF
})
})
describe('WebviewSearch', () => {
const toastMock = {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
addToast: vi.fn()
}
let removeFindShortcutListenerMock: ReturnType<typeof vi.fn>
let onFindShortcutMock: ReturnType<typeof vi.fn>
const invokeLatestShortcut = (payload: WebviewKeyEvent) => {
const handler = onFindShortcutMock.mock.calls.at(-1)?.[0] as ((args: WebviewKeyEvent) => void) | undefined
if (!handler) {
throw new Error('Shortcut handler not registered')
}
act(() => {
handler(payload)
})
}
beforeEach(() => {
removeFindShortcutListenerMock = vi.fn()
onFindShortcutMock = vi.fn(() => removeFindShortcutListenerMock)
Object.assign(window as any, {
api: {
webview: {
onFindShortcut: onFindShortcutMock
}
}
})
Object.assign(window, { toast: toastMock })
})
afterEach(() => {
vi.clearAllMocks()
Reflect.deleteProperty(window, 'api')
})
it('opens the search overlay with keyboard shortcut', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
await openSearchOverlay()
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
it('opens the search overlay when webview shortcut is forwarded', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
})
it('skips shortcut wiring when getWebContentsId throws', async () => {
const { webview } = createWebviewMock()
const error = new Error('not ready')
;(webview as any).getWebContentsId = vi.fn(() => {
throw error
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
expect(onFindShortcutMock).not.toHaveBeenCalled()
;(webview as any).getWebContentsId = vi.fn(() => 1)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
})
it('does not call stopFindInPage when webview is not ready', async () => {
const { stopFindInPageMock, webview } = createWebviewMock()
const error = new Error('loading')
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { rerender, unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
stopFindInPageMock.mockImplementation(() => {
throw new Error('should not be called')
})
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
expect(stopFindInPageMock).not.toHaveBeenCalled()
unmount()
expect(stopFindInPageMock).not.toHaveBeenCalled()
})
it('closes the search overlay when escape is forwarded from the webview', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
await waitFor(() => {
expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2)
})
invokeLatestShortcut({ webviewId: 1, key: 'escape', control: false, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
})
})
it('performs searches and navigates between results', async () => {
const { emit, findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
})
await act(async () => {
emit('found-in-page', {
requestId: 1,
matches: 3,
activeMatchOrdinal: 1,
selectionArea: undefined as unknown as Electron.Rectangle,
finalUpdate: false
} as Electron.FoundInPageResult)
})
const nextButton = screen.getByRole('button', { name: 'Next match' })
await waitFor(() => {
expect(nextButton).not.toBeDisabled()
})
await user.click(nextButton)
await waitFor(() => {
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: true, findNext: true })
})
const previousButton = screen.getByRole('button', { name: 'Previous match' })
await user.click(previousButton)
await waitFor(() => {
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: false, findNext: true })
})
})
it('navigates results when enter is forwarded from the webview', async () => {
const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
await waitFor(() => {
expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2)
})
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
})
findInPageMock.mockClear()
invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', { forward: true, findNext: true })
})
findInPageMock.mockClear()
invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: true, alt: false })
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', { forward: false, findNext: true })
})
})
it('clears search state when appId changes', async () => {
const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalled()
})
await act(async () => {
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-2" />)
})
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
})
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
})
it('shows toast error when search fails', async () => {
const { findInPageMock, webview } = createWebviewMock()
findInPageMock.mockImplementation(() => {
throw new Error('findInPage failed')
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(toastMock.error).toHaveBeenCalledWith('Error')
})
})
it('stops search when component unmounts', async () => {
const { stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
stopFindInPageMock.mockClear()
unmount()
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
expect(removeFindShortcutListenerMock).toHaveBeenCalled()
})
it('ignores keyboard shortcut when webview is not ready', async () => {
const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
await act(async () => {
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
})
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
expect(findInPageMock).not.toHaveBeenCalled()
})
})

View File

@@ -385,21 +385,25 @@ const NotesPage: FC = () => {
}, [activeFilePath])
// 获取目标文件夹路径(选中文件夹或根目录)
const getTargetFolderPath = useCallback(() => {
if (selectedFolderId) {
const selectedNode = findNode(notesTree, selectedFolderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
const getTargetFolderPath = useCallback(
(targetFolderId?: string) => {
const folderId = targetFolderId || selectedFolderId
if (folderId) {
const selectedNode = findNode(notesTree, folderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
}
}
}
return notesPath // 默认返回根目录
}, [selectedFolderId, notesTree, notesPath])
return notesPath // 默认返回根目录
},
[selectedFolderId, notesTree, notesPath]
)
// 创建文件夹
const handleCreateFolder = useCallback(
async (name: string) => {
async (name: string, targetFolderId?: string) => {
try {
const targetPath = getTargetFolderPath()
const targetPath = getTargetFolderPath(targetFolderId)
if (!targetPath) {
throw new Error('No folder path selected')
}
@@ -415,11 +419,11 @@ const NotesPage: FC = () => {
// 创建笔记
const handleCreateNote = useCallback(
async (name: string) => {
async (name: string, targetFolderId?: string) => {
try {
isCreatingNoteRef.current = true
const targetPath = getTargetFolderPath()
const targetPath = getTargetFolderPath(targetFolderId)
if (!targetPath) {
throw new Error('No folder path selected')
}

View File

@@ -34,8 +34,8 @@ import { useSelector } from 'react-redux'
import styled from 'styled-components'
interface NotesSidebarProps {
onCreateFolder: (name: string, parentId?: string) => void
onCreateNote: (name: string, parentId?: string) => void
onCreateFolder: (name: string, targetFolderId?: string) => void
onCreateNote: (name: string, targetFolderId?: string) => void
onSelectNode: (node: NotesTreeNode) => void
onDeleteNode: (nodeId: string) => void
onRenameNode: (nodeId: string, newName: string) => void
@@ -71,6 +71,8 @@ interface TreeNodeProps {
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
onDragEnd: () => void
renderChildren?: boolean // 控制是否渲染子节点
openDropdownKey: string | null
onDropdownOpenChange: (key: string | null) => void
}
const TreeNode = memo<TreeNodeProps>(
@@ -94,7 +96,9 @@ const TreeNode = memo<TreeNodeProps>(
onDragLeave,
onDrop,
onDragEnd,
renderChildren = true
renderChildren = true,
openDropdownKey,
onDropdownOpenChange
}) => {
const { t } = useTranslation()
@@ -119,8 +123,12 @@ const TreeNode = memo<TreeNodeProps>(
return (
<div key={node.id}>
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
<div>
<Dropdown
menu={{ items: getMenuItems(node) }}
trigger={['contextMenu']}
open={openDropdownKey === node.id}
onOpenChange={(open) => onDropdownOpenChange(open ? node.id : null)}>
<div onContextMenu={(e) => e.stopPropagation()}>
<TreeNodeContainer
active={isActive}
depth={depth}
@@ -206,6 +214,8 @@ const TreeNode = memo<TreeNodeProps>(
onDrop={onDrop}
onDragEnd={onDragEnd}
renderChildren={renderChildren}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={onDropdownOpenChange}
/>
))}
</div>
@@ -244,6 +254,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const [isShowSearch, setIsShowSearch] = useState(false)
const [searchKeyword, setSearchKeyword] = useState('')
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null)
const dragNodeRef = useRef<HTMLDivElement | null>(null)
const scrollbarRef = useRef<any>(null)
@@ -588,6 +599,28 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
})
}
if (node.type === 'folder') {
baseMenuItems.push(
{
label: t('notes.new_note'),
key: 'new_note',
icon: <FilePlus size={14} />,
onClick: () => {
onCreateNote(t('notes.untitled_note'), node.id)
}
},
{
label: t('notes.new_folder'),
key: 'new_folder',
icon: <Folder size={14} />,
onClick: () => {
onCreateFolder(t('notes.untitled_folder'), node.id)
}
},
{ type: 'divider' }
)
}
baseMenuItems.push(
{
label: t('notes.rename'),
@@ -702,7 +735,9 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
handleDeleteNode,
renamingNodeIds,
handleAutoRename,
exportMenuOptions
exportMenuOptions,
onCreateNote,
onCreateFolder
]
)
@@ -783,6 +818,23 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
fileInput.click()
}, [onUploadFiles])
const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => {
return [
{
label: t('notes.new_note'),
key: 'new_note',
icon: <FilePlus size={14} />,
onClick: handleCreateNote
},
{
label: t('notes.new_folder'),
key: 'new_folder',
icon: <Folder size={14} />,
onClick: handleCreateFolder
}
]
}, [t, handleCreateNote, handleCreateFolder])
return (
<SidebarContainer
onDragOver={(e) => {
@@ -812,31 +864,90 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
<NotesTreeContainer>
{shouldUseVirtualization ? (
<VirtualizedTreeContainer ref={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const { node, depth } = flattenedNodes[virtualItem.index]
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`
}}>
<div style={{ padding: '0 8px' }}>
<Dropdown
menu={{ items: getEmptyAreaMenuItems() }}
trigger={['contextMenu']}
open={openDropdownKey === 'empty-area'}
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
<VirtualizedTreeContainer ref={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const { node, depth } = flattenedNodes[virtualItem.index]
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`
}}>
<div style={{ padding: '0 8px' }}>
<TreeNode
node={node}
depth={depth}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
renderChildren={false}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/>
</div>
</div>
)
})}
</div>
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</VirtualizedTreeContainer>
</Dropdown>
) : (
<Dropdown
menu={{ items: getEmptyAreaMenuItems() }}
trigger={['contextMenu']}
open={openDropdownKey === 'empty-area'}
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
<StyledScrollbar ref={scrollbarRef}>
<TreeContent>
{isShowStarred || isShowSearch
? filteredTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={depth}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
@@ -854,92 +965,51 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
renderChildren={false}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/>
</div>
</div>
)
})}
</div>
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</VirtualizedTreeContainer>
) : (
<StyledScrollbar ref={scrollbarRef}>
<TreeContent>
{isShowStarred || isShowSearch
? filteredTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))
: notesTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</TreeContent>
</StyledScrollbar>
))
: notesTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/>
))}
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</TreeContent>
</StyledScrollbar>
</Dropdown>
)}
</NotesTreeContainer>

View File

@@ -12,6 +12,7 @@ import { HStack, VStack } from '@renderer/components/Layout'
import Scrollbar from '@renderer/components/Scrollbar'
import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { getProviderLogo } from '@renderer/config/providers'
import { LanguagesEnum } from '@renderer/config/translate'
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings'
@@ -26,7 +27,7 @@ import { useAppDispatch } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import type { FileMetadata, Painting } from '@renderer/types'
import { getErrorMessage, uuid } from '@renderer/utils'
import { Button, Input, InputNumber, Radio, Select, Slider, Switch, Tooltip } from 'antd'
import { Avatar, Button, Input, InputNumber, Radio, Select, Slider, Switch, Tooltip } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { Info } from 'lucide-react'
import type { FC } from 'react'
@@ -388,7 +389,16 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container">
<LeftContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<Select value={providerOptions[2].value} onChange={handleProviderChange} options={providerOptions} />
<Select value={providerOptions[2].value} onChange={handleProviderChange}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
<Select value={painting.model} options={modelOptions} onChange={onSelectModel} />
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>
@@ -662,4 +672,14 @@ const StyledInputNumber = styled(InputNumber)`
width: 70px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const ProviderLogo = styled(Avatar)`
flex-shrink: 0;
`
export default SiliconPage

View File

@@ -1,18 +1,21 @@
import { GithubOutlined } from '@ant-design/icons'
import { useDisclosure } from '@heroui/react'
import IndicatorLight from '@renderer/components/IndicatorLight'
import { HStack } from '@renderer/components/Layout'
import UpdateDialog from '@renderer/components/UpdateDialog'
import { APP_NAME, AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { handleSaveData, useAppDispatch } from '@renderer/store'
import { useAppDispatch } from '@renderer/store'
import { setUpdateState } from '@renderer/store/runtime'
import { ThemeMode } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { UpgradeChannel } from '@shared/config/constant'
import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd'
import { UpdateInfo } from 'builder-util-runtime'
import { debounce } from 'lodash'
import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react'
import { BadgeQuestionMark } from 'lucide-react'
@@ -27,6 +30,8 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitl
const AboutSettings: FC = () => {
const [version, setVersion] = useState('')
const [isPortable, setIsPortable] = useState(false)
const [updateDialogInfo, setUpdateDialogInfo] = useState<UpdateInfo | null>(null)
const { isOpen, onOpen, onClose } = useDisclosure()
const { t } = useTranslation()
const { autoCheckUpdate, setAutoCheckUpdate, testPlan, setTestPlan, testChannel, setTestChannel } = useSettings()
const { theme } = useTheme()
@@ -41,8 +46,9 @@ const AboutSettings: FC = () => {
}
if (update.downloaded) {
await handleSaveData()
window.api.showUpdateDialog()
// Open update dialog directly in renderer
setUpdateDialogInfo(update.info || null)
onOpen()
return
}
@@ -341,6 +347,9 @@ const AboutSettings: FC = () => {
<Button onClick={debug}>{t('settings.about.debug.open')}</Button>
</SettingRow>
</SettingGroup>
{/* Update Dialog */}
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={updateDialogInfo} />
</SettingContainer>
)
}

View File

@@ -71,12 +71,12 @@ export const AccessibleDirsSetting: React.FC<AccessibleDirsSettingProps> = ({ ba
}>
{t('agent.session.accessible_paths.label')}
</SettingsTitle>
<ul className="mt-2 flex flex-col gap-2 rounded-xl border p-2">
<ul className="flex flex-col gap-2">
{base.accessible_paths.map((path) => (
<li
key={path}
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-3 py-2">
<span className="truncate text-sm" title={path}>
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-2 py-1">
<span className="w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm" title={path}>
{path}
</span>
<Button size="sm" variant="light" color="danger" onPress={() => removeAccessiblePath(path)}>

View File

@@ -18,9 +18,10 @@ import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
interface AgentEssentialSettingsProps {
agent: GetAgentResponse | undefined | null
update: ReturnType<typeof useUpdateAgent>['updateAgent']
showModelSetting?: boolean
}
const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update }) => {
const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update, showModelSetting = true }) => {
const { t } = useTranslation()
if (!agent) return null
@@ -36,7 +37,7 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
</SettingsItem>
<AvatarSetting agent={agent} update={update} />
<NameSetting base={agent} update={update} />
<ModelSetting base={agent} update={update} />
{showModelSetting && <ModelSetting base={agent} update={update} />}
<AccessibleDirsSetting base={agent} update={update} />
<DescriptionSetting base={agent} update={update} />
</SettingsContainer>

View File

@@ -25,6 +25,7 @@ export const NameSetting: React.FC<NameSettingsProps> = ({ base, update }) => {
<Input
placeholder={t('common.agent_one') + t('common.name')}
value={name}
size="sm"
onValueChange={(value) => setName(value)}
onBlur={() => {
if (name !== base.name) {

View File

@@ -172,15 +172,22 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
const onUpdateApiVersion = () => updateProvider({ apiVersion })
const openApiKeyList = async () => {
if (localApiKey !== provider.apiKey) {
updateProvider({ apiKey: formatApiKeys(localApiKey) })
await new Promise((resolve) => setTimeout(resolve, 0))
}
await ApiKeyListPopup.show({
providerId: provider.id,
title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}`
title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}`,
providerType: 'llm'
})
}
const onCheckApi = async () => {
const formattedLocalKey = formatApiKeys(localApiKey)
// 如果存在多个密钥,直接打开管理窗口
if (provider.apiKey.includes(',')) {
if (formattedLocalKey.includes(',')) {
await openApiKeyList()
return
}
@@ -204,7 +211,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
try {
setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: HealthStatus.NOT_CHECKED }))
await checkApi({ ...provider, apiHost }, model)
await checkApi({ ...provider, apiHost, apiKey: formattedLocalKey }, model)
window.toast.success({
timeout: 2000,

View File

@@ -15,14 +15,13 @@ import {
WebSearchProviderResult,
WebSearchStatus
} from '@renderer/types'
import { hasObjectKey, uuid } from '@renderer/utils'
import { hasObjectKey, removeSpecialCharactersForFileName, uuid } from '@renderer/utils'
import { addAbortController } from '@renderer/utils/abortController'
import { formatErrorMessage } from '@renderer/utils/error'
import { ExtractResults } from '@renderer/utils/extract'
import { fetchWebContents } from '@renderer/utils/fetch'
import { consolidateReferencesByUrl, selectReferences } from '@renderer/utils/websearch'
import dayjs from 'dayjs'
import { LRUCache } from 'lru-cache'
import { sliceByTokens } from 'tokenx'
import { getKnowledgeBaseParams } from './KnowledgeService'
@@ -32,7 +31,6 @@ const logger = loggerService.withContext('WebSearchService')
interface RequestState {
signal: AbortSignal | null
searchBase?: KnowledgeBase
isPaused: boolean
createdAt: number
}
@@ -49,16 +47,7 @@ class WebSearchService {
isPaused = false
// 管理不同请求的状态
private requestStates = new LRUCache<string, RequestState>({
max: 5, // 最多5个并发请求
ttl: 1000 * 60 * 2, // 2分钟过期
dispose: (requestState: RequestState, requestId: string) => {
if (!requestState.searchBase) return
window.api.knowledgeBase
.delete(getKnowledgeBaseParams(requestState.searchBase), requestState.searchBase.id)
.catch((error) => logger.warn(`Failed to cleanup search base for ${requestId}:`, error))
}
})
private requestStates = new Map<string, RequestState>()
/**
* 获取或创建单个请求的状态
@@ -209,32 +198,22 @@ class WebSearchService {
}
/**
* 确保搜索压缩知识库存在并配置正确
* 创建临时搜索知识库
*/
private async ensureSearchBase(
config: CompressionConfig,
documentCount: number,
requestId: string
): Promise<KnowledgeBase> {
// requestId: eg: openai-responses-openai/gpt-5-timestamp-uuid
const baseId = `websearch-compression-${requestId}`
const state = this.getRequestState(requestId)
// 如果已存在且配置未变,直接复用
if (state.searchBase && this.isConfigMatched(state.searchBase, config)) {
return state.searchBase
}
// 清理旧的知识库
if (state.searchBase) {
await window.api.knowledgeBase.delete(getKnowledgeBaseParams(state.searchBase), state.searchBase.id)
}
if (!config.embeddingModel) {
throw new Error('Embedding model is required for RAG compression')
}
// 创建新的知识库
state.searchBase = {
const searchBase: KnowledgeBase = {
id: baseId,
name: `WebSearch-RAG-${requestId}`,
model: config.embeddingModel,
@@ -247,25 +226,23 @@ class WebSearchService {
version: 1
}
// 更新LRU cache
this.requestStates.set(requestId, state)
// 创建知识库
const baseParams = getKnowledgeBaseParams(state.searchBase)
const baseParams = getKnowledgeBaseParams(searchBase)
await window.api.knowledgeBase.create(baseParams)
return state.searchBase
return searchBase
}
/**
* 检查配置是否匹配
* 清理临时搜索知识库
*/
private isConfigMatched(base: KnowledgeBase, config: CompressionConfig): boolean {
return (
base.model.id === config.embeddingModel?.id &&
base.rerankModel?.id === config.rerankModel?.id &&
base.dimensions === config.embeddingDimensions
)
private async cleanupSearchBase(searchBase: KnowledgeBase): Promise<void> {
try {
await window.api.knowledgeBase.delete(removeSpecialCharactersForFileName(searchBase.id))
logger.debug(`Cleaned up search base: ${searchBase.id}`)
} catch (error) {
logger.warn(`Failed to cleanup search base ${searchBase.id}:`, error as Error)
}
}
/**
@@ -332,45 +309,50 @@ class WebSearchService {
const searchBase = await this.ensureSearchBase(config, totalDocumentCount, requestId)
logger.debug('Search base for RAG compression: ', searchBase)
// 1. 清空知识库
const baseParams = getKnowledgeBaseParams(searchBase)
await window.api.knowledgeBase.reset(baseParams)
try {
// 1. 清空知识库
const baseParams = getKnowledgeBaseParams(searchBase)
await window.api.knowledgeBase.reset(baseParams)
logger.debug('Search base parameters for RAG compression: ', baseParams)
logger.debug('Search base parameters for RAG compression: ', baseParams)
// 2. 顺序添加所有搜索结果到知识库
// FIXME: 目前的知识库 add 不支持并发
for (const result of rawResults) {
const item: KnowledgeItem & { sourceUrl?: string } = {
id: uuid(),
type: 'note',
content: result.content,
sourceUrl: result.url, // 设置 sourceUrl 用于映射
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending'
// 2. 顺序添加所有搜索结果到知识库
// FIXME: 目前的知识库 add 不支持并发
for (const result of rawResults) {
const item: KnowledgeItem & { sourceUrl?: string } = {
id: uuid(),
type: 'note',
content: result.content,
sourceUrl: result.url, // 设置 sourceUrl 用于映射
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending'
}
await window.api.knowledgeBase.add({
base: getKnowledgeBaseParams(searchBase),
item
})
}
await window.api.knowledgeBase.add({
base: getKnowledgeBaseParams(searchBase),
item
// 3. 对知识库执行多问题搜索获取压缩结果
const references = await this.querySearchBase(questions, searchBase)
// 4. 使用 Round Robin 策略选择引用
const selectedReferences = selectReferences(rawResults, references, totalDocumentCount)
logger.verbose('With RAG, the number of search results:', {
raw: rawResults.length,
retrieved: references.length,
selected: selectedReferences.length
})
// 5. 按 sourceUrl 分组并合并同源片段
return consolidateReferencesByUrl(rawResults, selectedReferences)
} finally {
// 无论成功或失败都立即清理知识库
await this.cleanupSearchBase(searchBase)
}
// 3. 对知识库执行多问题搜索获取压缩结果
const references = await this.querySearchBase(questions, searchBase)
// 4. 使用 Round Robin 策略选择引用
const selectedReferences = selectReferences(rawResults, references, totalDocumentCount)
logger.verbose('With RAG, the number of search results:', {
raw: rawResults.length,
retrieved: references.length,
selected: selectedReferences.length
})
// 5. 按 sourceUrl 分组并合并同源片段
return consolidateReferencesByUrl(rawResults, selectedReferences)
}
/**
@@ -462,7 +444,9 @@ class WebSearchService {
// 处理 summarize
if (questions[0] === 'summarize' && links && links.length > 0) {
const contents = await fetchWebContents(links, undefined, undefined, { signal })
const contents = await fetchWebContents(links, undefined, undefined, {
signal
})
webSearchProvider.topicId &&
endSpan({
topicId: webSearchProvider.topicId,

View File

@@ -121,7 +121,8 @@ export class BlockManager {
newMessagesActions.upsertBlockReference({
messageId: this.deps.assistantMsgId,
blockId: newBlock.id,
status: newBlock.status
status: newBlock.status,
blockType: newBlock.type
})
)

View File

@@ -13,6 +13,7 @@ export interface AssistantsState {
tagsOrder: string[]
collapsedTags: Record<string, boolean>
presets: AssistantPreset[]
unifiedListOrder: Array<{ type: 'agent' | 'assistant'; id: string }>
}
const initialState: AssistantsState = {
@@ -20,7 +21,8 @@ const initialState: AssistantsState = {
assistants: [getDefaultAssistant()],
tagsOrder: [],
collapsedTags: {},
presets: []
presets: [],
unifiedListOrder: []
}
const assistantsSlice = createSlice({
@@ -96,6 +98,9 @@ const assistantsSlice = createSlice({
[tag]: !prev[tag]
}
},
setUnifiedListOrder: (state, action: PayloadAction<Array<{ type: 'agent' | 'assistant'; id: string }>>) => {
state.unifiedListOrder = action.payload
},
addTopic: (state, action: PayloadAction<{ assistantId: string; topic: Topic }>) => {
const topic = action.payload.topic
topic.createdAt = topic.createdAt || new Date().toISOString()
@@ -244,6 +249,7 @@ export const {
setTagsOrder,
updateAssistantSettings,
updateTagCollapse,
setUnifiedListOrder,
setAssistantPresets,
addAssistantPreset,
removeAssistantPreset,

View File

@@ -65,7 +65,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 160,
version: 162,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@@ -1,14 +1,7 @@
import { loggerService } from '@logger'
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import FileManager from '@renderer/services/FileManager'
import {
FileMetadata,
KnowledgeBase,
KnowledgeBaseParams,
KnowledgeItem,
PreprocessProvider,
ProcessingStatus
} from '@renderer/types'
import { FileMetadata, KnowledgeBase, KnowledgeItem, PreprocessProvider, ProcessingStatus } from '@renderer/types'
const logger = loggerService.withContext('Store:Knowledge')
@@ -28,13 +21,13 @@ const knowledgeSlice = createSlice({
state.bases.push(action.payload)
},
deleteBase(state, action: PayloadAction<{ baseId: string; baseParams: KnowledgeBaseParams }>) {
deleteBase(state, action: PayloadAction<{ baseId: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
state.bases = state.bases.filter((b) => b.id !== action.payload.baseId)
const files = base.items.filter((item) => item.type === 'file')
FileManager.deleteFiles(files.map((item) => item.content) as FileMetadata[])
window.api.knowledgeBase.delete(action.payload.baseParams, action.payload.baseId)
window.api.knowledgeBase.delete(action.payload.baseId)
}
},

View File

@@ -144,6 +144,13 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
type: 'inMemory',
isActive: false,
provider: 'CherryAI'
},
{
id: nanoid(),
name: BuiltinMCPServerNames.js,
type: 'inMemory',
isActive: false,
provider: 'CherryAI'
}
] as const

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