diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml
index 054dea40e6..29cfd1fda0 100644
--- a/.github/workflows/auto-i18n.yml
+++ b/.github/workflows/auto-i18n.yml
@@ -1,7 +1,7 @@
name: Auto I18N
env:
- API_KEY: ${{ secrets.TRANSLATE_API_KEY}}
+ API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}}
BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}}
@@ -35,7 +35,7 @@ jobs:
# 在临时目录安装依赖
mkdir -p /tmp/translation-deps
cd /tmp/translation-deps
- echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "prettier": "^3.5.3", "prettier-plugin-sort-json": "^4.1.1"}}' > package.json
+ echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "prettier": "^3.5.3", "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-tailwindcss": "^0.6.14"}}' > package.json
npm install --no-package-lock
# 设置 NODE_PATH 让项目能找到这些依赖
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
index 9553ef6a91..5ed414b1fe 100644
--- a/.github/workflows/claude-code-review.yml
+++ b/.github/workflows/claude-code-review.yml
@@ -2,7 +2,7 @@ name: Claude Code Review
on:
pull_request:
- types: [opened, synchronize]
+ types: [opened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
@@ -12,12 +12,11 @@ on:
jobs:
claude-review:
- # Optional: Filter by PR author
- # if: |
- # github.event.pull_request.user.login == 'external-contributor' ||
- # github.event.pull_request.user.login == 'new-developer' ||
- # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
-
+ # Only trigger code review for PRs from the main repository due to upstream OIDC issues
+ # https://github.com/anthropics/claude-code-action/issues/542
+ if: |
+ (github.event.pull_request.head.repo.full_name == github.repository) &&
+ (github.event.pull_request.draft == false)
runs-on: ubuntu-latest
permissions:
contents: read
@@ -45,6 +44,9 @@ jobs:
- Security concerns
- Test coverage
+ PR number: ${{ github.event.number }}
+ Repo: ${{ github.repository }}
+
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
diff --git a/.github/workflows/claude-translator.yml b/.github/workflows/claude-translator.yml
index d5f7ea40d8..ab2b6f7e4f 100644
--- a/.github/workflows/claude-translator.yml
+++ b/.github/workflows/claude-translator.yml
@@ -1,6 +1,6 @@
-name: English Translator
+name: Claude Translator
concurrency:
- group: translator-${{ github.event.issue.number }}
+ group: translator-${{ github.event.comment.id || github.event.issue.number }}
cancel-in-progress: false
on:
@@ -12,13 +12,15 @@ on:
jobs:
translate:
if: |
- (github.event_name == 'issues' && github.event.issue.author_association == 'COLLABORATOR' && !contains(github.event.issue.body, 'This issue/comment was translated by Claude.')) ||
- (github.event_name == 'issue_comment' && github.event.comment.author_association == 'COLLABORATOR' && !contains(github.event.issue.body, 'This issue/comment was translated by Claude.'))
+ (github.event_name == 'issues') ||
+ (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') &&
+ ((github.event_name == 'issue_comment' && github.event.action == 'created' && !contains(github.event.comment.body, 'This issue was translated by Claude')) ||
+ (github.event_name == 'issue_comment' && github.event.action == 'edited'))
runs-on: ubuntu-latest
permissions:
contents: read
issues: write # 编辑issues/comments
- pull-requests: read
+ pull-requests: write
id-token: write
steps:
@@ -28,11 +30,16 @@ jobs:
fetch-depth: 1
- name: Run Claude for translation
- uses: anthropics/claude-code-action@v1
+ uses: anthropics/claude-code-action@main
id: claude
with:
+ # Warning: Permissions should have been controlled by workflow permission.
+ # Now `contents: read` is safe for files, but we could make a fine-grained token to control it.
+ # 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 }}
- claude_args: '--allowed-tools mcp__github_comment__update_claude_comment,Bash(gh issue:*),Bash(gh api:repos/*/issues:*)'
+ claude_args: '--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*)'
prompt: |
你是一个多语言翻译助手。请完成以下任务:
@@ -50,7 +57,7 @@ jobs:
---
- **Original Content:**
+ Original Content
[原始内容]
diff --git a/.prettierrc b/.prettierrc
index 85e2eb0ca6..7a06761104 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -3,9 +3,11 @@
"endOfLine": "lf",
"jsonRecursiveSort": true,
"jsonSortOrder": "{\"*\": \"lexical\"}",
- "plugins": ["prettier-plugin-sort-json"],
+ "plugins": ["prettier-plugin-sort-json", "prettier-plugin-tailwindcss"],
"printWidth": 120,
"semi": false,
"singleQuote": true,
+ "tailwindFunctions": ["clsx"],
+ "tailwindStylesheet": "./src/renderer/src/assets/styles/tailwind.css",
"trailingComma": "none"
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 997c26aedf..3dd634507f 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -28,6 +28,9 @@
"source.organizeImports": "never"
},
"editor.formatOnSave": true,
+ "files.associations": {
+ "*.css": "tailwindcss"
+ },
"files.eol": "\n",
"i18n-ally.displayLanguage": "zh-cn",
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],
diff --git a/CLAUDE.md b/CLAUDE.md
index 21b8b2080a..37e4dd6146 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -92,6 +92,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Multi-language Support**: i18n with dynamic loading
- **Theme System**: Light/dark themes with custom CSS variables
+### UI Design
+
+The project is in the process of migrating from antd & styled-components to HeroUI. Please use HeroUI to build UI components. The use of antd and styled-components is prohibited.
+
+HeroUI Docs: https://www.heroui.com/docs/guide/introduction
+
### Database Architecture
- **Database**: SQLite (`cherrystudio.sqlite`) + libsql driver
diff --git a/README.md b/README.md
index 47f29b0daf..90d1d9fb8b 100644
--- a/README.md
+++ b/README.md
@@ -82,7 +82,7 @@ Cherry Studio is a desktop client that supports multiple LLM providers, availabl
1. **Diverse LLM Provider Support**:
- ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more
-- 🔗 AI Web Service Integration: Claude, Peplexity, Poe, and others
+- 🔗 AI Web Service Integration: Claude, Perplexity, Poe, and others
- 💻 Local Model Support with Ollama, LM Studio
2. **AI Assistants & Conversations**:
diff --git a/components.json b/components.json
new file mode 100644
index 0000000000..c5aceeb3ce
--- /dev/null
+++ b/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "aliases": {
+ "components": "@renderer/ui/third-party",
+ "hooks": "@renderer/hooks",
+ "lib": "@renderer/lib",
+ "ui": "@renderer/ui",
+ "utils": "@renderer/utils"
+ },
+ "iconLibrary": "lucide",
+ "rsc": false,
+ "style": "new-york",
+ "tailwind": {
+ "baseColor": "zinc",
+ "config": "",
+ "css": "src/renderer/src/assets/styles/tailwind.css",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "tsx": true
+}
diff --git a/docs/README.zh.md b/docs/README.zh.md
index 774db66627..84546c57ee 100644
--- a/docs/README.zh.md
+++ b/docs/README.zh.md
@@ -13,7 +13,7 @@
Français
Deutsch
Español
- Itapano
+ Italiano
Русский
Português
Nederlands
@@ -89,7 +89,7 @@ https://docs.cherry-ai.com
1. **多样化 LLM 服务支持**:
- ☁️ 支持主流 LLM 云服务:OpenAI、Gemini、Anthropic、硅基流动等
-- 🔗 集成流行 AI Web 服务:Claude、Peplexity、Poe、腾讯元宝、知乎直答等
+- 🔗 集成流行 AI Web 服务:Claude、Perplexity、Poe、腾讯元宝、知乎直答等
- 💻 支持 Ollama、LM Studio 本地模型部署
2. **智能助手与对话**:
diff --git a/electron-builder.yml b/electron-builder.yml
index 272e8eba50..47f3fa3883 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -113,6 +113,10 @@ linux:
StartupWMClass: CherryStudio
mimeTypes:
- x-scheme-handler/cherrystudio
+rpm:
+ # Workaround for electron build issue on rpm package:
+ # https://github.com/electron/forge/issues/3594
+ fpm: ['--rpm-rpmbuild-define=_build_id_links none']
publish:
provider: generic
url: https://releases.cherry-ai.com
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 531cb89843..026303f0fc 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -74,6 +74,7 @@ export default defineConfig({
},
renderer: {
plugins: [
+ (async () => (await import('@tailwindcss/vite')).default())(),
react({
tsDecorators: true,
plugins: [
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 133025b1fd..f3a96ca225 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -123,7 +123,10 @@ export default defineConfig([
'.gitignore',
'scripts/cloudflare-worker.js',
'src/main/integration/nutstore/sso/lib/**',
- 'src/main/integration/cherryin/index.js'
+ 'src/main/integration/cherryin/index.js',
+ 'src/main/integration/nutstore/sso/lib/**',
+ 'src/renderer/src/ui/**',
+ 'packages/**/dist'
]
}
])
diff --git a/package.json b/package.json
index be23f06594..e560d8e855 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
"format:check": "prettier --check .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix && yarn typecheck && yarn check:i18n",
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
+ "claude": "dotenv -e .env -- claude",
"migrations:generate": "drizzle-kit generate --config ./migrations/sqlite-drizzle.config.ts"
},
"dependencies": {
@@ -76,14 +77,18 @@
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@strongtz/win32-arm64-msvc": "^0.4.7",
+ "express": "^5.1.0",
"faiss-node": "^0.5.1",
+ "font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"jsdom": "26.1.0",
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2",
- "selection-hook": "^1.0.11",
+ "selection-hook": "^1.0.12",
"sharp": "^0.34.3",
+ "swagger-jsdoc": "^6.2.8",
+ "swagger-ui-express": "^5.0.1",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"turndown": "7.2.0"
},
@@ -92,8 +97,9 @@
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.0",
- "@ai-sdk/google-vertex": "^3.0.0",
+ "@ai-sdk/google-vertex": "^3.0.25",
"@ai-sdk/mistral": "^2.0.0",
+ "@ai-sdk/perplexity": "^2.0.8",
"@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",
@@ -129,13 +135,14 @@
"@eslint/js": "^9.22.0",
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
"@hello-pangea/dnd": "^18.0.1",
+ "@heroui/react": "^2.8.3",
"@kangfenmao/keyv-storage": "^0.1.0",
"@langchain/community": "^0.3.50",
"@langchain/core": "^0.3.68",
"@langchain/ollama": "^0.2.1",
"@langchain/openai": "^0.6.7",
"@mistralai/mistralai": "^1.7.5",
- "@modelcontextprotocol/sdk": "^1.17.0",
+ "@modelcontextprotocol/sdk": "^1.17.5",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@openrouter/ai-sdk-provider": "^1.1.2",
@@ -149,6 +156,7 @@
"@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.12.0",
"@swc/plugin-styled-components": "^8.0.4",
+ "@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-virtual": "^3.13.12",
"@testing-library/dom": "^10.4.0",
@@ -174,6 +182,10 @@
"@truto/turndown-plugin-gfm": "^1.0.2",
"@tryfabric/martian": "^1.2.4",
"@types/cli-progress": "^3",
+ "@types/content-type": "^1.1.9",
+ "@types/cors": "^2.8.19",
+ "@types/diff": "^7",
+ "@types/express": "^5",
"@types/fs-extra": "^11",
"@types/he": "^1",
"@types/html-to-text": "^9",
@@ -187,6 +199,9 @@
"@types/react-dom": "^19.0.4",
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-transition-group": "^4.4.12",
+ "@types/react-window": "^1",
+ "@types/swagger-jsdoc": "^6",
+ "@types/swagger-ui-express": "^4.1.8",
"@types/tinycolor2": "^1",
"@types/turndown": "^5.0.5",
"@types/word-extractor": "^1",
@@ -201,16 +216,18 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
- "ai": "^5.0.29",
+ "ai": "^5.0.38",
"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",
"axios": "^1.7.3",
"browser-image-compression": "^2.0.2",
"chardet": "^2.1.0",
+ "check-disk-space": "3.4.0",
"cheerio": "^1.1.2",
"chokidar": "^4.0.3",
"cli-progress": "^3.12.0",
+ "clsx": "^2.1.1",
"code-inspector-plugin": "^0.20.14",
"color": "^5.0.0",
"concurrently": "^9.2.1",
@@ -241,6 +258,7 @@
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0",
"fetch-socks": "1.3.2",
+ "framer-motion": "^12.23.12",
"franc-min": "^6.2.0",
"fs-extra": "^11.2.0",
"google-auth-library": "^9.15.1",
@@ -275,6 +293,7 @@
"playwright": "^1.52.0",
"prettier": "^3.5.3",
"prettier-plugin-sort-json": "^4.1.1",
+ "prettier-plugin-tailwindcss": "^0.6.14",
"proxy-agent": "^6.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -304,18 +323,19 @@
"remark-math": "^6.0.0",
"remove-markdown": "^0.6.2",
"rollup-plugin-visualizer": "^5.12.0",
- "sass": "^1.88.0",
"shiki": "^3.12.0",
"strict-url-sanitise": "^0.0.1",
"string-width": "^7.2.0",
"striptags": "^3.2.0",
"styled-components": "^6.1.11",
"swr": "^2.3.6",
+ "tailwindcss": "^4.1.13",
"tar": "^7.4.3",
"tiny-pinyin": "^1.3.2",
"tokenx": "^1.1.0",
"tsx": "^4.20.3",
"turndown-plugin-gfm": "^1.0.2",
+ "tw-animate-css": "^1.3.8",
"typescript": "^5.6.2",
"undici": "6.21.2",
"unified": "^11.0.5",
@@ -331,7 +351,7 @@
"yjs": "^13.6.27",
"youtubei.js": "^15.0.1",
"zipread": "^1.3.3",
- "zod": "^3.25.74"
+ "zod": "^4.1.5"
},
"resolutions": {
"@codemirror/language": "6.11.3",
@@ -360,7 +380,7 @@
"prettier --write",
"eslint --fix"
],
- "*.{json,yml,yaml,css,scss,html}": [
+ "*.{json,yml,yaml,css,html}": [
"prettier --write"
]
}
diff --git a/packages/aiCore/examples/hub-provider-usage.ts b/packages/aiCore/examples/hub-provider-usage.ts
deleted file mode 100644
index 559e812bdb..0000000000
--- a/packages/aiCore/examples/hub-provider-usage.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * Hub Provider 使用示例
- *
- * 演示如何使用简化后的Hub Provider功能来路由到多个底层provider
- */
-
-import { createHubProvider, initializeProvider, providerRegistry } from '../src/index'
-
-async function demonstrateHubProvider() {
- try {
- // 1. 初始化底层providers
- console.log('📦 初始化底层providers...')
-
- initializeProvider('openai', {
- apiKey: process.env.OPENAI_API_KEY || 'sk-test-key'
- })
-
- initializeProvider('anthropic', {
- apiKey: process.env.ANTHROPIC_API_KEY || 'sk-ant-test-key'
- })
-
- // 2. 创建Hub Provider(自动包含所有已初始化的providers)
- console.log('🌐 创建Hub Provider...')
-
- const aihubmixProvider = createHubProvider({
- hubId: 'aihubmix',
- debug: true
- })
-
- // 3. 注册Hub Provider
- providerRegistry.registerProvider('aihubmix', aihubmixProvider)
-
- console.log('✅ Hub Provider "aihubmix" 注册成功')
-
- // 4. 使用Hub Provider访问不同的模型
- console.log('\n🚀 使用Hub模型...')
-
- // 通过Hub路由到OpenAI
- const openaiModel = providerRegistry.languageModel('aihubmix:openai:gpt-4')
- console.log('✓ OpenAI模型已获取:', openaiModel.modelId)
-
- // 通过Hub路由到Anthropic
- const anthropicModel = providerRegistry.languageModel('aihubmix:anthropic:claude-3.5-sonnet')
- console.log('✓ Anthropic模型已获取:', anthropicModel.modelId)
-
- // 5. 演示错误处理
- console.log('\n❌ 演示错误处理...')
-
- try {
- // 尝试访问未初始化的provider
- providerRegistry.languageModel('aihubmix:google:gemini-pro')
- } catch (error) {
- console.log('预期错误:', error.message)
- }
-
- try {
- // 尝试使用错误的模型ID格式
- providerRegistry.languageModel('aihubmix:invalid-format')
- } catch (error) {
- console.log('预期错误:', error.message)
- }
-
- // 6. 多个Hub Provider示例
- console.log('\n🔄 创建多个Hub Provider...')
-
- const localHubProvider = createHubProvider({
- hubId: 'local-ai'
- })
-
- providerRegistry.registerProvider('local-ai', localHubProvider)
- console.log('✅ Hub Provider "local-ai" 注册成功')
-
- console.log('\n🎉 Hub Provider演示完成!')
- } catch (error) {
- console.error('💥 演示过程中发生错误:', error)
- }
-}
-
-// 演示简化的使用方式
-function simplifiedUsageExample() {
- console.log('\n📝 简化使用示例:')
- console.log(`
-// 1. 初始化providers
-initializeProvider('openai', { apiKey: 'sk-xxx' })
-initializeProvider('anthropic', { apiKey: 'sk-ant-xxx' })
-
-// 2. 创建并注册Hub Provider
-const hubProvider = createHubProvider({ hubId: 'aihubmix' })
-providerRegistry.registerProvider('aihubmix', hubProvider)
-
-// 3. 直接使用
-const model1 = providerRegistry.languageModel('aihubmix:openai:gpt-4')
-const model2 = providerRegistry.languageModel('aihubmix:anthropic:claude-3.5-sonnet')
-`)
-}
-
-// 运行演示
-if (require.main === module) {
- demonstrateHubProvider()
- simplifiedUsageExample()
-}
-
-export { demonstrateHubProvider, simplifiedUsageExample }
diff --git a/packages/aiCore/examples/image-generation.ts b/packages/aiCore/examples/image-generation.ts
deleted file mode 100644
index 811aa048c8..0000000000
--- a/packages/aiCore/examples/image-generation.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-/**
- * Image Generation Example
- * 演示如何使用 aiCore 的文生图功能
- */
-
-import { createExecutor, generateImage } from '../src/index'
-
-async function main() {
- // 方式1: 使用执行器实例
- console.log('📸 创建 OpenAI 图像生成执行器...')
- const executor = createExecutor('openai', {
- apiKey: process.env.OPENAI_API_KEY!
- })
-
- try {
- console.log('🎨 使用执行器生成图像...')
- const result1 = await executor.generateImage('dall-e-3', {
- prompt: 'A futuristic cityscape at sunset with flying cars',
- size: '1024x1024',
- n: 1
- })
-
- console.log('✅ 图像生成成功!')
- console.log('📊 结果:', {
- imagesCount: result1.images.length,
- mediaType: result1.image.mediaType,
- hasBase64: !!result1.image.base64,
- providerMetadata: result1.providerMetadata
- })
- } catch (error) {
- console.error('❌ 执行器生成失败:', error)
- }
-
- // 方式2: 使用直接调用 API
- try {
- console.log('🎨 使用直接 API 生成图像...')
- const result2 = await generateImage('openai', { apiKey: process.env.OPENAI_API_KEY! }, 'dall-e-3', {
- prompt: 'A magical forest with glowing mushrooms and fairy lights',
- aspectRatio: '16:9',
- providerOptions: {
- openai: {
- quality: 'hd',
- style: 'vivid'
- }
- }
- })
-
- console.log('✅ 直接 API 生成成功!')
- console.log('📊 结果:', {
- imagesCount: result2.images.length,
- mediaType: result2.image.mediaType,
- hasBase64: !!result2.image.base64
- })
- } catch (error) {
- console.error('❌ 直接 API 生成失败:', error)
- }
-
- // 方式3: 支持其他提供商 (Google Imagen)
- if (process.env.GOOGLE_API_KEY) {
- try {
- console.log('🎨 使用 Google Imagen 生成图像...')
- const googleExecutor = createExecutor('google', {
- apiKey: process.env.GOOGLE_API_KEY!
- })
-
- const result3 = await googleExecutor.generateImage('imagen-3.0-generate-002', {
- prompt: 'A serene mountain lake at dawn with mist rising from the water',
- aspectRatio: '1:1'
- })
-
- console.log('✅ Google Imagen 生成成功!')
- console.log('📊 结果:', {
- imagesCount: result3.images.length,
- mediaType: result3.image.mediaType,
- hasBase64: !!result3.image.base64
- })
- } catch (error) {
- console.error('❌ Google Imagen 生成失败:', error)
- }
- }
-
- // 方式4: 支持插件系统
- const pluginExample = async () => {
- console.log('🔌 演示插件系统...')
-
- // 创建一个示例插件,用于修改提示词
- const promptEnhancerPlugin = {
- name: 'prompt-enhancer',
- transformParams: async (params: any) => {
- console.log('🔧 插件: 增强提示词...')
- return {
- ...params,
- prompt: `${params.prompt}, highly detailed, cinematic lighting, 4K resolution`
- }
- },
- transformResult: async (result: any) => {
- console.log('🔧 插件: 处理结果...')
- return {
- ...result,
- enhanced: true
- }
- }
- }
-
- const executorWithPlugin = createExecutor(
- 'openai',
- {
- apiKey: process.env.OPENAI_API_KEY!
- },
- [promptEnhancerPlugin]
- )
-
- try {
- const result4 = await executorWithPlugin.generateImage('dall-e-3', {
- prompt: 'A cute robot playing in a garden'
- })
-
- console.log('✅ 插件系统生成成功!')
- console.log('📊 结果:', {
- imagesCount: result4.images.length,
- enhanced: (result4 as any).enhanced,
- mediaType: result4.image.mediaType
- })
- } catch (error) {
- console.error('❌ 插件系统生成失败:', error)
- }
- }
-
- await pluginExample()
-}
-
-// 错误处理演示
-async function errorHandlingExample() {
- console.log('⚠️ 演示错误处理...')
-
- try {
- const executor = createExecutor('openai', {
- apiKey: 'invalid-key'
- })
-
- await executor.generateImage('dall-e-3', {
- prompt: 'Test image'
- })
- } catch (error: any) {
- console.log('✅ 成功捕获错误:', error.constructor.name)
- console.log('📋 错误信息:', error.message)
- console.log('🏷️ 提供商ID:', error.providerId)
- console.log('🏷️ 模型ID:', error.modelId)
- }
-}
-
-// 运行示例
-if (require.main === module) {
- main()
- .then(() => {
- console.log('🎉 所有示例完成!')
- return errorHandlingExample()
- })
- .then(() => {
- console.log('🎯 示例程序结束')
- process.exit(0)
- })
- .catch((error) => {
- console.error('💥 程序执行出错:', error)
- process.exit(1)
- })
-}
diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json
index 2e02700563..e61db529e2 100644
--- a/packages/aiCore/package.json
+++ b/packages/aiCore/package.json
@@ -1,6 +1,6 @@
{
"name": "@cherrystudio/ai-core",
- "version": "1.0.0-alpha.11",
+ "version": "1.0.0-alpha.14",
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
"main": "dist/index.js",
"module": "dist/index.mjs",
@@ -39,13 +39,13 @@
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/deepseek": "^1.0.9",
- "@ai-sdk/google": "^2.0.7",
- "@ai-sdk/openai": "^2.0.19",
+ "@ai-sdk/google": "^2.0.13",
+ "@ai-sdk/openai": "^2.0.26",
"@ai-sdk/openai-compatible": "^1.0.9",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.4",
"@ai-sdk/xai": "^2.0.9",
- "zod": "^3.25.0"
+ "zod": "^4.1.5"
},
"devDependencies": {
"tsdown": "^0.12.9",
diff --git a/packages/aiCore/src/core/models/ModelResolver.ts b/packages/aiCore/src/core/models/ModelResolver.ts
index 0f1bde95c6..20bf3d76d1 100644
--- a/packages/aiCore/src/core/models/ModelResolver.ts
+++ b/packages/aiCore/src/core/models/ModelResolver.ts
@@ -84,7 +84,6 @@ export class ModelResolver {
*/
private resolveTraditionalModel(providerId: string, modelId: string): LanguageModelV2 {
const fullModelId = `${providerId}${DEFAULT_SEPARATOR}${modelId}`
- console.log('fullModelId', fullModelId)
return globalRegistryManagement.languageModel(fullModelId as any)
}
diff --git a/packages/aiCore/src/core/options/xai.ts b/packages/aiCore/src/core/options/xai.ts
index 8d82f587e8..7fe5672778 100644
--- a/packages/aiCore/src/core/options/xai.ts
+++ b/packages/aiCore/src/core/options/xai.ts
@@ -1,7 +1,7 @@
// copy from @ai-sdk/xai/xai-chat-options.ts
// 如果@ai-sdk/xai暴露出了xaiProviderOptions就删除这个文件
-import * as z from 'zod/v4'
+import { z } from 'zod'
const webSourceSchema = z.object({
type: z.literal('web'),
diff --git a/packages/aiCore/src/core/plugins/built-in/googleToolsPlugin/index.ts b/packages/aiCore/src/core/plugins/built-in/googleToolsPlugin/index.ts
new file mode 100644
index 0000000000..cb581e17c6
--- /dev/null
+++ b/packages/aiCore/src/core/plugins/built-in/googleToolsPlugin/index.ts
@@ -0,0 +1,39 @@
+import { google } from '@ai-sdk/google'
+
+import { definePlugin } from '../../'
+import type { AiRequestContext } from '../../types'
+
+const toolNameMap = {
+ googleSearch: 'google_search',
+ urlContext: 'url_context',
+ codeExecution: 'code_execution'
+} as const
+
+type ToolConfigKey = keyof typeof toolNameMap
+type ToolConfig = { googleSearch?: boolean; urlContext?: boolean; codeExecution?: boolean }
+
+export const googleToolsPlugin = (config?: ToolConfig) =>
+ definePlugin({
+ name: 'googleToolsPlugin',
+ transformParams: (params: T, context: AiRequestContext): T => {
+ const { providerId } = context
+ if (providerId === 'google' && config) {
+ if (typeof params === 'object' && params !== null) {
+ const typedParams = params as T & { tools?: Record }
+
+ if (!typedParams.tools) {
+ typedParams.tools = {}
+ }
+
+ // 使用类型安全的方式遍历配置
+ ;(Object.keys(config) as ToolConfigKey[]).forEach((key) => {
+ if (config[key] && key in toolNameMap && key in google.tools) {
+ const toolName = toolNameMap[key]
+ typedParams.tools![toolName] = google.tools[key]({})
+ }
+ })
+ }
+ }
+ return params
+ }
+ })
diff --git a/packages/aiCore/src/core/plugins/built-in/index.ts b/packages/aiCore/src/core/plugins/built-in/index.ts
index 3c0dfa5a8f..e7dcae8738 100644
--- a/packages/aiCore/src/core/plugins/built-in/index.ts
+++ b/packages/aiCore/src/core/plugins/built-in/index.ts
@@ -4,6 +4,7 @@
*/
export const BUILT_IN_PLUGIN_PREFIX = 'built-in:'
+export { googleToolsPlugin } from './googleToolsPlugin'
export { createLoggingPlugin } from './logging'
export { createPromptToolUsePlugin } from './toolUsePlugin/promptToolUsePlugin'
export type { PromptToolUseConfig, ToolUseRequestContext, ToolUseResult } from './toolUsePlugin/type'
diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/StreamEventManager.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/StreamEventManager.ts
index 197b20e9b4..59a425712c 100644
--- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/StreamEventManager.ts
+++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/StreamEventManager.ts
@@ -27,10 +27,20 @@ export class StreamEventManager {
/**
* 发送步骤完成事件
*/
- sendStepFinishEvent(controller: StreamController, chunk: any): void {
+ sendStepFinishEvent(
+ controller: StreamController,
+ chunk: any,
+ context: AiRequestContext,
+ finishReason: string = 'stop'
+ ): void {
+ // 累加当前步骤的 usage
+ if (chunk.usage && context.accumulatedUsage) {
+ this.accumulateUsage(context.accumulatedUsage, chunk.usage)
+ }
+
controller.enqueue({
type: 'finish-step',
- finishReason: 'stop',
+ finishReason,
response: chunk.response,
usage: chunk.usage,
providerMetadata: chunk.providerMetadata
@@ -43,28 +53,32 @@ export class StreamEventManager {
async handleRecursiveCall(
controller: StreamController,
recursiveParams: any,
- context: AiRequestContext,
- stepId: string
+ context: AiRequestContext
): Promise {
- try {
- console.log('[MCP Prompt] Starting recursive call after tool execution...')
+ // try {
+ // 重置工具执行状态,准备处理新的步骤
+ context.hasExecutedToolsInCurrentStep = false
- const recursiveResult = await context.recursiveCall(recursiveParams)
+ const recursiveResult = await context.recursiveCall(recursiveParams)
- if (recursiveResult && recursiveResult.fullStream) {
- await this.pipeRecursiveStream(controller, recursiveResult.fullStream)
- } else {
- console.warn('[MCP Prompt] No fullstream found in recursive result:', recursiveResult)
- }
- } catch (error) {
- this.handleRecursiveCallError(controller, error, stepId)
+ if (recursiveResult && recursiveResult.fullStream) {
+ await this.pipeRecursiveStream(controller, recursiveResult.fullStream, context)
+ } else {
+ console.warn('[MCP Prompt] No fullstream found in recursive result:', recursiveResult)
}
+ // } catch (error) {
+ // this.handleRecursiveCallError(controller, error, stepId)
+ // }
}
/**
* 将递归流的数据传递到当前流
*/
- private async pipeRecursiveStream(controller: StreamController, recursiveStream: ReadableStream): Promise {
+ private async pipeRecursiveStream(
+ controller: StreamController,
+ recursiveStream: ReadableStream,
+ context?: AiRequestContext
+ ): Promise {
const reader = recursiveStream.getReader()
try {
while (true) {
@@ -73,9 +87,16 @@ export class StreamEventManager {
break
}
if (value.type === 'finish') {
- // 迭代的流不发finish
+ // 迭代的流不发finish,但需要累加其 usage
+ if (value.usage && context?.accumulatedUsage) {
+ this.accumulateUsage(context.accumulatedUsage, value.usage)
+ }
break
}
+ // 对于 finish-step 类型,累加其 usage
+ if (value.type === 'finish-step' && value.usage && context?.accumulatedUsage) {
+ this.accumulateUsage(context.accumulatedUsage, value.usage)
+ }
// 将递归流的数据传递到当前流
controller.enqueue(value)
}
@@ -87,25 +108,25 @@ export class StreamEventManager {
/**
* 处理递归调用错误
*/
- private handleRecursiveCallError(controller: StreamController, error: unknown, stepId: string): void {
- console.error('[MCP Prompt] Recursive call failed:', error)
+ // private handleRecursiveCallError(controller: StreamController, error: unknown): void {
+ // console.error('[MCP Prompt] Recursive call failed:', error)
- // 使用 AI SDK 标准错误格式,但不中断流
- controller.enqueue({
- type: 'error',
- error: {
- message: error instanceof Error ? error.message : String(error),
- name: error instanceof Error ? error.name : 'RecursiveCallError'
- }
- })
+ // // 使用 AI SDK 标准错误格式,但不中断流
+ // controller.enqueue({
+ // type: 'error',
+ // error: {
+ // message: error instanceof Error ? error.message : String(error),
+ // name: error instanceof Error ? error.name : 'RecursiveCallError'
+ // }
+ // })
- // 继续发送文本增量,保持流的连续性
- controller.enqueue({
- type: 'text-delta',
- id: stepId,
- text: '\n\n[工具执行后递归调用失败,继续对话...]'
- })
- }
+ // // // 继续发送文本增量,保持流的连续性
+ // // controller.enqueue({
+ // // type: 'text-delta',
+ // // id: stepId,
+ // // text: '\n\n[工具执行后递归调用失败,继续对话...]'
+ // // })
+ // }
/**
* 构建递归调用的参数
@@ -136,4 +157,18 @@ export class StreamEventManager {
return recursiveParams
}
+
+ /**
+ * 累加 usage 数据
+ */
+ private accumulateUsage(target: any, source: any): void {
+ if (!target || !source) return
+
+ // 累加各种 token 类型
+ target.inputTokens = (target.inputTokens || 0) + (source.inputTokens || 0)
+ target.outputTokens = (target.outputTokens || 0) + (source.outputTokens || 0)
+ target.totalTokens = (target.totalTokens || 0) + (source.totalTokens || 0)
+ target.reasoningTokens = (target.reasoningTokens || 0) + (source.reasoningTokens || 0)
+ target.cachedInputTokens = (target.cachedInputTokens || 0) + (source.cachedInputTokens || 0)
+ }
}
diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/ToolExecutor.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/ToolExecutor.ts
index ec174fa2ea..29d644554e 100644
--- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/ToolExecutor.ts
+++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/ToolExecutor.ts
@@ -4,7 +4,7 @@
* 负责工具的执行、结果格式化和相关事件发送
* 从 promptToolUsePlugin.ts 中提取出来以降低复杂度
*/
-import type { ToolSet } from 'ai'
+import type { ToolSet, TypedToolError } from 'ai'
import type { ToolUseResult } from './type'
@@ -38,7 +38,6 @@ export class ToolExecutor {
controller: StreamController
): Promise {
const executedResults: ExecutedResult[] = []
-
for (const toolUse of toolUses) {
try {
const tool = tools[toolUse.toolName]
@@ -46,17 +45,12 @@ export class ToolExecutor {
throw new Error(`Tool "${toolUse.toolName}" has no execute method`)
}
- // 发送工具调用开始事件
- this.sendToolStartEvents(controller, toolUse)
-
- console.log(`[MCP Prompt Stream] Executing tool: ${toolUse.toolName}`, toolUse.arguments)
-
// 发送 tool-call 事件
controller.enqueue({
type: 'tool-call',
toolCallId: toolUse.id,
toolName: toolUse.toolName,
- input: tool.inputSchema
+ input: toolUse.arguments
})
const result = await tool.execute(toolUse.arguments, {
@@ -111,45 +105,46 @@ export class ToolExecutor {
/**
* 发送工具调用开始相关事件
*/
- private sendToolStartEvents(controller: StreamController, toolUse: ToolUseResult): void {
- // 发送 tool-input-start 事件
- controller.enqueue({
- type: 'tool-input-start',
- id: toolUse.id,
- toolName: toolUse.toolName
- })
- }
+ // private sendToolStartEvents(controller: StreamController, toolUse: ToolUseResult): void {
+ // // 发送 tool-input-start 事件
+ // controller.enqueue({
+ // type: 'tool-input-start',
+ // id: toolUse.id,
+ // toolName: toolUse.toolName
+ // })
+ // }
/**
* 处理工具执行错误
*/
- private handleToolError(
+ private handleToolError(
toolUse: ToolUseResult,
error: unknown,
controller: StreamController
- // _tools: ToolSet
): ExecutedResult {
// 使用 AI SDK 标准错误格式
- // const toolError: TypedToolError = {
- // type: 'tool-error',
- // toolCallId: toolUse.id,
- // toolName: toolUse.toolName,
- // input: toolUse.arguments,
- // error: error instanceof Error ? error.message : String(error)
- // }
+ const toolError: TypedToolError = {
+ type: 'tool-error',
+ toolCallId: toolUse.id,
+ toolName: toolUse.toolName,
+ input: toolUse.arguments,
+ error
+ }
- // controller.enqueue(toolError)
+ controller.enqueue(toolError)
// 发送标准错误事件
- controller.enqueue({
- type: 'error',
- error: error instanceof Error ? error.message : String(error)
- })
+ // controller.enqueue({
+ // type: 'tool-error',
+ // toolCallId: toolUse.id,
+ // error: error instanceof Error ? error.message : String(error),
+ // input: toolUse.arguments
+ // })
return {
toolCallId: toolUse.id,
toolName: toolUse.toolName,
- result: error instanceof Error ? error.message : String(error),
+ result: error,
isError: true
}
}
diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts
index 1d795c94a3..fce028f5cd 100644
--- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts
+++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts
@@ -8,9 +8,19 @@ import type { TextStreamPart, ToolSet } from 'ai'
import { definePlugin } from '../../index'
import type { AiRequestContext } from '../../types'
import { StreamEventManager } from './StreamEventManager'
+import { type TagConfig, TagExtractor } from './tagExtraction'
import { ToolExecutor } from './ToolExecutor'
import { PromptToolUseConfig, ToolUseResult } from './type'
+/**
+ * 工具使用标签配置
+ */
+const TOOL_USE_TAG_CONFIG: TagConfig = {
+ openingTag: '',
+ closingTag: ' ',
+ separator: '\n'
+}
+
/**
* 默认系统提示符模板(提取自 Cherry Studio)
*/
@@ -146,8 +156,10 @@ Assistant: The population of Shanghai is 26 million, while Guangzhou has a popul
/**
* 构建可用工具部分(提取自 Cherry Studio)
*/
-function buildAvailableTools(tools: ToolSet): string {
+function buildAvailableTools(tools: ToolSet): string | null {
const availableTools = Object.keys(tools)
+ if (availableTools.length === 0) return null
+ const result = availableTools
.map((toolName: string) => {
const tool = tools[toolName]
return `
@@ -162,7 +174,7 @@ function buildAvailableTools(tools: ToolSet): string {
})
.join('\n')
return `
-${availableTools}
+${result}
`
}
@@ -171,6 +183,7 @@ ${availableTools}
*/
function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet): string {
const availableTools = buildAvailableTools(tools)
+ if (availableTools === null) return userSystemPrompt
const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES)
.replace('{{ AVAILABLE_TOOLS }}', availableTools)
@@ -249,13 +262,11 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
}
context.mcpTools = params.tools
- console.log('tools stored in context', params.tools)
// 构建系统提示符
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
const systemPrompt = buildSystemPrompt(userSystemPrompt, params.tools)
let systemMessage: string | null = systemPrompt
- console.log('config.context', context)
if (config.createSystemMessage) {
// 🎯 如果用户提供了自定义处理函数,使用它
systemMessage = config.createSystemMessage(systemPrompt, params, context)
@@ -268,20 +279,40 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
tools: undefined
}
context.originalParams = transformedParams
- console.log('transformedParams', transformedParams)
return transformedParams
},
transformStream: (_: any, context: AiRequestContext) => () => {
let textBuffer = ''
- let stepId = ''
+ // let stepId = ''
if (!context.mcpTools) {
throw new Error('No tools available')
}
- // 创建工具执行器和流事件管理器
+ // 从 context 中获取或初始化 usage 累加器
+ if (!context.accumulatedUsage) {
+ context.accumulatedUsage = {
+ inputTokens: 0,
+ outputTokens: 0,
+ totalTokens: 0,
+ reasoningTokens: 0,
+ cachedInputTokens: 0
+ }
+ }
+
+ // 创建工具执行器、流事件管理器和标签提取器
const toolExecutor = new ToolExecutor()
const streamEventManager = new StreamEventManager()
+ const tagExtractor = new TagExtractor(TOOL_USE_TAG_CONFIG)
+
+ // 在context中初始化工具执行状态,避免递归调用时状态丢失
+ if (!context.hasExecutedToolsInCurrentStep) {
+ context.hasExecutedToolsInCurrentStep = false
+ }
+
+ // 用于hold text-start事件,直到确认有非工具标签内容
+ let pendingTextStart: TextStreamPart | null = null
+ let hasStartedText = false
type TOOLS = NonNullable
return new TransformStream, TextStreamPart>({
@@ -289,83 +320,106 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
chunk: TextStreamPart,
controller: TransformStreamDefaultController>
) {
- // 收集文本内容
- if (chunk.type === 'text-delta') {
- textBuffer += chunk.text || ''
- stepId = chunk.id || ''
- controller.enqueue(chunk)
+ // Hold住text-start事件,直到确认有非工具标签内容
+ if ((chunk as any).type === 'text-start') {
+ pendingTextStart = chunk
return
}
- if (chunk.type === 'text-end' || chunk.type === 'finish-step') {
- const tools = context.mcpTools
- if (!tools || Object.keys(tools).length === 0) {
- controller.enqueue(chunk)
- return
- }
+ // text-delta阶段:收集文本内容并过滤工具标签
+ if (chunk.type === 'text-delta') {
+ textBuffer += chunk.text || ''
+ // stepId = chunk.id || ''
- // 解析工具调用
- const { results: parsedTools, content: parsedContent } = parseToolUse(textBuffer, tools)
- const validToolUses = parsedTools.filter((t) => t.status === 'pending')
+ // 使用TagExtractor过滤工具标签,只传递非标签内容到UI层
+ const extractionResults = tagExtractor.processText(chunk.text || '')
- // 如果没有有效的工具调用,直接传递原始事件
- if (validToolUses.length === 0) {
- controller.enqueue(chunk)
- return
- }
-
- if (chunk.type === 'text-end') {
- controller.enqueue({
- type: 'text-end',
- id: stepId,
- providerMetadata: {
- text: {
- value: parsedContent
- }
+ for (const result of extractionResults) {
+ // 只传递非标签内容到UI层
+ if (!result.isTagContent && result.content) {
+ // 如果还没有发送text-start且有pending的text-start,先发送它
+ if (!hasStartedText && pendingTextStart) {
+ controller.enqueue(pendingTextStart)
+ hasStartedText = true
+ pendingTextStart = null
}
- })
- return
+
+ const filteredChunk = {
+ ...chunk,
+ text: result.content
+ }
+ controller.enqueue(filteredChunk)
+ }
+ }
+ return
+ }
+
+ if (chunk.type === 'text-end') {
+ // 只有当已经发送了text-start时才发送text-end
+ if (hasStartedText) {
+ controller.enqueue(chunk)
+ }
+ return
+ }
+
+ if (chunk.type === 'finish-step') {
+ // 统一在finish-step阶段检查并执行工具调用
+ const tools = context.mcpTools
+ if (tools && Object.keys(tools).length > 0 && !context.hasExecutedToolsInCurrentStep) {
+ // 解析完整的textBuffer来检测工具调用
+ const { results: parsedTools } = parseToolUse(textBuffer, tools)
+ const validToolUses = parsedTools.filter((t) => t.status === 'pending')
+
+ if (validToolUses.length > 0) {
+ context.hasExecutedToolsInCurrentStep = true
+
+ // 执行工具调用(不需要手动发送 start-step,外部流已经处理)
+ const executedResults = await toolExecutor.executeTools(validToolUses, tools, controller)
+
+ // 发送步骤完成事件,使用 tool-calls 作为 finishReason
+ streamEventManager.sendStepFinishEvent(controller, chunk, context, 'tool-calls')
+
+ // 处理递归调用
+ const toolResultsText = toolExecutor.formatToolResults(executedResults)
+ const recursiveParams = streamEventManager.buildRecursiveParams(
+ context,
+ textBuffer,
+ toolResultsText,
+ tools
+ )
+
+ await streamEventManager.handleRecursiveCall(controller, recursiveParams, context)
+ return
+ }
}
- controller.enqueue({
- ...chunk,
- finishReason: 'tool-calls'
- })
-
- // 发送步骤开始事件
- streamEventManager.sendStepStartEvent(controller)
-
- // 执行工具调用
- const executedResults = await toolExecutor.executeTools(validToolUses, tools, controller)
-
- // 发送步骤完成事件
- streamEventManager.sendStepFinishEvent(controller, chunk)
-
- // 处理递归调用
- if (validToolUses.length > 0) {
- const toolResultsText = toolExecutor.formatToolResults(executedResults)
- const recursiveParams = streamEventManager.buildRecursiveParams(
- context,
- textBuffer,
- toolResultsText,
- tools
- )
-
- await streamEventManager.handleRecursiveCall(controller, recursiveParams, context, stepId)
- }
+ // 如果没有执行工具调用,直接传递原始finish-step事件
+ controller.enqueue(chunk)
// 清理状态
textBuffer = ''
return
}
- // 对于其他类型的事件,直接传递
- controller.enqueue(chunk)
+ // 处理 finish 类型,使用累加后的 totalUsage
+ if (chunk.type === 'finish') {
+ controller.enqueue({
+ ...chunk,
+ totalUsage: context.accumulatedUsage
+ })
+ return
+ }
+
+ // 对于其他类型的事件,直接传递(不包括text-start,已在上面处理)
+ if ((chunk as any).type !== 'text-start') {
+ controller.enqueue(chunk)
+ }
},
flush() {
- // 流结束时的清理工作
- console.log('[MCP Prompt] Stream ended, cleaning up...')
+ // 清理pending状态
+ pendingTextStart = null
+ hasStartedText = false
}
})
}
diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts
index 3d549eeac4..d941e0d0c7 100644
--- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts
+++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts
@@ -27,7 +27,7 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
case 'openai': {
if (config.openai) {
if (!params.tools) params.tools = {}
- params.tools.web_search_preview = openai.tools.webSearchPreview(config.openai)
+ params.tools.web_search = openai.tools.webSearch(config.openai)
}
break
}
diff --git a/packages/aiCore/src/core/plugins/index.ts b/packages/aiCore/src/core/plugins/index.ts
index 200188d59d..551b252bf1 100644
--- a/packages/aiCore/src/core/plugins/index.ts
+++ b/packages/aiCore/src/core/plugins/index.ts
@@ -1,5 +1,8 @@
// 核心类型和接口
export type { AiPlugin, AiRequestContext, HookResult, PluginManagerConfig } from './types'
+import type { ImageModelV2 } from '@ai-sdk/provider'
+import type { LanguageModel } from 'ai'
+
import type { ProviderId } from '../providers'
import type { AiPlugin, AiRequestContext } from './types'
@@ -9,16 +12,16 @@ export { PluginManager } from './manager'
// 工具函数
export function createContext(
providerId: T,
- modelId: string,
+ model: LanguageModel | ImageModelV2,
originalParams: any
): AiRequestContext {
return {
providerId,
- modelId,
+ model,
originalParams,
metadata: {},
startTime: Date.now(),
- requestId: `${providerId}-${modelId}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ requestId: `${providerId}-${typeof model === 'string' ? model : model?.modelId}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
// 占位
recursiveCall: () => Promise.resolve(null)
}
diff --git a/packages/aiCore/src/core/plugins/types.ts b/packages/aiCore/src/core/plugins/types.ts
index 378fa6c3d3..aaf485ea2f 100644
--- a/packages/aiCore/src/core/plugins/types.ts
+++ b/packages/aiCore/src/core/plugins/types.ts
@@ -14,7 +14,7 @@ export type RecursiveCallFn = (newParams: any) => Promise
*/
export interface AiRequestContext {
providerId: ProviderId
- modelId: string
+ model: LanguageModel | ImageModelV2
originalParams: any
metadata: Record
startTime: number
diff --git a/packages/aiCore/src/core/providers/schemas.ts b/packages/aiCore/src/core/providers/schemas.ts
index 0c1c847d98..e4b8d8aa64 100644
--- a/packages/aiCore/src/core/providers/schemas.ts
+++ b/packages/aiCore/src/core/providers/schemas.ts
@@ -10,8 +10,8 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google'
import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { createXai } from '@ai-sdk/xai'
-import { customProvider, type Provider } from 'ai'
-import * as z from 'zod'
+import { customProvider, Provider } from 'ai'
+import { z } from 'zod'
/**
* 基础 Provider IDs
@@ -38,14 +38,12 @@ export const baseProviderIdSchema = z.enum(baseProviderIds)
*/
export type BaseProviderId = z.infer
-export const baseProviderSchema = z.object({
- id: baseProviderIdSchema,
- name: z.string(),
- creator: z.function().args(z.any()).returns(z.any()) as z.ZodType<(options: any) => Provider>,
- supportsImageGeneration: z.boolean()
-})
-
-export type BaseProvider = z.infer
+type BaseProvider = {
+ id: BaseProviderId
+ name: string
+ creator: (options: any) => Provider
+ supportsImageGeneration: boolean
+}
/**
* 基础 Providers 定义
@@ -148,7 +146,12 @@ export const providerConfigSchema = z
.object({
id: customProviderIdSchema, // 只允许自定义ID
name: z.string().min(1),
- creator: z.function().optional(),
+ creator: z
+ .function({
+ input: z.any(),
+ output: z.any()
+ })
+ .optional(),
import: z.function().optional(),
creatorFunctionName: z.string().optional(),
supportsImageGeneration: z.boolean().default(false),
diff --git a/packages/aiCore/src/core/runtime/executor.ts b/packages/aiCore/src/core/runtime/executor.ts
index ab80f9cecc..ab764bacd6 100644
--- a/packages/aiCore/src/core/runtime/executor.ts
+++ b/packages/aiCore/src/core/runtime/executor.ts
@@ -4,12 +4,12 @@
*/
import { ImageModelV2, LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider'
import {
- experimental_generateImage as generateImage,
- generateObject,
- generateText,
+ experimental_generateImage as _generateImage,
+ generateObject as _generateObject,
+ generateText as _generateText,
LanguageModel,
- streamObject,
- streamText
+ streamObject as _streamObject,
+ streamText as _streamText
} from 'ai'
import { globalModelResolver } from '../models'
@@ -18,7 +18,14 @@ import { type AiPlugin, type AiRequestContext, definePlugin } from '../plugins'
import { type ProviderId } from '../providers'
import { ImageGenerationError, ImageModelResolutionError } from './errors'
import { PluginEngine } from './pluginEngine'
-import { type RuntimeConfig } from './types'
+import type {
+ generateImageParams,
+ generateObjectParams,
+ generateTextParams,
+ RuntimeConfig,
+ streamObjectParams,
+ streamTextParams
+} from './types'
export class RuntimeExecutor {
public pluginEngine: PluginEngine
@@ -75,12 +82,12 @@ export class RuntimeExecutor {
* 流式文本生成
*/
async streamText(
- params: Parameters[0],
+ params: streamTextParams,
options?: {
middlewares?: LanguageModelV2Middleware[]
}
- ): Promise> {
- const { model, ...restParams } = params
+ ): Promise> {
+ const { model } = params
// 根据 model 类型决定插件配置
if (typeof model === 'string') {
@@ -94,19 +101,16 @@ export class RuntimeExecutor {
return this.pluginEngine.executeStreamWithPlugins(
'streamText',
- model,
- restParams,
- async (resolvedModel, transformedParams, streamTransforms) => {
+ params,
+ (resolvedModel, transformedParams, streamTransforms) => {
const experimental_transform =
params?.experimental_transform ?? (streamTransforms.length > 0 ? streamTransforms : undefined)
- const finalParams = {
- model: resolvedModel,
+ return _streamText({
...transformedParams,
+ model: resolvedModel,
experimental_transform
- } as Parameters[0]
-
- return await streamText(finalParams)
+ })
}
)
}
@@ -117,12 +121,12 @@ export class RuntimeExecutor {
* 生成文本
*/
async generateText(
- params: Parameters[0],
+ params: generateTextParams,
options?: {
middlewares?: LanguageModelV2Middleware[]
}
- ): Promise> {
- const { model, ...restParams } = params
+ ): Promise> {
+ const { model } = params
// 根据 model 类型决定插件配置
if (typeof model === 'string') {
@@ -134,12 +138,10 @@ export class RuntimeExecutor {
this.pluginEngine.usePlugins([this.createConfigureContextPlugin()])
}
- return this.pluginEngine.executeWithPlugins(
+ return this.pluginEngine.executeWithPlugins[0], ReturnType>(
'generateText',
- model,
- restParams,
- async (resolvedModel, transformedParams) =>
- generateText({ model: resolvedModel, ...transformedParams } as Parameters[0])
+ params,
+ (resolvedModel, transformedParams) => _generateText({ ...transformedParams, model: resolvedModel })
)
}
@@ -147,12 +149,12 @@ export class RuntimeExecutor {
* 生成结构化对象
*/
async generateObject(
- params: Parameters[0],
+ params: generateObjectParams,
options?: {
middlewares?: LanguageModelV2Middleware[]
}
- ): Promise> {
- const { model, ...restParams } = params
+ ): Promise> {
+ const { model } = params
// 根据 model 类型决定插件配置
if (typeof model === 'string') {
@@ -164,25 +166,23 @@ export class RuntimeExecutor {
this.pluginEngine.usePlugins([this.createConfigureContextPlugin()])
}
- return this.pluginEngine.executeWithPlugins(
+ return this.pluginEngine.executeWithPlugins>(
'generateObject',
- model,
- restParams,
- async (resolvedModel, transformedParams) =>
- generateObject({ model: resolvedModel, ...transformedParams } as Parameters[0])
+ params,
+ async (resolvedModel, transformedParams) => _generateObject({ ...transformedParams, model: resolvedModel })
)
}
/**
* 流式生成结构化对象
*/
- async streamObject(
- params: Parameters[0],
+ streamObject(
+ params: streamObjectParams,
options?: {
middlewares?: LanguageModelV2Middleware[]
}
- ): Promise> {
- const { model, ...restParams } = params
+ ): Promise> {
+ const { model } = params
// 根据 model 类型决定插件配置
if (typeof model === 'string') {
@@ -194,23 +194,17 @@ export class RuntimeExecutor {
this.pluginEngine.usePlugins([this.createConfigureContextPlugin()])
}
- return this.pluginEngine.executeWithPlugins(
- 'streamObject',
- model,
- restParams,
- async (resolvedModel, transformedParams) =>
- streamObject({ model: resolvedModel, ...transformedParams } as Parameters[0])
+ return this.pluginEngine.executeStreamWithPlugins('streamObject', params, (resolvedModel, transformedParams) =>
+ _streamObject({ ...transformedParams, model: resolvedModel })
)
}
/**
* 生成图像
*/
- async generateImage(
- params: Omit[0], 'model'> & { model: string | ImageModelV2 }
- ): Promise> {
+ generateImage(params: generateImageParams): Promise> {
try {
- const { model, ...restParams } = params
+ const { model } = params
// 根据 model 类型决定插件配置
if (typeof model === 'string') {
@@ -219,13 +213,8 @@ export class RuntimeExecutor {
this.pluginEngine.usePlugins([this.createConfigureContextPlugin()])
}
- return await this.pluginEngine.executeImageWithPlugins(
- 'generateImage',
- model,
- restParams,
- async (resolvedModel, transformedParams) => {
- return await generateImage({ model: resolvedModel, ...transformedParams })
- }
+ return this.pluginEngine.executeImageWithPlugins('generateImage', params, (resolvedModel, transformedParams) =>
+ _generateImage({ ...transformedParams, model: resolvedModel })
)
} catch (error) {
if (error instanceof Error) {
diff --git a/packages/aiCore/src/core/runtime/pluginEngine.ts b/packages/aiCore/src/core/runtime/pluginEngine.ts
index 7a4bb440f7..d0100d2bcb 100644
--- a/packages/aiCore/src/core/runtime/pluginEngine.ts
+++ b/packages/aiCore/src/core/runtime/pluginEngine.ts
@@ -1,6 +1,6 @@
/* eslint-disable @eslint-react/naming-convention/context-name */
import { ImageModelV2 } from '@ai-sdk/provider'
-import { LanguageModel } from 'ai'
+import { experimental_generateImage, generateObject, generateText, LanguageModel, streamObject, streamText } from 'ai'
import { type AiPlugin, createContext, PluginManager } from '../plugins'
import { type ProviderId } from '../providers/types'
@@ -62,17 +62,19 @@ export class PluginEngine {
* 执行带插件的操作(非流式)
* 提供给AiExecutor使用
*/
- async executeWithPlugins(
+ async executeWithPlugins<
+ TParams extends Parameters[0],
+ TResult extends ReturnType
+ >(
methodName: string,
- model: LanguageModel,
params: TParams,
- executor: (model: LanguageModel, transformedParams: TParams) => Promise,
+ executor: (model: LanguageModel, transformedParams: TParams) => TResult,
_context?: ReturnType
): Promise {
// 统一处理模型解析
let resolvedModel: LanguageModel | undefined
let modelId: string
-
+ const { model } = params
if (typeof model === 'string') {
// 字符串:需要通过插件解析
modelId = model
@@ -83,13 +85,13 @@ export class PluginEngine {
}
// 使用正确的createContext创建请求上下文
- const context = _context ? _context : createContext(this.providerId, modelId, params)
+ const context = _context ? _context : createContext(this.providerId, model, params)
// 🔥 为上下文添加递归调用能力
context.recursiveCall = async (newParams: any): Promise => {
// 递归调用自身,重新走完整的插件流程
context.isRecursiveCall = true
- const result = await this.executeWithPlugins(methodName, model, newParams, executor, context)
+ const result = await this.executeWithPlugins(methodName, newParams, executor, context)
context.isRecursiveCall = false
return result
}
@@ -138,17 +140,19 @@ export class PluginEngine {
* 执行带插件的图像生成操作
* 提供给AiExecutor使用
*/
- async executeImageWithPlugins(
+ async executeImageWithPlugins<
+ TParams extends Omit[0], 'model'> & { model: string | ImageModelV2 },
+ TResult extends ReturnType
+ >(
methodName: string,
- model: ImageModelV2 | string,
params: TParams,
- executor: (model: ImageModelV2, transformedParams: TParams) => Promise,
+ executor: (model: ImageModelV2, transformedParams: TParams) => TResult,
_context?: ReturnType
): Promise {
// 统一处理模型解析
let resolvedModel: ImageModelV2 | undefined
let modelId: string
-
+ const { model } = params
if (typeof model === 'string') {
// 字符串:需要通过插件解析
modelId = model
@@ -159,13 +163,13 @@ export class PluginEngine {
}
// 使用正确的createContext创建请求上下文
- const context = _context ? _context : createContext(this.providerId, modelId, params)
+ const context = _context ? _context : createContext(this.providerId, model, params)
// 🔥 为上下文添加递归调用能力
context.recursiveCall = async (newParams: any): Promise => {
// 递归调用自身,重新走完整的插件流程
context.isRecursiveCall = true
- const result = await this.executeImageWithPlugins(methodName, model, newParams, executor, context)
+ const result = await this.executeImageWithPlugins(methodName, newParams, executor, context)
context.isRecursiveCall = false
return result
}
@@ -214,17 +218,19 @@ export class PluginEngine {
* 执行流式调用的通用逻辑(支持流转换器)
* 提供给AiExecutor使用
*/
- async executeStreamWithPlugins(
+ async executeStreamWithPlugins<
+ TParams extends Parameters[0],
+ TResult extends ReturnType
+ >(
methodName: string,
- model: LanguageModel,
params: TParams,
- executor: (model: LanguageModel, transformedParams: TParams, streamTransforms: any[]) => Promise,
+ executor: (model: LanguageModel, transformedParams: TParams, streamTransforms: any[]) => TResult,
_context?: ReturnType
): Promise {
// 统一处理模型解析
let resolvedModel: LanguageModel | undefined
let modelId: string
-
+ const { model } = params
if (typeof model === 'string') {
// 字符串:需要通过插件解析
modelId = model
@@ -235,13 +241,13 @@ export class PluginEngine {
}
// 创建请求上下文
- const context = _context ? _context : createContext(this.providerId, modelId, params)
+ const context = _context ? _context : createContext(this.providerId, model, params)
// 🔥 为上下文添加递归调用能力
context.recursiveCall = async (newParams: any): Promise => {
// 递归调用自身,重新走完整的插件流程
context.isRecursiveCall = true
- const result = await this.executeStreamWithPlugins(methodName, model, newParams, executor, context)
+ const result = await this.executeStreamWithPlugins(methodName, newParams, executor, context)
context.isRecursiveCall = false
return result
}
diff --git a/packages/aiCore/src/core/runtime/types.ts b/packages/aiCore/src/core/runtime/types.ts
index f98e9034c6..fbdcf46333 100644
--- a/packages/aiCore/src/core/runtime/types.ts
+++ b/packages/aiCore/src/core/runtime/types.ts
@@ -1,6 +1,9 @@
/**
* Runtime 层类型定义
*/
+import { ImageModelV2 } from '@ai-sdk/provider'
+import { experimental_generateImage, generateObject, generateText, streamObject, streamText } from 'ai'
+
import { type ModelConfig } from '../models/types'
import { type AiPlugin } from '../plugins'
import { type ProviderId } from '../providers/types'
@@ -13,3 +16,11 @@ export interface RuntimeConfig {
providerSettings: ModelConfig['providerSettings'] & { mode?: 'chat' | 'responses' }
plugins?: AiPlugin[]
}
+
+export type generateImageParams = Omit[0], 'model'> & {
+ model: string | ImageModelV2
+}
+export type generateObjectParams = Parameters[0]
+export type generateTextParams = Parameters[0]
+export type streamObjectParams = Parameters[0]
+export type streamTextParams = Parameters[0]
diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts
index 5bad530f95..94f24a7aeb 100644
--- a/packages/shared/IpcChannel.ts
+++ b/packages/shared/IpcChannel.ts
@@ -35,8 +35,10 @@ export enum IpcChannel {
App_InstallBunBinary = 'app:install-bun-binary',
App_LogToMain = 'app:log-to-main',
App_SaveData = 'app:save-data',
+ App_GetDiskInfo = 'app:get-disk-info',
App_SetFullScreen = 'app:set-full-screen',
App_IsFullScreen = 'app:is-full-screen',
+ App_GetSystemFonts = 'app:get-system-fonts',
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
@@ -331,6 +333,13 @@ export enum IpcChannel {
TRACE_CLEAN_LOCAL_DATA = 'trace:cleanLocalData',
TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage',
+ // API Server
+ ApiServer_Start = 'api-server:start',
+ ApiServer_Stop = 'api-server:stop',
+ ApiServer_Restart = 'api-server:restart',
+ ApiServer_GetStatus = 'api-server:get-status',
+ ApiServer_GetConfig = 'api-server:get-config',
+
// Anthropic OAuth
Anthropic_StartOAuthFlow = 'anthropic:start-oauth-flow',
Anthropic_CompleteOAuthWithCode = 'anthropic:complete-oauth-with-code',
diff --git a/packages/shared/utils.ts b/packages/shared/utils.ts
new file mode 100644
index 0000000000..e87e2f2bef
--- /dev/null
+++ b/packages/shared/utils.ts
@@ -0,0 +1,6 @@
+export const defaultAppHeaders = () => {
+ return {
+ 'HTTP-Referer': 'https://cherry-ai.com',
+ 'X-Title': 'Cherry Studio'
+ }
+}
diff --git a/resources/cherry-studio/license.html b/resources/cherry-studio/license.html
index ce3079a322..1df611e47c 100644
--- a/resources/cherry-studio/license.html
+++ b/resources/cherry-studio/license.html
@@ -8,18 +8,18 @@
-
+
-
许可协议
+
许可协议
本项目采用区分用户的双重许可 (User-Segmented Dual Licensing) 模式。
- 核心原则
-
+ 核心原则
+
个人用户 和 10人及以下企业/组织: 默认适用
GNU Affero 通用公共许可证 v3.0 (AGPLv3) 。
@@ -32,7 +32,7 @@
- 定义:"10人及以下"
+ 定义:"10人及以下"
指在您的组织(包括公司、非营利组织、政府机构、教育机构等任何实体)中,能够访问、使用或以任何方式直接或间接受益于本软件(Cherry
Studio)功能的个人总数不超过10人。这包括但不限于开发者、测试人员、运营人员、最终用户、通过集成系统间接使用者等。
@@ -40,10 +40,10 @@
-
+
1. 开源许可证 (Open Source License): AGPLv3 - 适用于个人及10人及以下组织
-
+
如果您是个人用户,或者您的组织满足上述"10人及以下"的定义,您可以在
AGPLv3 的条款下自由使用、修改和分发 Cherry Studio。AGPLv3 的完整文本可以访问
@@ -62,10 +62,10 @@
-
+
2. 商业许可证 (Commercial License) - 适用于超过10人的组织,或希望规避 AGPLv3 义务的用户
-
+
强制要求:
如果您的组织不 满足上述"10人及以下"的定义(即有11人或更多人可以访问、使用或受益于本软件),您必须 联系我们获取并签署一份商业许可证才能使用
@@ -80,7 +80,7 @@
需要商业许可证的常见情况包括(但不限于):
-
+
您的组织规模超过10人。
(无论组织规模)您希望分发修改过的 Cherry Studio 版本,但不希望 根据 AGPLv3
@@ -104,8 +104,8 @@
- 3. 贡献 (Contributions)
-
+ 3. 贡献 (Contributions)
+
我们欢迎社区对 Cherry Studio 的贡献。所有向本项目提交的贡献都将被视为在
AGPLv3 许可证下提供。
@@ -119,8 +119,8 @@
- 4. 其他条款 (Other Terms)
-
+ 4. 其他条款 (Other Terms)
+
关于商业许可证的具体条款和条件,以双方签署的正式商业许可协议为准。
项目维护者保留根据需要更新本许可政策(包括用户规模定义和阈值)的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。
@@ -133,13 +133,13 @@
-
Licensing
+
Licensing
This project employs a User-Segmented Dual Licensing model.
- Core Principle
-
+ Core Principle
+
Individual Users and Organizations with 10 or Fewer Individuals: Governed by default
under the GNU Affero General Public License v3.0 (AGPLv3) .
@@ -152,7 +152,7 @@
- Definition: "10 or Fewer Individuals"
+ Definition: "10 or Fewer Individuals"
Refers to any organization (including companies, non-profits, government agencies, educational institutions,
etc.) where the total number of individuals who can access, use, or in any way directly or indirectly
@@ -162,10 +162,10 @@
-
+
1. Open Source License: AGPLv3 - For Individuals and Organizations of 10 or Fewer
-
+
If you are an individual user, or if your organization meets the "10 or Fewer Individuals" definition
above, you are free to use, modify, and distribute Cherry Studio under the terms of the
@@ -186,11 +186,11 @@
-
+
2. Commercial License - For Organizations with More Than 10 Individuals, or Users Needing to Avoid AGPLv3
Obligations
-
+
Mandatory Requirement: If your organization does not meet the "10 or
Fewer Individuals" definition above (i.e., 11 or more individuals can access, use, or benefit from the
@@ -207,7 +207,7 @@
Common scenarios requiring a Commercial License include (but are not limited to):
-
+
Your organization has more than 10 individuals who can access, use, or benefit from the software.
@@ -236,8 +236,8 @@
- 3. Contributions
-
+ 3. Contributions
+
We welcome community contributions to Cherry Studio. All contributions submitted to this project are
considered to be offered under the AGPLv3 license.
@@ -255,8 +255,8 @@
- 4. Other Terms
-
+ 4. Other Terms
+
The specific terms and conditions of the Commercial License are governed by the formal commercial license
agreement signed by both parties.
diff --git a/resources/cherry-studio/releases.html b/resources/cherry-studio/releases.html
index 5515645829..13edca06f9 100644
--- a/resources/cherry-studio/releases.html
+++ b/resources/cherry-studio/releases.html
@@ -12,18 +12,18 @@
-
-
Release Timeline
+
+
Release Timeline
-
+
-
{{ error }}
+
{{ error }}
@@ -32,21 +32,21 @@
:key="release.id"
class="relative pl-8"
:class="isDark ? 'border-l-2 border-gray-700' : 'border-l-2 border-gray-200'">
-
+
-
+
{{ release.name || release.tag_name }}
-
+
{{ formatDate(release.published_at) }}
{{ release.tag_name }}
diff --git a/scripts/update-languages.ts b/scripts/update-languages.ts
index 91416a9732..58640637b1 100644
--- a/scripts/update-languages.ts
+++ b/scripts/update-languages.ts
@@ -1,6 +1,6 @@
import { exec } from 'child_process'
import * as fs from 'fs/promises'
-import linguistLanguages from 'linguist-languages'
+import * as linguistLanguages from 'linguist-languages'
import * as path from 'path'
import { promisify } from 'util'
diff --git a/src/main/apiServer/app.ts b/src/main/apiServer/app.ts
new file mode 100644
index 0000000000..46da10f876
--- /dev/null
+++ b/src/main/apiServer/app.ts
@@ -0,0 +1,128 @@
+import { loggerService } from '@main/services/LoggerService'
+import cors from 'cors'
+import express from 'express'
+import { v4 as uuidv4 } from 'uuid'
+
+import { authMiddleware } from './middleware/auth'
+import { errorHandler } from './middleware/error'
+import { setupOpenAPIDocumentation } from './middleware/openapi'
+import { chatRoutes } from './routes/chat'
+import { mcpRoutes } from './routes/mcp'
+import { modelsRoutes } from './routes/models'
+
+const logger = loggerService.withContext('ApiServer')
+
+const app = express()
+
+// Global middleware
+app.use((req, res, next) => {
+ const start = Date.now()
+ res.on('finish', () => {
+ const duration = Date.now() - start
+ logger.info(`${req.method} ${req.path} - ${res.statusCode} - ${duration}ms`)
+ })
+ next()
+})
+
+app.use((_req, res, next) => {
+ res.setHeader('X-Request-ID', uuidv4())
+ next()
+})
+
+app.use(
+ cors({
+ origin: '*',
+ allowedHeaders: ['Content-Type', 'Authorization'],
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
+ })
+)
+
+/**
+ * @swagger
+ * /health:
+ * get:
+ * summary: Health check endpoint
+ * description: Check server status (no authentication required)
+ * tags: [Health]
+ * security: []
+ * responses:
+ * 200:
+ * description: Server is healthy
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * status:
+ * type: string
+ * example: ok
+ * timestamp:
+ * type: string
+ * format: date-time
+ * version:
+ * type: string
+ * example: 1.0.0
+ */
+app.get('/health', (_req, res) => {
+ res.json({
+ status: 'ok',
+ timestamp: new Date().toISOString(),
+ version: process.env.npm_package_version || '1.0.0'
+ })
+})
+
+/**
+ * @swagger
+ * /:
+ * get:
+ * summary: API information
+ * description: Get basic API information and available endpoints
+ * tags: [General]
+ * security: []
+ * responses:
+ * 200:
+ * description: API information
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * name:
+ * type: string
+ * example: Cherry Studio API
+ * version:
+ * type: string
+ * example: 1.0.0
+ * endpoints:
+ * type: object
+ */
+app.get('/', (_req, res) => {
+ res.json({
+ name: 'Cherry Studio API',
+ version: '1.0.0',
+ endpoints: {
+ health: 'GET /health',
+ models: 'GET /v1/models',
+ chat: 'POST /v1/chat/completions',
+ mcp: 'GET /v1/mcps'
+ }
+ })
+})
+
+// API v1 routes with auth
+const apiRouter = express.Router()
+apiRouter.use(authMiddleware)
+apiRouter.use(express.json())
+// Mount routes
+apiRouter.use('/chat', chatRoutes)
+apiRouter.use('/mcps', mcpRoutes)
+apiRouter.use('/models', modelsRoutes)
+app.use('/v1', apiRouter)
+
+// Setup OpenAPI documentation
+setupOpenAPIDocumentation(app)
+
+// Error handling (must be last)
+app.use(errorHandler)
+
+export { app }
diff --git a/src/main/apiServer/config.ts b/src/main/apiServer/config.ts
new file mode 100644
index 0000000000..8bc4922968
--- /dev/null
+++ b/src/main/apiServer/config.ts
@@ -0,0 +1,65 @@
+import { ApiServerConfig } from '@types'
+import { v4 as uuidv4 } from 'uuid'
+
+import { loggerService } from '../services/LoggerService'
+import { reduxService } from '../services/ReduxService'
+
+const logger = loggerService.withContext('ApiServerConfig')
+
+const defaultHost = 'localhost'
+const defaultPort = 23333
+
+class ConfigManager {
+ private _config: ApiServerConfig | null = null
+
+ private generateApiKey(): string {
+ return `cs-sk-${uuidv4()}`
+ }
+
+ async load(): Promise
{
+ try {
+ const settings = await reduxService.select('state.settings')
+ const serverSettings = settings?.apiServer
+ let apiKey = serverSettings?.apiKey
+ if (!apiKey || apiKey.trim() === '') {
+ apiKey = this.generateApiKey()
+ await reduxService.dispatch({
+ type: 'settings/setApiServerApiKey',
+ payload: apiKey
+ })
+ }
+ this._config = {
+ enabled: serverSettings?.enabled ?? false,
+ port: serverSettings?.port ?? defaultPort,
+ host: defaultHost,
+ apiKey: apiKey
+ }
+ return this._config
+ } catch (error: any) {
+ logger.warn('Failed to load config from Redux, using defaults:', error)
+ this._config = {
+ enabled: false,
+ port: defaultPort,
+ host: defaultHost,
+ apiKey: this.generateApiKey()
+ }
+ return this._config
+ }
+ }
+
+ async get(): Promise {
+ if (!this._config) {
+ await this.load()
+ }
+ if (!this._config) {
+ throw new Error('Failed to load API server configuration')
+ }
+ return this._config
+ }
+
+ async reload(): Promise {
+ return await this.load()
+ }
+}
+
+export const config = new ConfigManager()
diff --git a/src/main/apiServer/index.ts b/src/main/apiServer/index.ts
new file mode 100644
index 0000000000..765ca05fba
--- /dev/null
+++ b/src/main/apiServer/index.ts
@@ -0,0 +1,2 @@
+export { config } from './config'
+export { apiServer } from './server'
diff --git a/src/main/apiServer/middleware/auth.ts b/src/main/apiServer/middleware/auth.ts
new file mode 100644
index 0000000000..02cf017247
--- /dev/null
+++ b/src/main/apiServer/middleware/auth.ts
@@ -0,0 +1,62 @@
+import crypto from 'crypto'
+import { NextFunction, Request, Response } from 'express'
+
+import { config } from '../config'
+
+export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
+ const auth = req.header('Authorization') || ''
+ const xApiKey = req.header('x-api-key') || ''
+
+ // Fast rejection if neither credential header provided
+ if (!auth && !xApiKey) {
+ return res.status(401).json({ error: 'Unauthorized: missing credentials' })
+ }
+
+ let token: string | undefined
+
+ // Prefer Bearer if well‑formed
+ if (auth) {
+ const trimmed = auth.trim()
+ const bearerPrefix = /^Bearer\s+/i
+ if (bearerPrefix.test(trimmed)) {
+ const candidate = trimmed.replace(bearerPrefix, '').trim()
+ if (!candidate) {
+ return res.status(401).json({ error: 'Unauthorized: empty bearer token' })
+ }
+ token = candidate
+ }
+ }
+
+ // Fallback to x-api-key if token still not resolved
+ if (!token && xApiKey) {
+ if (!xApiKey.trim()) {
+ return res.status(401).json({ error: 'Unauthorized: empty x-api-key' })
+ }
+ token = xApiKey.trim()
+ }
+
+ if (!token) {
+ // At this point we had at least one header, but none yielded a usable token
+ return res.status(401).json({ error: 'Unauthorized: invalid credentials format' })
+ }
+
+ const { apiKey } = await config.get()
+
+ if (!apiKey) {
+ // If server not configured, treat as forbidden (or could be 500). Choose 403 to avoid leaking config state.
+ return res.status(403).json({ error: 'Forbidden' })
+ }
+
+ // Timing-safe compare when lengths match, else immediate forbidden
+ if (token.length !== apiKey.length) {
+ return res.status(403).json({ error: 'Forbidden' })
+ }
+
+ const tokenBuf = Buffer.from(token)
+ const keyBuf = Buffer.from(apiKey)
+ if (!crypto.timingSafeEqual(tokenBuf, keyBuf)) {
+ return res.status(403).json({ error: 'Forbidden' })
+ }
+
+ return next()
+}
diff --git a/src/main/apiServer/middleware/error.ts b/src/main/apiServer/middleware/error.ts
new file mode 100644
index 0000000000..65eef5e43d
--- /dev/null
+++ b/src/main/apiServer/middleware/error.ts
@@ -0,0 +1,21 @@
+import { NextFunction, Request, Response } from 'express'
+
+import { loggerService } from '../../services/LoggerService'
+
+const logger = loggerService.withContext('ApiServerErrorHandler')
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
+ logger.error('API Server Error:', err)
+
+ // Don't expose internal errors in production
+ const isDev = process.env.NODE_ENV === 'development'
+
+ res.status(500).json({
+ error: {
+ message: isDev ? err.message : 'Internal server error',
+ type: 'server_error',
+ ...(isDev && { stack: err.stack })
+ }
+ })
+}
diff --git a/src/main/apiServer/middleware/openapi.ts b/src/main/apiServer/middleware/openapi.ts
new file mode 100644
index 0000000000..691bd8ec96
--- /dev/null
+++ b/src/main/apiServer/middleware/openapi.ts
@@ -0,0 +1,206 @@
+import { Express } from 'express'
+import swaggerJSDoc from 'swagger-jsdoc'
+import swaggerUi from 'swagger-ui-express'
+
+import { loggerService } from '../../services/LoggerService'
+
+const logger = loggerService.withContext('OpenAPIMiddleware')
+
+const swaggerOptions: swaggerJSDoc.Options = {
+ definition: {
+ openapi: '3.0.0',
+ info: {
+ title: 'Cherry Studio API',
+ version: '1.0.0',
+ description: 'OpenAI-compatible API for Cherry Studio with additional Cherry-specific endpoints',
+ contact: {
+ name: 'Cherry Studio',
+ url: 'https://github.com/CherryHQ/cherry-studio'
+ }
+ },
+ servers: [
+ {
+ url: 'http://localhost:23333',
+ description: 'Local development server'
+ }
+ ],
+ components: {
+ securitySchemes: {
+ BearerAuth: {
+ type: 'http',
+ scheme: 'bearer',
+ bearerFormat: 'JWT',
+ description: 'Use the API key from Cherry Studio settings'
+ }
+ },
+ schemas: {
+ Error: {
+ type: 'object',
+ properties: {
+ error: {
+ type: 'object',
+ properties: {
+ message: { type: 'string' },
+ type: { type: 'string' },
+ code: { type: 'string' }
+ }
+ }
+ }
+ },
+ ChatMessage: {
+ type: 'object',
+ properties: {
+ role: {
+ type: 'string',
+ enum: ['system', 'user', 'assistant', 'tool']
+ },
+ content: {
+ oneOf: [
+ { type: 'string' },
+ {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ type: { type: 'string' },
+ text: { type: 'string' },
+ image_url: {
+ type: 'object',
+ properties: {
+ url: { type: 'string' }
+ }
+ }
+ }
+ }
+ }
+ ]
+ },
+ name: { type: 'string' },
+ tool_calls: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string' },
+ type: { type: 'string' },
+ function: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ arguments: { type: 'string' }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ ChatCompletionRequest: {
+ type: 'object',
+ required: ['model', 'messages'],
+ properties: {
+ model: {
+ type: 'string',
+ description: 'The model to use for completion, in format provider:model-id'
+ },
+ messages: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/ChatMessage' }
+ },
+ temperature: {
+ type: 'number',
+ minimum: 0,
+ maximum: 2,
+ default: 1
+ },
+ max_tokens: {
+ type: 'integer',
+ minimum: 1
+ },
+ stream: {
+ type: 'boolean',
+ default: false
+ },
+ tools: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ type: { type: 'string' },
+ function: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ description: { type: 'string' },
+ parameters: { type: 'object' }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ Model: {
+ type: 'object',
+ properties: {
+ id: { type: 'string' },
+ object: { type: 'string', enum: ['model'] },
+ created: { type: 'integer' },
+ owned_by: { type: 'string' }
+ }
+ },
+ MCPServer: {
+ type: 'object',
+ properties: {
+ id: { type: 'string' },
+ name: { type: 'string' },
+ command: { type: 'string' },
+ args: {
+ type: 'array',
+ items: { type: 'string' }
+ },
+ env: { type: 'object' },
+ disabled: { type: 'boolean' }
+ }
+ }
+ }
+ },
+ security: [
+ {
+ BearerAuth: []
+ }
+ ]
+ },
+ apis: ['./src/main/apiServer/routes/*.ts', './src/main/apiServer/app.ts']
+}
+
+export function setupOpenAPIDocumentation(app: Express) {
+ try {
+ const specs = swaggerJSDoc(swaggerOptions)
+
+ // Serve OpenAPI JSON
+ app.get('/api-docs.json', (_req, res) => {
+ res.setHeader('Content-Type', 'application/json')
+ res.send(specs)
+ })
+
+ // Serve Swagger UI
+ app.use(
+ '/api-docs',
+ swaggerUi.serve,
+ swaggerUi.setup(specs, {
+ customCss: `
+ .swagger-ui .topbar { display: none; }
+ .swagger-ui .info .title { color: #1890ff; }
+ `,
+ customSiteTitle: 'Cherry Studio API Documentation'
+ })
+ )
+
+ logger.info('OpenAPI documentation setup complete')
+ logger.info('Documentation available at /api-docs')
+ logger.info('OpenAPI spec available at /api-docs.json')
+ } catch (error) {
+ logger.error('Failed to setup OpenAPI documentation:', error as Error)
+ }
+}
diff --git a/src/main/apiServer/routes/chat.ts b/src/main/apiServer/routes/chat.ts
new file mode 100644
index 0000000000..be43d866a4
--- /dev/null
+++ b/src/main/apiServer/routes/chat.ts
@@ -0,0 +1,225 @@
+import express, { Request, Response } from 'express'
+import OpenAI from 'openai'
+import { ChatCompletionCreateParams } from 'openai/resources'
+
+import { loggerService } from '../../services/LoggerService'
+import { chatCompletionService } from '../services/chat-completion'
+import { validateModelId } from '../utils'
+
+const logger = loggerService.withContext('ApiServerChatRoutes')
+
+const router = express.Router()
+
+/**
+ * @swagger
+ * /v1/chat/completions:
+ * post:
+ * summary: Create chat completion
+ * description: Create a chat completion response, compatible with OpenAI API
+ * tags: [Chat]
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/ChatCompletionRequest'
+ * responses:
+ * 200:
+ * description: Chat completion response
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * object:
+ * type: string
+ * example: chat.completion
+ * created:
+ * type: integer
+ * model:
+ * type: string
+ * choices:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * index:
+ * type: integer
+ * message:
+ * $ref: '#/components/schemas/ChatMessage'
+ * finish_reason:
+ * type: string
+ * usage:
+ * type: object
+ * properties:
+ * prompt_tokens:
+ * type: integer
+ * completion_tokens:
+ * type: integer
+ * total_tokens:
+ * type: integer
+ * text/plain:
+ * schema:
+ * type: string
+ * description: Server-sent events stream (when stream=true)
+ * 400:
+ * description: Bad request
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 401:
+ * description: Unauthorized
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 429:
+ * description: Rate limit exceeded
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+router.post('/completions', async (req: Request, res: Response) => {
+ try {
+ const request: ChatCompletionCreateParams = req.body
+
+ if (!request) {
+ return res.status(400).json({
+ error: {
+ message: 'Request body is required',
+ type: 'invalid_request_error',
+ code: 'missing_body'
+ }
+ })
+ }
+
+ logger.info('Chat completion request:', {
+ model: request.model,
+ messageCount: request.messages?.length || 0,
+ stream: request.stream,
+ temperature: request.temperature
+ })
+
+ // Validate request
+ const validation = chatCompletionService.validateRequest(request)
+ if (!validation.isValid) {
+ return res.status(400).json({
+ error: {
+ message: validation.errors.join('; '),
+ type: 'invalid_request_error',
+ code: 'validation_failed'
+ }
+ })
+ }
+
+ // Validate model ID and get provider
+ const modelValidation = await validateModelId(request.model)
+ if (!modelValidation.valid) {
+ const error = modelValidation.error!
+ logger.warn(`Model validation failed for '${request.model}':`, error)
+ return res.status(400).json({
+ error: {
+ message: error.message,
+ type: 'invalid_request_error',
+ code: error.code
+ }
+ })
+ }
+
+ const provider = modelValidation.provider!
+ const modelId = modelValidation.modelId!
+
+ logger.info('Model validation successful:', {
+ provider: provider.id,
+ providerType: provider.type,
+ modelId: modelId,
+ fullModelId: request.model
+ })
+
+ // Create OpenAI client
+ const client = new OpenAI({
+ baseURL: provider.apiHost,
+ apiKey: provider.apiKey
+ })
+ request.model = modelId
+
+ // Handle streaming
+ if (request.stream) {
+ const streamResponse = await client.chat.completions.create(request)
+
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
+ res.setHeader('Cache-Control', 'no-cache')
+ res.setHeader('Connection', 'keep-alive')
+
+ try {
+ for await (const chunk of streamResponse as any) {
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`)
+ }
+ res.write('data: [DONE]\n\n')
+ res.end()
+ } catch (streamError: any) {
+ logger.error('Stream error:', streamError)
+ res.write(
+ `data: ${JSON.stringify({
+ error: {
+ message: 'Stream processing error',
+ type: 'server_error',
+ code: 'stream_error'
+ }
+ })}\n\n`
+ )
+ res.end()
+ }
+ return
+ }
+
+ // Handle non-streaming
+ const response = await client.chat.completions.create(request)
+ return res.json(response)
+ } catch (error: any) {
+ logger.error('Chat completion error:', error)
+
+ let statusCode = 500
+ let errorType = 'server_error'
+ let errorCode = 'internal_error'
+ let errorMessage = 'Internal server error'
+
+ if (error instanceof Error) {
+ errorMessage = error.message
+
+ if (error.message.includes('API key') || error.message.includes('authentication')) {
+ statusCode = 401
+ errorType = 'authentication_error'
+ errorCode = 'invalid_api_key'
+ } else if (error.message.includes('rate limit') || error.message.includes('quota')) {
+ statusCode = 429
+ errorType = 'rate_limit_error'
+ errorCode = 'rate_limit_exceeded'
+ } else if (error.message.includes('timeout') || error.message.includes('connection')) {
+ statusCode = 502
+ errorType = 'server_error'
+ errorCode = 'upstream_error'
+ }
+ }
+
+ return res.status(statusCode).json({
+ error: {
+ message: errorMessage,
+ type: errorType,
+ code: errorCode
+ }
+ })
+ }
+})
+
+export { router as chatRoutes }
diff --git a/src/main/apiServer/routes/mcp.ts b/src/main/apiServer/routes/mcp.ts
new file mode 100644
index 0000000000..1e154ee583
--- /dev/null
+++ b/src/main/apiServer/routes/mcp.ts
@@ -0,0 +1,153 @@
+import express, { Request, Response } from 'express'
+
+import { loggerService } from '../../services/LoggerService'
+import { mcpApiService } from '../services/mcp'
+
+const logger = loggerService.withContext('ApiServerMCPRoutes')
+
+const router = express.Router()
+
+/**
+ * @swagger
+ * /v1/mcps:
+ * get:
+ * summary: List MCP servers
+ * description: Get a list of all configured Model Context Protocol servers
+ * tags: [MCP]
+ * responses:
+ * 200:
+ * description: List of MCP servers
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * data:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/MCPServer'
+ * 503:
+ * description: Service unavailable
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * example: false
+ * error:
+ * $ref: '#/components/schemas/Error'
+ */
+router.get('/', async (req: Request, res: Response) => {
+ try {
+ logger.info('Get all MCP servers request received')
+ const servers = await mcpApiService.getAllServers(req)
+ return res.json({
+ success: true,
+ data: servers
+ })
+ } catch (error: any) {
+ logger.error('Error fetching MCP servers:', error)
+ return res.status(503).json({
+ success: false,
+ error: {
+ message: `Failed to retrieve MCP servers: ${error.message}`,
+ type: 'service_unavailable',
+ code: 'servers_unavailable'
+ }
+ })
+ }
+})
+
+/**
+ * @swagger
+ * /v1/mcps/{server_id}:
+ * get:
+ * summary: Get MCP server info
+ * description: Get detailed information about a specific MCP server
+ * tags: [MCP]
+ * parameters:
+ * - in: path
+ * name: server_id
+ * required: true
+ * schema:
+ * type: string
+ * description: MCP server ID
+ * responses:
+ * 200:
+ * description: MCP server information
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * data:
+ * $ref: '#/components/schemas/MCPServer'
+ * 404:
+ * description: MCP server not found
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * example: false
+ * error:
+ * $ref: '#/components/schemas/Error'
+ */
+router.get('/:server_id', async (req: Request, res: Response) => {
+ try {
+ logger.info('Get MCP server info request received')
+ const server = await mcpApiService.getServerInfo(req.params.server_id)
+ if (!server) {
+ logger.warn('MCP server not found')
+ return res.status(404).json({
+ success: false,
+ error: {
+ message: 'MCP server not found',
+ type: 'not_found',
+ code: 'server_not_found'
+ }
+ })
+ }
+ return res.json({
+ success: true,
+ data: server
+ })
+ } catch (error: any) {
+ logger.error('Error fetching MCP server info:', error)
+ return res.status(503).json({
+ success: false,
+ error: {
+ message: `Failed to retrieve MCP server info: ${error.message}`,
+ type: 'service_unavailable',
+ code: 'server_info_unavailable'
+ }
+ })
+ }
+})
+
+// Connect to MCP server
+router.all('/:server_id/mcp', async (req: Request, res: Response) => {
+ const server = await mcpApiService.getServerById(req.params.server_id)
+ if (!server) {
+ logger.warn('MCP server not found')
+ return res.status(404).json({
+ success: false,
+ error: {
+ message: 'MCP server not found',
+ type: 'not_found',
+ code: 'server_not_found'
+ }
+ })
+ }
+ return await mcpApiService.handleRequest(req, res, server)
+})
+
+export { router as mcpRoutes }
diff --git a/src/main/apiServer/routes/models.ts b/src/main/apiServer/routes/models.ts
new file mode 100644
index 0000000000..9f4d2f13c9
--- /dev/null
+++ b/src/main/apiServer/routes/models.ts
@@ -0,0 +1,73 @@
+import express, { Request, Response } from 'express'
+
+import { loggerService } from '../../services/LoggerService'
+import { chatCompletionService } from '../services/chat-completion'
+
+const logger = loggerService.withContext('ApiServerModelsRoutes')
+
+const router = express.Router()
+
+/**
+ * @swagger
+ * /v1/models:
+ * get:
+ * summary: List available models
+ * description: Returns a list of available AI models from all configured providers
+ * tags: [Models]
+ * responses:
+ * 200:
+ * description: List of available models
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * object:
+ * type: string
+ * example: list
+ * data:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/Model'
+ * 503:
+ * description: Service unavailable
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+router.get('/', async (_req: Request, res: Response) => {
+ try {
+ logger.info('Models list request received')
+
+ const models = await chatCompletionService.getModels()
+
+ if (models.length === 0) {
+ logger.warn(
+ 'No models available from providers. This may be because no OpenAI providers are configured or enabled.'
+ )
+ }
+
+ logger.info(`Returning ${models.length} models (OpenAI providers only)`)
+ logger.debug(
+ 'Model IDs:',
+ models.map((m) => m.id)
+ )
+
+ return res.json({
+ object: 'list',
+ data: models
+ })
+ } catch (error: any) {
+ logger.error('Error fetching models:', error)
+ return res.status(503).json({
+ error: {
+ message: 'Failed to retrieve models from available providers',
+ type: 'service_unavailable',
+ code: 'models_unavailable'
+ }
+ })
+ }
+})
+
+export { router as modelsRoutes }
diff --git a/src/main/apiServer/server.ts b/src/main/apiServer/server.ts
new file mode 100644
index 0000000000..2555fa8c2e
--- /dev/null
+++ b/src/main/apiServer/server.ts
@@ -0,0 +1,65 @@
+import { createServer } from 'node:http'
+
+import { loggerService } from '../services/LoggerService'
+import { app } from './app'
+import { config } from './config'
+
+const logger = loggerService.withContext('ApiServer')
+
+export class ApiServer {
+ private server: ReturnType | null = null
+
+ async start(): Promise {
+ if (this.server) {
+ logger.warn('Server already running')
+ return
+ }
+
+ // Load config
+ const { port, host, apiKey } = await config.load()
+
+ // Create server with Express app
+ this.server = createServer(app)
+
+ // Start server
+ return new Promise((resolve, reject) => {
+ this.server!.listen(port, host, () => {
+ logger.info(`API Server started at http://${host}:${port}`)
+ logger.info(`API Key: ${apiKey}`)
+ resolve()
+ })
+
+ this.server!.on('error', reject)
+ })
+ }
+
+ async stop(): Promise {
+ if (!this.server) return
+
+ return new Promise((resolve) => {
+ this.server!.close(() => {
+ logger.info('API Server stopped')
+ this.server = null
+ resolve()
+ })
+ })
+ }
+
+ async restart(): Promise {
+ await this.stop()
+ await config.reload()
+ await this.start()
+ }
+
+ isRunning(): boolean {
+ const hasServer = this.server !== null
+ const isListening = this.server?.listening || false
+ const result = hasServer && isListening
+
+ logger.debug('isRunning check:', { hasServer, isListening, result })
+
+ return result
+ }
+}
+
+export const apiServer = new ApiServer()
diff --git a/src/main/apiServer/services/chat-completion.ts b/src/main/apiServer/services/chat-completion.ts
new file mode 100644
index 0000000000..7df6226706
--- /dev/null
+++ b/src/main/apiServer/services/chat-completion.ts
@@ -0,0 +1,239 @@
+import OpenAI from 'openai'
+import { ChatCompletionCreateParams } from 'openai/resources'
+
+import { loggerService } from '../../services/LoggerService'
+import {
+ getProviderByModel,
+ getRealProviderModel,
+ listAllAvailableModels,
+ OpenAICompatibleModel,
+ transformModelToOpenAI,
+ validateProvider
+} from '../utils'
+
+const logger = loggerService.withContext('ChatCompletionService')
+
+export interface ModelData extends OpenAICompatibleModel {
+ provider_id: string
+ model_id: string
+ name: string
+}
+
+export interface ValidationResult {
+ isValid: boolean
+ errors: string[]
+}
+
+export class ChatCompletionService {
+ async getModels(): Promise {
+ try {
+ logger.info('Getting available models from providers')
+
+ const models = await listAllAvailableModels()
+
+ // Use Map to deduplicate models by their full ID (provider:model_id)
+ const uniqueModels = new Map()
+
+ for (const model of models) {
+ const openAIModel = transformModelToOpenAI(model)
+ const fullModelId = openAIModel.id // This is already in format "provider:model_id"
+
+ // Only add if not already present (first occurrence wins)
+ if (!uniqueModels.has(fullModelId)) {
+ uniqueModels.set(fullModelId, {
+ ...openAIModel,
+ provider_id: model.provider,
+ model_id: model.id,
+ name: model.name
+ })
+ } else {
+ logger.debug(`Skipping duplicate model: ${fullModelId}`)
+ }
+ }
+
+ const modelData = Array.from(uniqueModels.values())
+
+ logger.info(`Successfully retrieved ${modelData.length} unique models from ${models.length} total models`)
+
+ if (models.length > modelData.length) {
+ logger.debug(`Filtered out ${models.length - modelData.length} duplicate models`)
+ }
+
+ return modelData
+ } catch (error: any) {
+ logger.error('Error getting models:', error)
+ return []
+ }
+ }
+
+ validateRequest(request: ChatCompletionCreateParams): ValidationResult {
+ const errors: string[] = []
+
+ // Validate model
+ if (!request.model) {
+ errors.push('Model is required')
+ } else if (typeof request.model !== 'string') {
+ errors.push('Model must be a string')
+ } else if (!request.model.includes(':')) {
+ errors.push('Model must be in format "provider:model_id"')
+ }
+
+ // Validate messages
+ if (!request.messages) {
+ errors.push('Messages array is required')
+ } else if (!Array.isArray(request.messages)) {
+ errors.push('Messages must be an array')
+ } else if (request.messages.length === 0) {
+ errors.push('Messages array cannot be empty')
+ } else {
+ // Validate each message
+ request.messages.forEach((message, index) => {
+ if (!message.role) {
+ errors.push(`Message ${index}: role is required`)
+ }
+ if (!message.content) {
+ errors.push(`Message ${index}: content is required`)
+ }
+ })
+ }
+
+ // Validate optional parameters
+ if (request.temperature !== undefined) {
+ if (typeof request.temperature !== 'number' || request.temperature < 0 || request.temperature > 2) {
+ errors.push('Temperature must be a number between 0 and 2')
+ }
+ }
+
+ if (request.max_tokens !== undefined) {
+ if (typeof request.max_tokens !== 'number' || request.max_tokens < 1) {
+ errors.push('max_tokens must be a positive number')
+ }
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors
+ }
+ }
+
+ async processCompletion(request: ChatCompletionCreateParams): Promise {
+ try {
+ logger.info('Processing chat completion request:', {
+ model: request.model,
+ messageCount: request.messages.length,
+ stream: request.stream
+ })
+
+ // Validate request
+ const validation = this.validateRequest(request)
+ if (!validation.isValid) {
+ throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
+ }
+
+ // Get provider for the model
+ const provider = await getProviderByModel(request.model!)
+ if (!provider) {
+ throw new Error(`Provider not found for model: ${request.model}`)
+ }
+
+ // Validate provider
+ if (!validateProvider(provider)) {
+ throw new Error(`Provider validation failed for: ${provider.id}`)
+ }
+
+ // Extract model ID from the full model string
+ const modelId = getRealProviderModel(request.model)
+
+ // Create OpenAI client for the provider
+ const client = new OpenAI({
+ baseURL: provider.apiHost,
+ apiKey: provider.apiKey
+ })
+
+ // Prepare request with the actual model ID
+ const providerRequest = {
+ ...request,
+ model: modelId,
+ stream: false
+ }
+
+ logger.debug('Sending request to provider:', {
+ provider: provider.id,
+ model: modelId,
+ apiHost: provider.apiHost
+ })
+
+ const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion
+
+ logger.info('Successfully processed chat completion')
+ return response
+ } catch (error: any) {
+ logger.error('Error processing chat completion:', error)
+ throw error
+ }
+ }
+
+ async *processStreamingCompletion(
+ request: ChatCompletionCreateParams
+ ): AsyncIterable {
+ try {
+ logger.info('Processing streaming chat completion request:', {
+ model: request.model,
+ messageCount: request.messages.length
+ })
+
+ // Validate request
+ const validation = this.validateRequest(request)
+ if (!validation.isValid) {
+ throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
+ }
+
+ // Get provider for the model
+ const provider = await getProviderByModel(request.model!)
+ if (!provider) {
+ throw new Error(`Provider not found for model: ${request.model}`)
+ }
+
+ // Validate provider
+ if (!validateProvider(provider)) {
+ throw new Error(`Provider validation failed for: ${provider.id}`)
+ }
+
+ // Extract model ID from the full model string
+ const modelId = getRealProviderModel(request.model)
+
+ // Create OpenAI client for the provider
+ const client = new OpenAI({
+ baseURL: provider.apiHost,
+ apiKey: provider.apiKey
+ })
+
+ // Prepare streaming request
+ const streamingRequest = {
+ ...request,
+ model: modelId,
+ stream: true as const
+ }
+
+ logger.debug('Sending streaming request to provider:', {
+ provider: provider.id,
+ model: modelId,
+ apiHost: provider.apiHost
+ })
+
+ const stream = await client.chat.completions.create(streamingRequest)
+
+ for await (const chunk of stream) {
+ yield chunk
+ }
+
+ logger.info('Successfully completed streaming chat completion')
+ } catch (error: any) {
+ logger.error('Error processing streaming chat completion:', error)
+ throw error
+ }
+ }
+}
+
+// Export singleton instance
+export const chatCompletionService = new ChatCompletionService()
diff --git a/src/main/apiServer/services/mcp.ts b/src/main/apiServer/services/mcp.ts
new file mode 100644
index 0000000000..99f1732114
--- /dev/null
+++ b/src/main/apiServer/services/mcp.ts
@@ -0,0 +1,251 @@
+import mcpService from '@main/services/MCPService'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp'
+import {
+ isJSONRPCRequest,
+ JSONRPCMessage,
+ JSONRPCMessageSchema,
+ MessageExtraInfo
+} from '@modelcontextprotocol/sdk/types'
+import { MCPServer } from '@types'
+import { randomUUID } from 'crypto'
+import { EventEmitter } from 'events'
+import { Request, Response } from 'express'
+import { IncomingMessage, ServerResponse } from 'http'
+
+import { loggerService } from '../../services/LoggerService'
+import { reduxService } from '../../services/ReduxService'
+import { getMcpServerById } from '../utils/mcp'
+
+const logger = loggerService.withContext('MCPApiService')
+const transports: Record = {}
+
+interface McpServerDTO {
+ id: MCPServer['id']
+ name: MCPServer['name']
+ type: MCPServer['type']
+ description: MCPServer['description']
+ url: string
+}
+
+interface McpServersResp {
+ servers: Record
+}
+
+/**
+ * MCPApiService - API layer for MCP server management
+ *
+ * This service provides a REST API interface for MCP servers while integrating
+ * with the existing application architecture:
+ *
+ * 1. Uses ReduxService to access the renderer's Redux store directly
+ * 2. Syncs changes back to the renderer via Redux actions
+ * 3. Leverages existing MCPService for actual server connections
+ * 4. Provides session management for API clients
+ */
+class MCPApiService extends EventEmitter {
+ private transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID()
+ })
+
+ constructor() {
+ super()
+ this.initMcpServer()
+ logger.silly('MCPApiService initialized')
+ }
+
+ private initMcpServer() {
+ this.transport.onmessage = this.onMessage
+ }
+
+ /**
+ * Get servers directly from Redux store
+ */
+ private async getServersFromRedux(): Promise {
+ try {
+ logger.silly('Getting servers from Redux store')
+
+ // Try to get from cache first (faster)
+ const cachedServers = reduxService.selectSync('state.mcp.servers')
+ if (cachedServers && Array.isArray(cachedServers)) {
+ logger.silly(`Found ${cachedServers.length} servers in Redux cache`)
+ return cachedServers
+ }
+
+ // If cache is not available, get fresh data
+ const servers = await reduxService.select('state.mcp.servers')
+ logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`)
+ return servers || []
+ } catch (error: any) {
+ logger.error('Failed to get servers from Redux:', error)
+ return []
+ }
+ }
+
+ // get all activated servers
+ async getAllServers(req: Request): Promise {
+ try {
+ const servers = await this.getServersFromRedux()
+ logger.silly(`Returning ${servers.length} servers`)
+ const resp: McpServersResp = {
+ servers: {}
+ }
+ for (const server of servers) {
+ if (server.isActive) {
+ resp.servers[server.id] = {
+ id: server.id,
+ name: server.name,
+ type: 'streamableHttp',
+ description: server.description,
+ url: `${req.protocol}://${req.host}/v1/mcps/${server.id}/mcp`
+ }
+ }
+ }
+ return resp
+ } catch (error: any) {
+ logger.error('Failed to get all servers:', error)
+ throw new Error('Failed to retrieve servers')
+ }
+ }
+
+ // get server by id
+ async getServerById(id: string): Promise {
+ try {
+ logger.silly(`getServerById called with id: ${id}`)
+ const servers = await this.getServersFromRedux()
+ const server = servers.find((s) => s.id === id)
+ if (!server) {
+ logger.warn(`Server with id ${id} not found`)
+ return null
+ }
+ logger.silly(`Returning server with id ${id}`)
+ return server
+ } catch (error: any) {
+ logger.error(`Failed to get server with id ${id}:`, error)
+ throw new Error('Failed to retrieve server')
+ }
+ }
+
+ async getServerInfo(id: string): Promise {
+ try {
+ logger.silly(`getServerInfo called with id: ${id}`)
+ const server = await this.getServerById(id)
+ if (!server) {
+ logger.warn(`Server with id ${id} not found`)
+ return null
+ }
+ logger.silly(`Returning server info for id ${id}`)
+
+ const client = await mcpService.initClient(server)
+ const tools = await client.listTools()
+
+ logger.info(`Server with id ${id} info:`, { tools: JSON.stringify(tools) })
+
+ // const [version, tools, prompts, resources] = await Promise.all([
+ // () => {
+ // try {
+ // return client.getServerVersion()
+ // } catch (error) {
+ // logger.error(`Failed to get server version for id ${id}:`, { error: error })
+ // return '1.0.0'
+ // }
+ // },
+ // (() => {
+ // try {
+ // return client.listTools()
+ // } catch (error) {
+ // logger.error(`Failed to list tools for id ${id}:`, { error: error })
+ // return []
+ // }
+ // })(),
+ // (() => {
+ // try {
+ // return client.listPrompts()
+ // } catch (error) {
+ // logger.error(`Failed to list prompts for id ${id}:`, { error: error })
+ // return []
+ // }
+ // })(),
+ // (() => {
+ // try {
+ // return client.listResources()
+ // } catch (error) {
+ // logger.error(`Failed to list resources for id ${id}:`, { error: error })
+ // return []
+ // }
+ // })()
+ // ])
+
+ return {
+ id: server.id,
+ name: server.name,
+ type: server.type,
+ description: server.description,
+ tools
+ }
+ } catch (error: any) {
+ logger.error(`Failed to get server info with id ${id}:`, error)
+ throw new Error('Failed to retrieve server info')
+ }
+ }
+
+ async handleRequest(req: Request, res: Response, server: MCPServer) {
+ const sessionId = req.headers['mcp-session-id'] as string | undefined
+ logger.silly(`Handling request for server with sessionId ${sessionId}`)
+ let transport: StreamableHTTPServerTransport
+ if (sessionId && transports[sessionId]) {
+ transport = transports[sessionId]
+ } else {
+ transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ onsessioninitialized: (sessionId) => {
+ transports[sessionId] = transport
+ }
+ })
+
+ transport.onclose = () => {
+ logger.info(`Transport for sessionId ${sessionId} closed`)
+ if (transport.sessionId) {
+ delete transports[transport.sessionId]
+ }
+ }
+ const mcpServer = await getMcpServerById(server.id)
+ if (mcpServer) {
+ await mcpServer.connect(transport)
+ }
+ }
+ const jsonpayload = req.body
+ const messages: JSONRPCMessage[] = []
+
+ if (Array.isArray(jsonpayload)) {
+ for (const payload of jsonpayload) {
+ const message = JSONRPCMessageSchema.parse(payload)
+ messages.push(message)
+ }
+ } else {
+ const message = JSONRPCMessageSchema.parse(jsonpayload)
+ messages.push(message)
+ }
+
+ for (const message of messages) {
+ if (isJSONRPCRequest(message)) {
+ if (!message.params) {
+ message.params = {}
+ }
+ if (!message.params._meta) {
+ message.params._meta = {}
+ }
+ message.params._meta.serverId = server.id
+ }
+ }
+
+ logger.info(`Request body`, { rawBody: req.body, messages: JSON.stringify(messages) })
+ await transport.handleRequest(req as IncomingMessage, res as ServerResponse, messages)
+ }
+
+ private onMessage(message: JSONRPCMessage, extra?: MessageExtraInfo) {
+ logger.info(`Received message: ${JSON.stringify(message)}`, extra)
+ // Handle message here
+ }
+}
+
+export const mcpApiService = new MCPApiService()
diff --git a/src/main/apiServer/utils/index.ts b/src/main/apiServer/utils/index.ts
new file mode 100644
index 0000000000..9d3b81c328
--- /dev/null
+++ b/src/main/apiServer/utils/index.ts
@@ -0,0 +1,231 @@
+import { loggerService } from '@main/services/LoggerService'
+import { reduxService } from '@main/services/ReduxService'
+import { Model, Provider } from '@types'
+
+const logger = loggerService.withContext('ApiServerUtils')
+
+// OpenAI compatible model format
+export interface OpenAICompatibleModel {
+ id: string
+ object: 'model'
+ created: number
+ owned_by: string
+ provider?: string
+ provider_model_id?: string
+}
+
+export async function getAvailableProviders(): Promise {
+ try {
+ // Wait for store to be ready before accessing providers
+ const providers = await reduxService.select('state.llm.providers')
+ if (!providers || !Array.isArray(providers)) {
+ logger.warn('No providers found in Redux store, returning empty array')
+ return []
+ }
+
+ // Only support OpenAI type providers for API server
+ const openAIProviders = providers.filter((p: Provider) => p.enabled && p.type === 'openai')
+
+ logger.info(`Filtered to ${openAIProviders.length} OpenAI providers from ${providers.length} total providers`)
+
+ return openAIProviders
+ } catch (error: any) {
+ logger.error('Failed to get providers from Redux store:', error)
+ return []
+ }
+}
+
+export async function listAllAvailableModels(): Promise {
+ try {
+ const providers = await getAvailableProviders()
+ return providers.map((p: Provider) => p.models || []).flat()
+ } catch (error: any) {
+ logger.error('Failed to list available models:', error)
+ return []
+ }
+}
+
+export async function getProviderByModel(model: string): Promise {
+ try {
+ if (!model || typeof model !== 'string') {
+ logger.warn(`Invalid model parameter: ${model}`)
+ return undefined
+ }
+
+ // Validate model format first
+ if (!model.includes(':')) {
+ logger.warn(
+ `Invalid model format, must contain ':' separator. Expected format "provider:model_id", got: ${model}`
+ )
+ return undefined
+ }
+
+ const providers = await getAvailableProviders()
+ const modelInfo = model.split(':')
+
+ if (modelInfo.length < 2 || modelInfo[0].length === 0 || modelInfo[1].length === 0) {
+ logger.warn(`Invalid model format, expected "provider:model_id" with non-empty parts, got: ${model}`)
+ return undefined
+ }
+
+ const providerId = modelInfo[0]
+ const provider = providers.find((p: Provider) => p.id === providerId)
+
+ if (!provider) {
+ logger.warn(
+ `Provider '${providerId}' not found or not enabled. Available providers: ${providers.map((p) => p.id).join(', ')}`
+ )
+ return undefined
+ }
+
+ logger.debug(`Found provider '${providerId}' for model: ${model}`)
+ return provider
+ } catch (error: any) {
+ logger.error('Failed to get provider by model:', error)
+ return undefined
+ }
+}
+
+export function getRealProviderModel(modelStr: string): string {
+ return modelStr.split(':').slice(1).join(':')
+}
+
+export interface ModelValidationError {
+ type: 'invalid_format' | 'provider_not_found' | 'model_not_available' | 'unsupported_provider_type'
+ message: string
+ code: string
+}
+
+export async function validateModelId(
+ model: string
+): Promise<{ valid: boolean; error?: ModelValidationError; provider?: Provider; modelId?: string }> {
+ try {
+ if (!model || typeof model !== 'string') {
+ return {
+ valid: false,
+ error: {
+ type: 'invalid_format',
+ message: 'Model must be a non-empty string',
+ code: 'invalid_model_parameter'
+ }
+ }
+ }
+
+ if (!model.includes(':')) {
+ return {
+ valid: false,
+ error: {
+ type: 'invalid_format',
+ message: "Invalid model format. Expected format: 'provider:model_id' (e.g., 'my-openai:gpt-4')",
+ code: 'invalid_model_format'
+ }
+ }
+ }
+
+ const modelInfo = model.split(':')
+ if (modelInfo.length < 2 || modelInfo[0].length === 0 || modelInfo[1].length === 0) {
+ return {
+ valid: false,
+ error: {
+ type: 'invalid_format',
+ message: "Invalid model format. Both provider and model_id must be non-empty. Expected: 'provider:model_id'",
+ code: 'invalid_model_format'
+ }
+ }
+ }
+
+ const providerId = modelInfo[0]
+ const modelId = getRealProviderModel(model)
+ const provider = await getProviderByModel(model)
+
+ if (!provider) {
+ return {
+ valid: false,
+ error: {
+ type: 'provider_not_found',
+ message: `Provider '${providerId}' not found, not enabled, or not supported. Only OpenAI providers are currently supported.`,
+ code: 'provider_not_found'
+ }
+ }
+ }
+
+ // Check if model exists in provider
+ const modelExists = provider.models?.some((m) => m.id === modelId)
+ if (!modelExists) {
+ const availableModels = provider.models?.map((m) => m.id).join(', ') || 'none'
+ return {
+ valid: false,
+ error: {
+ type: 'model_not_available',
+ message: `Model '${modelId}' not available in provider '${providerId}'. Available models: ${availableModels}`,
+ code: 'model_not_available'
+ }
+ }
+ }
+
+ return {
+ valid: true,
+ provider,
+ modelId
+ }
+ } catch (error: any) {
+ logger.error('Error validating model ID:', error)
+ return {
+ valid: false,
+ error: {
+ type: 'invalid_format',
+ message: 'Failed to validate model ID',
+ code: 'validation_error'
+ }
+ }
+ }
+}
+
+export function transformModelToOpenAI(model: Model): OpenAICompatibleModel {
+ return {
+ id: `${model.provider}:${model.id}`,
+ object: 'model',
+ created: Math.floor(Date.now() / 1000),
+ owned_by: model.owned_by || model.provider,
+ provider: model.provider,
+ provider_model_id: model.id
+ }
+}
+
+export function validateProvider(provider: Provider): boolean {
+ try {
+ if (!provider) {
+ return false
+ }
+
+ // Check required fields
+ if (!provider.id || !provider.type || !provider.apiKey || !provider.apiHost) {
+ logger.warn('Provider missing required fields:', {
+ id: !!provider.id,
+ type: !!provider.type,
+ apiKey: !!provider.apiKey,
+ apiHost: !!provider.apiHost
+ })
+ return false
+ }
+
+ // Check if provider is enabled
+ if (!provider.enabled) {
+ logger.debug(`Provider is disabled: ${provider.id}`)
+ return false
+ }
+
+ // Only support OpenAI type providers
+ if (provider.type !== 'openai') {
+ logger.debug(
+ `Provider type '${provider.type}' not supported, only 'openai' type is currently supported: ${provider.id}`
+ )
+ return false
+ }
+
+ return true
+ } catch (error: any) {
+ logger.error('Error validating provider:', error)
+ return false
+ }
+}
diff --git a/src/main/apiServer/utils/mcp.ts b/src/main/apiServer/utils/mcp.ts
new file mode 100644
index 0000000000..1ebe06ba68
--- /dev/null
+++ b/src/main/apiServer/utils/mcp.ts
@@ -0,0 +1,76 @@
+import mcpService from '@main/services/MCPService'
+import { Server } from '@modelcontextprotocol/sdk/server/index.js'
+import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
+import { MCPServer } from '@types'
+
+import { loggerService } from '../../services/LoggerService'
+import { reduxService } from '../../services/ReduxService'
+
+const logger = loggerService.withContext('MCPApiService')
+
+const cachedServers: Record = {}
+
+async function handleListToolsRequest(request: any, extra: any): Promise {
+ logger.debug('Handling list tools request', { request: request, extra: extra })
+ const serverId: string = request.params._meta.serverId
+ const serverConfig = await getMcpServerConfigById(serverId)
+ if (!serverConfig) {
+ throw new Error(`Server not found: ${serverId}`)
+ }
+ const client = await mcpService.initClient(serverConfig)
+ return client.listTools()
+}
+
+async function handleCallToolRequest(request: any, extra: any): Promise {
+ logger.debug('Handling call tool request', { request: request, extra: extra })
+ const serverId: string = request.params._meta.serverId
+ const serverConfig = await getMcpServerConfigById(serverId)
+ if (!serverConfig) {
+ throw new Error(`Server not found: ${serverId}`)
+ }
+ const client = await mcpService.initClient(serverConfig)
+ return client.callTool(request.params)
+}
+
+async function getMcpServerConfigById(id: string): Promise {
+ const servers = await getServersFromRedux()
+ return servers.find((s) => s.id === id || s.name === id)
+}
+
+/**
+ * Get servers directly from Redux store
+ */
+async function getServersFromRedux(): Promise {
+ try {
+ const servers = await reduxService.select('state.mcp.servers')
+ logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`)
+ return servers || []
+ } catch (error: any) {
+ logger.error('Failed to get servers from Redux:', error)
+ return []
+ }
+}
+
+export async function getMcpServerById(id: string): Promise {
+ const server = cachedServers[id]
+ if (!server) {
+ const servers = await getServersFromRedux()
+ const mcpServer = servers.find((s) => s.id === id || s.name === id)
+ if (!mcpServer) {
+ throw new Error(`Server not found: ${id}`)
+ }
+
+ const createMcpServer = (name: string, version: string): Server => {
+ const server = new Server({ name: name, version }, { capabilities: { tools: {} } })
+ server.setRequestHandler(ListToolsRequestSchema, handleListToolsRequest)
+ server.setRequestHandler(CallToolRequestSchema, handleCallToolRequest)
+ return server
+ }
+
+ const newServer = createMcpServer(mcpServer.name, '0.1.0')
+ cachedServers[id] = newServer
+ return newServer
+ }
+ logger.silly('getMcpServer ', { server: server })
+ return server
+}
diff --git a/src/main/index.ts b/src/main/index.ts
index 9b0b5726a8..4fcdeed13e 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -31,6 +31,7 @@ import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { dataRefactorMigrateService } from './data/migrate/dataRefactor/DataRefactorMigrateService'
import process from 'node:process'
+import { apiServerService } from './services/ApiServerService'
const logger = loggerService.withContext('MainEntry')
@@ -219,6 +220,17 @@ if (!app.requestSingleInstanceLock()) {
//start selection assistant service
initSelectionService()
+
+ // Start API server if enabled
+ try {
+ const config = await apiServerService.getCurrentConfig()
+ logger.info('API server config:', config)
+ if (config.enabled) {
+ await apiServerService.start()
+ }
+ } catch (error: any) {
+ logger.error('Failed to check/start API server:', error)
+ }
})
registerProtocolClient(app)
@@ -264,6 +276,7 @@ if (!app.requestSingleInstanceLock()) {
// 简单的资源清理,不阻塞退出流程
try {
await mcpService.cleanup()
+ await apiServerService.stop()
} catch (error) {
logger.warn('Error cleaning up MCP service:', error as Error)
}
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index d9d3ca5f95..b03fc6e093 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -15,9 +15,12 @@ import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant'
import { UpgradeChannel } from '@shared/data/preferenceTypes'
import { IpcChannel } from '@shared/IpcChannel'
import { FileMetadata, Provider, Shortcut } from '@types'
+import checkDiskSpace from 'check-disk-space'
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
+import fontList from 'font-list'
import { Notification } from 'src/renderer/src/types/notification'
+import { apiServerService } from './services/ApiServerService'
import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
@@ -217,6 +220,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
return mainWindow.isFullScreen()
})
+ // Get System Fonts
+ ipcMain.handle(IpcChannel.App_GetSystemFonts, async () => {
+ try {
+ const fonts = await fontList.getFonts()
+ return fonts.map((font: string) => font.replace(/^"(.*)"$/, '$1')).filter((font: string) => font.length > 0)
+ } catch (error) {
+ logger.error('Failed to get system fonts:', error as Error)
+ return []
+ }
+ })
+
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
configManager.set(key, value, isNotify)
})
@@ -782,6 +796,23 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
addStreamMessage(spanId, modelName, context, msg)
)
+ ipcMain.handle(IpcChannel.App_GetDiskInfo, async (_, directoryPath: string) => {
+ try {
+ const diskSpace = await checkDiskSpace(directoryPath) // { free, size } in bytes
+ logger.debug('disk space', diskSpace)
+ const { free, size } = diskSpace
+ return {
+ free,
+ size
+ }
+ } catch (error) {
+ logger.error('check disk space error', error as Error)
+ return null
+ }
+ })
+ // API Server
+ apiServerService.registerIpcHandlers()
+
// Anthropic OAuth
ipcMain.handle(IpcChannel.Anthropic_StartOAuthFlow, () => anthropicService.startOAuthFlow())
ipcMain.handle(IpcChannel.Anthropic_CompleteOAuthWithCode, (_, code: string) =>
diff --git a/src/main/knowledge/langchain/embeddings/JinaEmbeddings.ts b/src/main/knowledge/langchain/embeddings/JinaEmbeddings.ts
index 0a6c5f1f84..f0380ff360 100644
--- a/src/main/knowledge/langchain/embeddings/JinaEmbeddings.ts
+++ b/src/main/knowledge/langchain/embeddings/JinaEmbeddings.ts
@@ -1,7 +1,7 @@
import { Embeddings, type EmbeddingsParams } from '@langchain/core/embeddings'
import { chunkArray } from '@langchain/core/utils/chunk_array'
import { getEnvironmentVariable } from '@langchain/core/utils/env'
-import z from 'zod/v4'
+import { z } from 'zod'
const jinaModelSchema = z.union([
z.literal('jina-clip-v2'),
diff --git a/src/main/mcpServers/dify-knowledge.ts b/src/main/mcpServers/dify-knowledge.ts
index 83a352fd4f..04f010ce16 100644
--- a/src/main/mcpServers/dify-knowledge.ts
+++ b/src/main/mcpServers/dify-knowledge.ts
@@ -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 * as z from 'zod/v4'
+import { z } from 'zod'
const logger = loggerService.withContext('DifyKnowledgeServer')
diff --git a/src/main/mcpServers/fetch.ts b/src/main/mcpServers/fetch.ts
index e55b114776..f170cc54c0 100644
--- a/src/main/mcpServers/fetch.ts
+++ b/src/main/mcpServers/fetch.ts
@@ -8,8 +8,8 @@ import TurndownService from 'turndown'
import { z } from 'zod'
export const RequestPayloadSchema = z.object({
- url: z.string().url(),
- headers: z.record(z.string()).optional()
+ url: z.url(),
+ headers: z.record(z.string(), z.string()).optional()
})
export type RequestPayload = z.infer
diff --git a/src/main/mcpServers/filesystem.ts b/src/main/mcpServers/filesystem.ts
index 3b3c5ed799..9ec5ced0b0 100644
--- a/src/main/mcpServers/filesystem.ts
+++ b/src/main/mcpServers/filesystem.ts
@@ -8,7 +8,7 @@ import fs from 'fs/promises'
import { minimatch } from 'minimatch'
import os from 'os'
import path from 'path'
-import * as z from 'zod/v4'
+import { z } from 'zod'
const logger = loggerService.withContext('MCP:FileSystemServer')
diff --git a/src/main/services/ApiServerService.ts b/src/main/services/ApiServerService.ts
new file mode 100644
index 0000000000..9a7bfad7e0
--- /dev/null
+++ b/src/main/services/ApiServerService.ts
@@ -0,0 +1,108 @@
+import { IpcChannel } from '@shared/IpcChannel'
+import { ApiServerConfig } from '@types'
+import { ipcMain } from 'electron'
+
+import { apiServer } from '../apiServer'
+import { config } from '../apiServer/config'
+import { loggerService } from './LoggerService'
+const logger = loggerService.withContext('ApiServerService')
+
+export class ApiServerService {
+ constructor() {
+ // Use the new clean implementation
+ }
+
+ async start(): Promise {
+ try {
+ await apiServer.start()
+ logger.info('API Server started successfully')
+ } catch (error: any) {
+ logger.error('Failed to start API Server:', error)
+ throw error
+ }
+ }
+
+ async stop(): Promise {
+ try {
+ await apiServer.stop()
+ logger.info('API Server stopped successfully')
+ } catch (error: any) {
+ logger.error('Failed to stop API Server:', error)
+ throw error
+ }
+ }
+
+ async restart(): Promise {
+ try {
+ await apiServer.restart()
+ logger.info('API Server restarted successfully')
+ } catch (error: any) {
+ logger.error('Failed to restart API Server:', error)
+ throw error
+ }
+ }
+
+ isRunning(): boolean {
+ return apiServer.isRunning()
+ }
+
+ async getCurrentConfig(): Promise {
+ return config.get()
+ }
+
+ registerIpcHandlers(): void {
+ // API Server
+ ipcMain.handle(IpcChannel.ApiServer_Start, async () => {
+ try {
+ await this.start()
+ return { success: true }
+ } catch (error: any) {
+ return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }
+ }
+ })
+
+ ipcMain.handle(IpcChannel.ApiServer_Stop, async () => {
+ try {
+ await this.stop()
+ return { success: true }
+ } catch (error: any) {
+ return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }
+ }
+ })
+
+ ipcMain.handle(IpcChannel.ApiServer_Restart, async () => {
+ try {
+ await this.restart()
+ return { success: true }
+ } catch (error: any) {
+ return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }
+ }
+ })
+
+ ipcMain.handle(IpcChannel.ApiServer_GetStatus, async () => {
+ try {
+ const config = await this.getCurrentConfig()
+ return {
+ running: this.isRunning(),
+ config
+ }
+ } catch (error: any) {
+ return {
+ running: this.isRunning(),
+ config: null
+ }
+ }
+ })
+
+ ipcMain.handle(IpcChannel.ApiServer_GetConfig, async () => {
+ try {
+ return this.getCurrentConfig()
+ } catch (error: any) {
+ return null
+ }
+ })
+ }
+}
+
+// Export singleton instance
+export const apiServerService = new ApiServerService()
diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts
index 66575870c7..12da4896be 100644
--- a/src/main/services/CodeToolsService.ts
+++ b/src/main/services/CodeToolsService.ts
@@ -332,14 +332,15 @@ class CodeToolsService {
// macOS - Use osascript to launch terminal and execute command directly, without showing startup command
const envPrefix = buildEnvPrefix(false)
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
+ // Combine directory change with the main command to ensure they execute in the same shell session
+ const fullCommand = `cd '${directory.replace(/'/g, "\\'")}' && clear && ${command}`
terminalCommand = 'osascript'
terminalArgs = [
'-e',
`tell application "Terminal"
- set newTab to do script "cd '${directory.replace(/'/g, "\\'")}' && clear"
+ do script "${fullCommand.replace(/"/g, '\\"')}"
activate
- do script "${command.replace(/"/g, '\\"')}" in newTab
end tell`
]
break
diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts
index ecb34500c5..710867da88 100644
--- a/src/main/services/MCPService.ts
+++ b/src/main/services/MCPService.ts
@@ -16,6 +16,7 @@ import {
type StreamableHTTPClientTransportOptions
} from '@modelcontextprotocol/sdk/client/streamableHttp'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
+import { McpError, type Tool as SDKTool } from '@modelcontextprotocol/sdk/types'
// Import notification schemas from MCP SDK
import {
CancelledNotificationSchema,
@@ -29,6 +30,7 @@ import {
import { nanoid } from '@reduxjs/toolkit'
import { MCPProgressEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
+import { defaultAppHeaders } from '@shared/utils'
import {
BuiltinMCPServerNames,
type GetResourceResponse,
@@ -94,7 +96,7 @@ function getServerLogger(server: MCPServer, extra?: Record) {
baseUrl: server?.baseUrl,
type: server?.type || (server?.command ? 'stdio' : server?.baseUrl ? 'http' : 'inmemory')
}
- return loggerService.withContext('MCPService', { ...base, ...(extra || {}) })
+ return loggerService.withContext('MCPService', { ...base, ...extra })
}
/**
@@ -193,11 +195,18 @@ class McpService {
return existingClient
}
} catch (error: any) {
- getServerLogger(server).error(`Error pinging server`, error as Error)
+ getServerLogger(server).error(`Error pinging server ${server.name}`, error as Error)
this.clients.delete(serverKey)
}
}
+ const prepareHeaders = () => {
+ return {
+ ...defaultAppHeaders(),
+ ...server.headers
+ }
+ }
+
// Create a promise for the initialization process
const initPromise = (async () => {
try {
@@ -235,8 +244,11 @@ class McpService {
} else if (server.baseUrl) {
if (server.type === 'streamableHttp') {
const options: StreamableHTTPClientTransportOptions = {
+ fetch: async (url, init) => {
+ return net.fetch(typeof url === 'string' ? url : url.toString(), init)
+ },
requestInit: {
- headers: server.headers || {}
+ headers: prepareHeaders()
},
authProvider
}
@@ -249,25 +261,11 @@ class McpService {
const options: SSEClientTransportOptions = {
eventSourceInit: {
fetch: async (url, init) => {
- const headers = { ...(server.headers || {}), ...(init?.headers || {}) }
-
- // Get tokens from authProvider to make sure using the latest tokens
- if (authProvider && typeof authProvider.tokens === 'function') {
- try {
- const tokens = await authProvider.tokens()
- if (tokens && tokens.access_token) {
- headers['Authorization'] = `Bearer ${tokens.access_token}`
- }
- } catch (error) {
- getServerLogger(server).error('Failed to fetch tokens:', error as Error)
- }
- }
-
- return net.fetch(typeof url === 'string' ? url : url.toString(), { ...init, headers })
+ return net.fetch(typeof url === 'string' ? url : url.toString(), init)
}
},
requestInit: {
- headers: server.headers || {}
+ headers: prepareHeaders()
},
authProvider
}
@@ -444,9 +442,9 @@ class McpService {
logger.debug(`Activated server: ${server.name}`)
return client
- } catch (error: any) {
- getServerLogger(server).error(`Error activating server`, error as Error)
- throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
+ } catch (error) {
+ getServerLogger(server).error(`Error activating server ${server.name}`, error as Error)
+ throw error
}
} finally {
// Clean up the pending promise when done
@@ -614,12 +612,11 @@ class McpService {
}
private async listToolsImpl(server: MCPServer): Promise {
- getServerLogger(server).debug(`Listing tools`)
const client = await this.initClient(server)
try {
const { tools } = await client.listTools()
const serverTools: MCPTool[] = []
- tools.map((tool: any) => {
+ tools.map((tool: SDKTool) => {
const serverTool: MCPTool = {
...tool,
id: buildFunctionCallToolName(server.name, tool.name),
@@ -628,11 +625,12 @@ class McpService {
type: 'mcp'
}
serverTools.push(serverTool)
+ getServerLogger(server).debug(`Listing tools`, { tool: serverTool })
})
return serverTools
- } catch (error: any) {
+ } catch (error: unknown) {
getServerLogger(server).error(`Failed to list tools`, error as Error)
- return []
+ throw error
}
}
@@ -739,9 +737,9 @@ class McpService {
serverId: server.id,
serverName: server.name
}))
- } catch (error: any) {
+ } catch (error: unknown) {
// -32601 is the code for the method not found
- if (error?.code !== -32601) {
+ if (error instanceof McpError && error.code !== -32601) {
getServerLogger(server).error(`Failed to list prompts`, error as Error)
}
return []
diff --git a/src/main/services/knowledge/KnowledgeService.ts b/src/main/services/knowledge/KnowledgeService.ts
index f34a2b31b6..199f597eed 100644
--- a/src/main/services/knowledge/KnowledgeService.ts
+++ b/src/main/services/knowledge/KnowledgeService.ts
@@ -115,7 +115,7 @@ class KnowledgeService {
const framework = knowledgeFrameworkFactory.getFramework(base)
await framework.initialize(base)
}
- public async reset(_: Electron.IpcMainInvokeEvent, { base }: { base: KnowledgeBaseParams }): Promise {
+ public async reset(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise {
const framework = knowledgeFrameworkFactory.getFramework(base)
await framework.reset(base)
}
diff --git a/src/main/services/knowledge/LangChainFramework.ts b/src/main/services/knowledge/LangChainFramework.ts
index b82242e102..a3c48e1e09 100644
--- a/src/main/services/knowledge/LangChainFramework.ts
+++ b/src/main/services/knowledge/LangChainFramework.ts
@@ -30,7 +30,7 @@ import {
KnowledgeBaseParams,
KnowledgeSearchResult
} from '@types'
-import { uuidv4 } from 'zod/v4'
+import { uuidv4 } from 'zod'
import { windowService } from '../WindowService'
import {
@@ -103,6 +103,8 @@ export class LangChainFramework implements IKnowledgeFramework {
if (fs.existsSync(dbPath)) {
fs.rmSync(dbPath, { recursive: true })
}
+ // 立即重建空索引,避免随后加载时报错
+ await this.createDatabase(base)
}
async delete(id: string): Promise {
diff --git a/src/main/services/remotefile/FileServiceManager.ts b/src/main/services/remotefile/FileServiceManager.ts
index 9cdf6f834c..f456ba285d 100644
--- a/src/main/services/remotefile/FileServiceManager.ts
+++ b/src/main/services/remotefile/FileServiceManager.ts
@@ -3,6 +3,7 @@ import { Provider } from '@types'
import { BaseFileService } from './BaseFileService'
import { GeminiService } from './GeminiService'
import { MistralService } from './MistralService'
+import { OpenaiService } from './OpenAIService'
export class FileServiceManager {
private static instance: FileServiceManager
@@ -30,6 +31,9 @@ export class FileServiceManager {
case 'mistral':
service = new MistralService(provider)
break
+ case 'openai':
+ service = new OpenaiService(provider)
+ break
default:
throw new Error(`Unsupported service type: ${type}`)
}
diff --git a/src/main/services/remotefile/OpenAIService.ts b/src/main/services/remotefile/OpenAIService.ts
new file mode 100644
index 0000000000..b7f8d3ea39
--- /dev/null
+++ b/src/main/services/remotefile/OpenAIService.ts
@@ -0,0 +1,125 @@
+import { loggerService } from '@logger'
+import { fileStorage } from '@main/services/FileStorage'
+import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
+import * as fs from 'fs'
+import OpenAI from 'openai'
+
+import { CacheService } from '../CacheService'
+import { BaseFileService } from './BaseFileService'
+
+const logger = loggerService.withContext('OpenAIService')
+
+export class OpenaiService extends BaseFileService {
+ private static readonly FILE_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000
+ private static readonly generateUIFileIdCacheKey = (fileId: string) => `ui_file_id_${fileId}`
+ private readonly client: OpenAI
+
+ constructor(provider: Provider) {
+ super(provider)
+ this.client = new OpenAI({
+ apiKey: provider.apiKey,
+ baseURL: provider.apiHost
+ })
+ }
+
+ async uploadFile(file: FileMetadata): Promise {
+ let fileReadStream: fs.ReadStream | undefined
+ try {
+ fileReadStream = fs.createReadStream(fileStorage.getFilePathById(file))
+ // 还原文件原始名,以提高模型对文件的理解
+ const fileStreamWithMeta = Object.assign(fileReadStream, {
+ name: file.origin_name
+ })
+ const response = await this.client.files.create({
+ file: fileStreamWithMeta,
+ purpose: file.purpose || 'assistants'
+ })
+ if (!response.id) {
+ throw new Error('File id not found in response')
+ }
+ // 映射RemoteFileId到UIFileId上
+ CacheService.set(
+ OpenaiService.generateUIFileIdCacheKey(file.id),
+ response.id,
+ OpenaiService.FILE_CACHE_DURATION
+ )
+ return {
+ fileId: response.id,
+ displayName: file.origin_name,
+ status: 'success',
+ originalFile: {
+ type: 'openai',
+ file: response
+ }
+ }
+ } catch (error) {
+ logger.error('Error uploading file:', error as Error)
+ return {
+ fileId: '',
+ displayName: file.origin_name,
+ status: 'failed'
+ }
+ } finally {
+ // 销毁文件流
+ if (fileReadStream) fileReadStream.destroy()
+ }
+ }
+
+ async listFiles(): Promise {
+ try {
+ const response = await this.client.files.list()
+ return {
+ files: response.data.map((file) => ({
+ id: file.id,
+ displayName: file.filename || '',
+ size: file.bytes,
+ status: 'success', // All listed files are processed,
+ originalFile: {
+ type: 'openai',
+ file
+ }
+ }))
+ }
+ } catch (error) {
+ logger.error('Error listing files:', error as Error)
+ return { files: [] }
+ }
+ }
+
+ async deleteFile(fileId: string): Promise {
+ try {
+ const cachedRemoteFileId = CacheService.get(OpenaiService.generateUIFileIdCacheKey(fileId))
+ await this.client.files.delete(cachedRemoteFileId || fileId)
+ logger.debug(`File ${fileId} deleted`)
+ } catch (error) {
+ logger.error('Error deleting file:', error as Error)
+ throw error
+ }
+ }
+
+ async retrieveFile(fileId: string): Promise {
+ try {
+ // 尝试反映射RemoteFileId
+ const cachedRemoteFileId = CacheService.get(OpenaiService.generateUIFileIdCacheKey(fileId))
+ const response = await this.client.files.retrieve(cachedRemoteFileId || fileId)
+
+ return {
+ fileId: response.id,
+ displayName: response.filename,
+ status: 'success',
+ originalFile: {
+ type: 'openai',
+ file: response
+ }
+ }
+ } catch (error) {
+ logger.error('Error retrieving file:', error as Error)
+ return {
+ fileId: fileId,
+ displayName: '',
+ status: 'failed',
+ originalFile: undefined
+ }
+ }
+ }
+}
diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts
index 97e87bf9ea..f9363a500e 100644
--- a/src/main/utils/file.ts
+++ b/src/main/utils/file.ts
@@ -398,11 +398,15 @@ export function validateFileName(fileName: string, platform = process.platform):
* @returns 合法的文件名
*/
export function checkName(fileName: string): string {
- const validation = validateFileName(fileName)
+ const baseName = path.basename(fileName)
+ const validation = validateFileName(baseName)
if (!validation.valid) {
- throw new Error(`Invalid file name: ${fileName}. ${validation.error}`)
+ // 自动清理非法字符,而不是抛出错误
+ const sanitized = sanitizeFilename(baseName)
+ logger.warn(`File name contains invalid characters, auto-sanitized: "${baseName}" -> "${sanitized}"`)
+ return sanitized
}
- return fileName
+ return baseName
}
/**
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 7c29d8579c..ed8184cde8 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -42,6 +42,8 @@ export function tracedInvoke(channel: string, spanContext: SpanContext | undefin
// Custom APIs for renderer
const api = {
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
+ getDiskInfo: (directoryPath: string): Promise<{ free: number; size: number } | null> =>
+ ipcRenderer.invoke(IpcChannel.App_GetDiskInfo, directoryPath),
reload: () => ipcRenderer.invoke(IpcChannel.App_Reload),
setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
@@ -80,6 +82,7 @@ const api = {
ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data),
setFullScreen: (value: boolean): Promise => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
isFullScreen: (): Promise => ipcRenderer.invoke(IpcChannel.App_IsFullScreen),
+ getSystemFonts: (): Promise => ipcRenderer.invoke(IpcChannel.App_GetSystemFonts),
mac: {
isProcessTrusted: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
requestProcessTrust: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
@@ -445,9 +448,10 @@ const api = {
isMaximized: (): Promise => ipcRenderer.invoke(IpcChannel.Windows_IsMaximized),
onMaximizedChange: (callback: (isMaximized: boolean) => void): (() => void) => {
const channel = IpcChannel.Windows_MaximizedChanged
- ipcRenderer.on(channel, (_, isMaximized: boolean) => callback(isMaximized))
+ const listener = (_: Electron.IpcRendererEvent, isMaximized: boolean) => callback(isMaximized)
+ ipcRenderer.on(channel, listener)
return () => {
- ipcRenderer.removeAllListeners(channel)
+ ipcRenderer.removeListener(channel, listener)
}
}
},
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index fb34a13a26..62bbbdd060 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -1,5 +1,6 @@
import '@renderer/databases'
+import { HeroUIProvider } from '@heroui/react'
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import store, { persistor } from '@renderer/store'
@@ -7,6 +8,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
+import { ToastPortal } from './components/ToastPortal'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
@@ -35,21 +37,24 @@ function App(): React.ReactElement {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts
index 183e469cdd..8af4388d5f 100644
--- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts
+++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts
@@ -4,8 +4,9 @@
*/
import { loggerService } from '@logger'
-import { MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
+import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
+import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import type { TextStreamPart, ToolSet } from 'ai'
import { ToolCallChunkHandler } from './handleToolCallChunk'
@@ -29,13 +30,18 @@ export interface CherryStudioChunk {
export class AiSdkToChunkAdapter {
toolCallHandler: ToolCallChunkHandler
private accumulate: boolean | undefined
+ private isFirstChunk = true
+ private enableWebSearch: boolean = false
+
constructor(
private onChunk: (chunk: Chunk) => void,
mcpTools: MCPTool[] = [],
- accumulate?: boolean
+ accumulate?: boolean,
+ enableWebSearch?: boolean
) {
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
this.accumulate = accumulate
+ this.enableWebSearch = enableWebSearch || false
}
/**
@@ -65,11 +71,24 @@ export class AiSdkToChunkAdapter {
webSearchResults: [],
reasoningId: ''
}
+ // Reset link converter state at the start of stream
+ this.isFirstChunk = true
+
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
+ // Flush any remaining content from link converter buffer if web search is enabled
+ if (this.enableWebSearch) {
+ const remainingText = flushLinkConverterBuffer()
+ if (remainingText) {
+ this.onChunk({
+ type: ChunkType.TEXT_DELTA,
+ text: remainingText
+ })
+ }
+ }
break
}
@@ -87,9 +106,9 @@ export class AiSdkToChunkAdapter {
*/
private convertAndEmitChunk(
chunk: TextStreamPart,
- final: { text: string; reasoningContent: string; webSearchResults: any[]; reasoningId: string }
+ final: { text: string; reasoningContent: string; webSearchResults: AISDKWebSearchResult[]; reasoningId: string }
) {
- logger.info(`AI SDK chunk type: ${chunk.type}`, chunk)
+ logger.silly(`AI SDK chunk type: ${chunk.type}`, chunk)
switch (chunk.type) {
// === 文本相关事件 ===
case 'text-start':
@@ -97,17 +116,44 @@ export class AiSdkToChunkAdapter {
type: ChunkType.TEXT_START
})
break
- case 'text-delta':
- if (this.accumulate) {
- final.text += chunk.text || ''
+ case 'text-delta': {
+ const processedText = chunk.text || ''
+ let finalText: string
+
+ // Only apply link conversion if web search is enabled
+ if (this.enableWebSearch) {
+ const result = convertLinks(processedText, this.isFirstChunk)
+
+ if (this.isFirstChunk) {
+ this.isFirstChunk = false
+ }
+
+ // Handle buffered content
+ if (result.hasBufferedContent) {
+ finalText = result.text
+ } else {
+ finalText = result.text || processedText
+ }
} else {
- final.text = chunk.text || ''
+ // Without web search, just use the original text
+ finalText = processedText
+ }
+
+ if (this.accumulate) {
+ final.text += finalText
+ } else {
+ final.text = finalText
+ }
+
+ // Only emit chunk if there's text to send
+ if (finalText) {
+ this.onChunk({
+ type: ChunkType.TEXT_DELTA,
+ text: this.accumulate ? final.text : finalText
+ })
}
- this.onChunk({
- type: ChunkType.TEXT_DELTA,
- text: final.text || ''
- })
break
+ }
case 'text-end':
this.onChunk({
type: ChunkType.TEXT_COMPLETE,
@@ -152,12 +198,14 @@ export class AiSdkToChunkAdapter {
// this.toolCallHandler.handleToolCallCreated(chunk)
// break
case 'tool-call':
- // 原始的工具调用(未被中间件处理)
this.toolCallHandler.handleToolCall(chunk)
break
+ case 'tool-error':
+ this.toolCallHandler.handleToolError(chunk)
+ break
+
case 'tool-result':
- // 原始的工具调用结果(未被中间件处理)
this.toolCallHandler.handleToolResult(chunk)
break
@@ -167,7 +215,6 @@ export class AiSdkToChunkAdapter {
// type: ChunkType.LLM_RESPONSE_CREATED
// })
// break
- // TODO: 需要区分接口开始和步骤开始
// case 'start-step':
// this.onChunk({
// type: ChunkType.BLOCK_CREATED
@@ -199,7 +246,7 @@ export class AiSdkToChunkAdapter {
[WebSearchSource.ANTHROPIC]: WebSearchSource.ANTHROPIC,
[WebSearchSource.OPENROUTER]: WebSearchSource.OPENROUTER,
[WebSearchSource.GEMINI]: WebSearchSource.GEMINI,
- [WebSearchSource.PERPLEXITY]: WebSearchSource.PERPLEXITY,
+ // [WebSearchSource.PERPLEXITY]: WebSearchSource.PERPLEXITY,
[WebSearchSource.QWEN]: WebSearchSource.QWEN,
[WebSearchSource.HUNYUAN]: WebSearchSource.HUNYUAN,
[WebSearchSource.ZHIPU]: WebSearchSource.ZHIPU,
@@ -267,18 +314,9 @@ export class AiSdkToChunkAdapter {
// === 源和文件相关事件 ===
case 'source':
if (chunk.sourceType === 'url') {
- // if (final.webSearchResults.length === 0) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { sourceType: _, ...rest } = chunk
final.webSearchResults.push(rest)
- // }
- // this.onChunk({
- // type: ChunkType.LLM_WEB_SEARCH_COMPLETE,
- // llm_web_search: {
- // source: WebSearchSource.AISDK,
- // results: final.webSearchResults
- // }
- // })
}
break
case 'file':
@@ -305,8 +343,6 @@ export class AiSdkToChunkAdapter {
break
default:
- // 其他类型的 chunk 可以忽略或记录日志
- // console.log('Unhandled AI SDK chunk type:', chunk.type, chunk)
}
}
}
diff --git a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts
index 8a24c2d010..57cd974a2c 100644
--- a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts
+++ b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts
@@ -8,34 +8,61 @@ import { loggerService } from '@logger'
import { processKnowledgeReferences } from '@renderer/services/KnowledgeService'
import { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
-import type { ProviderMetadata, ToolSet, TypedToolCall, TypedToolResult } from 'ai'
-// import type {
-// AnthropicSearchOutput,
-// WebSearchPluginConfig
-// } from '@cherrystudio/ai-core/core/plugins/built-in/webSearchPlugin'
+import type { ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai'
const logger = loggerService.withContext('ToolCallChunkHandler')
+export type ToolcallsMap = {
+ toolCallId: string
+ toolName: string
+ args: any
+ // mcpTool 现在可以是 MCPTool 或我们为 Provider 工具创建的通用类型
+ tool: BaseTool
+}
/**
* 工具调用处理器类
*/
export class ToolCallChunkHandler {
- // private onChunk: (chunk: Chunk) => void
- private activeToolCalls = new Map<
- string,
- {
- toolCallId: string
- toolName: string
- args: any
- // mcpTool 现在可以是 MCPTool 或我们为 Provider 工具创建的通用类型
- tool: BaseTool
- }
- >()
+ private static globalActiveToolCalls = new Map()
+
+ private activeToolCalls = ToolCallChunkHandler.globalActiveToolCalls
constructor(
private onChunk: (chunk: Chunk) => void,
private mcpTools: MCPTool[]
) {}
+ /**
+ * 内部静态方法:添加活跃工具调用的核心逻辑
+ */
+ private static addActiveToolCallImpl(toolCallId: string, map: ToolcallsMap): boolean {
+ if (!ToolCallChunkHandler.globalActiveToolCalls.has(toolCallId)) {
+ ToolCallChunkHandler.globalActiveToolCalls.set(toolCallId, map)
+ return true
+ }
+ return false
+ }
+
+ /**
+ * 实例方法:添加活跃工具调用
+ */
+ private addActiveToolCall(toolCallId: string, map: ToolcallsMap): boolean {
+ return ToolCallChunkHandler.addActiveToolCallImpl(toolCallId, map)
+ }
+
+ /**
+ * 获取全局活跃的工具调用
+ */
+ public static getActiveToolCalls() {
+ return ToolCallChunkHandler.globalActiveToolCalls
+ }
+
+ /**
+ * 静态方法:添加活跃工具调用(外部访问)
+ */
+ public static addActiveToolCall(toolCallId: string, map: ToolcallsMap): boolean {
+ return ToolCallChunkHandler.addActiveToolCallImpl(toolCallId, map)
+ }
+
// /**
// * 设置 onChunk 回调
// */
@@ -43,103 +70,103 @@ export class ToolCallChunkHandler {
// this.onChunk = callback
// }
- handleToolCallCreated(
- chunk:
- | {
- type: 'tool-input-start'
- id: string
- toolName: string
- providerMetadata?: ProviderMetadata
- providerExecuted?: boolean
- }
- | {
- type: 'tool-input-end'
- id: string
- providerMetadata?: ProviderMetadata
- }
- | {
- type: 'tool-input-delta'
- id: string
- delta: string
- providerMetadata?: ProviderMetadata
- }
- ): void {
- switch (chunk.type) {
- case 'tool-input-start': {
- // 能拿到说明是mcpTool
- // if (this.activeToolCalls.get(chunk.id)) return
+ // handleToolCallCreated(
+ // chunk:
+ // | {
+ // type: 'tool-input-start'
+ // id: string
+ // toolName: string
+ // providerMetadata?: ProviderMetadata
+ // providerExecuted?: boolean
+ // }
+ // | {
+ // type: 'tool-input-end'
+ // id: string
+ // providerMetadata?: ProviderMetadata
+ // }
+ // | {
+ // type: 'tool-input-delta'
+ // id: string
+ // delta: string
+ // providerMetadata?: ProviderMetadata
+ // }
+ // ): void {
+ // switch (chunk.type) {
+ // case 'tool-input-start': {
+ // // 能拿到说明是mcpTool
+ // // if (this.activeToolCalls.get(chunk.id)) return
- const tool: BaseTool | MCPTool = {
- id: chunk.id,
- name: chunk.toolName,
- description: chunk.toolName,
- type: chunk.toolName.startsWith('builtin_') ? 'builtin' : 'provider'
- }
- this.activeToolCalls.set(chunk.id, {
- toolCallId: chunk.id,
- toolName: chunk.toolName,
- args: '',
- tool
- })
- const toolResponse: MCPToolResponse | NormalToolResponse = {
- id: chunk.id,
- tool: tool,
- arguments: {},
- status: 'pending',
- toolCallId: chunk.id
- }
- this.onChunk({
- type: ChunkType.MCP_TOOL_PENDING,
- responses: [toolResponse]
- })
- break
- }
- case 'tool-input-delta': {
- const toolCall = this.activeToolCalls.get(chunk.id)
- if (!toolCall) {
- logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
- return
- }
- toolCall.args += chunk.delta
- break
- }
- case 'tool-input-end': {
- const toolCall = this.activeToolCalls.get(chunk.id)
- this.activeToolCalls.delete(chunk.id)
- if (!toolCall) {
- logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
- return
- }
- // const toolResponse: ToolCallResponse = {
- // id: toolCall.toolCallId,
- // tool: toolCall.tool,
- // arguments: toolCall.args,
- // status: 'pending',
- // toolCallId: toolCall.toolCallId
- // }
- // logger.debug('toolResponse', toolResponse)
- // this.onChunk({
- // type: ChunkType.MCP_TOOL_PENDING,
- // responses: [toolResponse]
- // })
- break
- }
- }
- // if (!toolCall) {
- // Logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
- // return
- // }
- // this.onChunk({
- // type: ChunkType.MCP_TOOL_CREATED,
- // tool_calls: [
- // {
- // id: chunk.id,
- // name: chunk.toolName,
- // status: 'pending'
- // }
- // ]
- // })
- }
+ // const tool: BaseTool | MCPTool = {
+ // id: chunk.id,
+ // name: chunk.toolName,
+ // description: chunk.toolName,
+ // type: chunk.toolName.startsWith('builtin_') ? 'builtin' : 'provider'
+ // }
+ // this.activeToolCalls.set(chunk.id, {
+ // toolCallId: chunk.id,
+ // toolName: chunk.toolName,
+ // args: '',
+ // tool
+ // })
+ // const toolResponse: MCPToolResponse | NormalToolResponse = {
+ // id: chunk.id,
+ // tool: tool,
+ // arguments: {},
+ // status: 'pending',
+ // toolCallId: chunk.id
+ // }
+ // this.onChunk({
+ // type: ChunkType.MCP_TOOL_PENDING,
+ // responses: [toolResponse]
+ // })
+ // break
+ // }
+ // case 'tool-input-delta': {
+ // const toolCall = this.activeToolCalls.get(chunk.id)
+ // if (!toolCall) {
+ // logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
+ // return
+ // }
+ // toolCall.args += chunk.delta
+ // break
+ // }
+ // case 'tool-input-end': {
+ // const toolCall = this.activeToolCalls.get(chunk.id)
+ // this.activeToolCalls.delete(chunk.id)
+ // if (!toolCall) {
+ // logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
+ // return
+ // }
+ // // const toolResponse: ToolCallResponse = {
+ // // id: toolCall.toolCallId,
+ // // tool: toolCall.tool,
+ // // arguments: toolCall.args,
+ // // status: 'pending',
+ // // toolCallId: toolCall.toolCallId
+ // // }
+ // // logger.debug('toolResponse', toolResponse)
+ // // this.onChunk({
+ // // type: ChunkType.MCP_TOOL_PENDING,
+ // // responses: [toolResponse]
+ // // })
+ // break
+ // }
+ // }
+ // // if (!toolCall) {
+ // // Logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
+ // // return
+ // // }
+ // // this.onChunk({
+ // // type: ChunkType.MCP_TOOL_CREATED,
+ // // tool_calls: [
+ // // {
+ // // id: chunk.id,
+ // // name: chunk.toolName,
+ // // status: 'pending'
+ // // }
+ // // ]
+ // // })
+ // }
/**
* 处理工具调用事件
@@ -158,7 +185,6 @@ export class ToolCallChunkHandler {
let tool: BaseTool
let mcpTool: MCPTool | undefined
-
// 根据 providerExecuted 标志区分处理逻辑
if (providerExecuted) {
// 如果是 Provider 执行的工具(如 web_search)
@@ -196,27 +222,25 @@ export class ToolCallChunkHandler {
}
}
- // 记录活跃的工具调用
- this.activeToolCalls.set(toolCallId, {
+ this.addActiveToolCall(toolCallId, {
toolCallId,
toolName,
args,
tool
})
-
// 创建 MCPToolResponse 格式
const toolResponse: MCPToolResponse | NormalToolResponse = {
id: toolCallId,
tool: tool,
arguments: args,
- status: 'pending',
+ status: 'pending', // 统一使用 pending 状态
toolCallId: toolCallId
}
// 调用 onChunk
if (this.onChunk) {
this.onChunk({
- type: ChunkType.MCP_TOOL_PENDING,
+ type: ChunkType.MCP_TOOL_PENDING, // 统一发送 pending 状态
responses: [toolResponse]
})
}
@@ -257,7 +281,7 @@ export class ToolCallChunkHandler {
// 工具特定的后处理
switch (toolResponse.tool.name) {
case 'builtin_knowledge_search': {
- processKnowledgeReferences(toolResponse.response?.knowledgeReferences, this.onChunk)
+ processKnowledgeReferences(toolResponse.response, this.onChunk)
break
}
// 未来可以在这里添加其他工具的后处理逻辑
@@ -276,4 +300,33 @@ export class ToolCallChunkHandler {
})
}
}
+ handleToolError(
+ chunk: {
+ type: 'tool-error'
+ } & TypedToolError
+ ): void {
+ const { toolCallId, error, input } = chunk
+ const toolCallInfo = this.activeToolCalls.get(toolCallId)
+ if (!toolCallInfo) {
+ logger.warn(`🔧 [ToolCallChunkHandler] Tool call info not found for ID: ${toolCallId}`)
+ return
+ }
+ const toolResponse: MCPToolResponse | NormalToolResponse = {
+ id: toolCallId,
+ tool: toolCallInfo.tool,
+ arguments: input,
+ status: 'error',
+ response: error,
+ toolCallId: toolCallId
+ }
+ this.activeToolCalls.delete(toolCallId)
+ if (this.onChunk) {
+ this.onChunk({
+ type: ChunkType.MCP_TOOL_COMPLETE,
+ responses: [toolResponse]
+ })
+ }
+ }
}
+
+export const addActiveToolCall = ToolCallChunkHandler.addActiveToolCall.bind(ToolCallChunkHandler)
diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts
index 2c55ed35c3..fde316f478 100644
--- a/src/renderer/src/aiCore/index_new.ts
+++ b/src/renderer/src/aiCore/index_new.ts
@@ -265,15 +265,15 @@ export default class ModernAiProvider {
params: StreamTextParams,
config: ModernAiProviderConfig
): Promise {
- const modelId = this.model!.id
- logger.info('Starting modernCompletions', {
- modelId,
- providerId: this.config!.providerId,
- topicId: config.topicId,
- hasOnChunk: !!config.onChunk,
- hasTools: !!params.tools && Object.keys(params.tools).length > 0,
- toolCount: params.tools ? Object.keys(params.tools).length : 0
- })
+ // const modelId = this.model!.id
+ // logger.info('Starting modernCompletions', {
+ // modelId,
+ // providerId: this.config!.providerId,
+ // topicId: config.topicId,
+ // hasOnChunk: !!config.onChunk,
+ // hasTools: !!params.tools && Object.keys(params.tools).length > 0,
+ // toolCount: params.tools ? Object.keys(params.tools).length : 0
+ // })
// 根据条件构建插件数组
const plugins = await buildPlugins(config)
@@ -284,7 +284,7 @@ export default class ModernAiProvider {
// 创建带有中间件的执行器
if (config.onChunk) {
const accumulate = this.model!.supported_text_delta !== false // true and undefined
- const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate)
+ const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate, config.enableWebSearch)
const streamResult = await executor.streamText({
...params,
diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts
index 90eab6b141..f1128ab812 100644
--- a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts
+++ b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts
@@ -45,6 +45,7 @@ import { isJSON, parseJSON } from '@renderer/utils'
import { addAbortController, removeAbortController } from '@renderer/utils/abortController'
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { defaultTimeout } from '@shared/config/constant'
+import { defaultAppHeaders } from '@shared/utils'
import { REFERENCE_PROMPT } from '@shared/config/prompts'
import { isEmpty } from 'lodash'
@@ -179,8 +180,7 @@ export abstract class BaseApiClient<
public defaultHeaders() {
return {
- 'HTTP-Referer': 'https://cherry-ai.com',
- 'X-Title': 'Cherry Studio',
+ ...defaultAppHeaders(),
'X-Api-Key': this.apiKey
}
}
diff --git a/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts
index 4c72e877a9..ae346af836 100644
--- a/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts
+++ b/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts
@@ -1,6 +1,6 @@
import { loggerService } from '@logger'
import { ChunkType } from '@renderer/types/chunk'
-import { flushLinkConverterBuffer, smartLinkConverter } from '@renderer/utils/linkConverter'
+import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
import { CompletionsContext, CompletionsMiddleware } from '../types'
@@ -28,8 +28,6 @@ export const WebSearchMiddleware: CompletionsMiddleware =
}
// 调用下游中间件
const result = await next(ctx, params)
-
- const model = params.assistant?.model!
let isFirstChunk = true
// 响应后处理:记录Web搜索事件
@@ -42,15 +40,9 @@ export const WebSearchMiddleware: CompletionsMiddleware =
new TransformStream({
transform(chunk: GenericChunk, controller) {
if (chunk.type === ChunkType.TEXT_DELTA) {
- const providerType = model.provider || 'openai'
// 使用当前可用的Web搜索结果进行链接转换
const text = chunk.text
- const result = smartLinkConverter(
- text,
- providerType,
- isFirstChunk,
- ctx._internal.webSearchState!.results
- )
+ const result = convertLinks(text, isFirstChunk)
if (isFirstChunk) {
isFirstChunk = false
}
diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts
index f331d36a7e..f0d3b2eb59 100644
--- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts
+++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts
@@ -20,8 +20,10 @@ export interface AiSdkMiddlewareConfig {
isSupportedToolUse: boolean
// image generation endpoint
isImageGenerationEndpoint: boolean
+ // 是否开启内置搜索
enableWebSearch: boolean
enableGenerateImage: boolean
+ enableUrlContext: boolean
mcpTools?: MCPTool[]
uiMessages?: Message[]
}
@@ -132,7 +134,6 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo
})
}
- logger.info('builder.build()', builder.buildNamed())
return builder.build()
}
diff --git a/src/renderer/src/aiCore/plugins/PluginBuilder.ts b/src/renderer/src/aiCore/plugins/PluginBuilder.ts
index 3b4d4b4d85..471f203d15 100644
--- a/src/renderer/src/aiCore/plugins/PluginBuilder.ts
+++ b/src/renderer/src/aiCore/plugins/PluginBuilder.ts
@@ -1,5 +1,5 @@
import { AiPlugin } from '@cherrystudio/ai-core'
-import { createPromptToolUsePlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
+import { createPromptToolUsePlugin, googleToolsPlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import type { Assistant } from '@renderer/types'
@@ -35,7 +35,7 @@ export async function buildPlugins(
plugins.push(webSearchPlugin())
}
// 2. 支持工具调用时添加搜索插件
- if (middlewareConfig.isSupportedToolUse) {
+ if (middlewareConfig.isSupportedToolUse || middlewareConfig.isPromptToolUse) {
plugins.push(searchOrchestrationPlugin(middlewareConfig.assistant, middlewareConfig.topicId || ''))
}
@@ -45,12 +45,13 @@ export async function buildPlugins(
}
// 4. 启用Prompt工具调用时添加工具插件
- if (middlewareConfig.isPromptToolUse && middlewareConfig.mcpTools && middlewareConfig.mcpTools.length > 0) {
+ if (middlewareConfig.isPromptToolUse) {
plugins.push(
createPromptToolUsePlugin({
enabled: true,
createSystemMessage: (systemPrompt, params, context) => {
- if (context.modelId.includes('o1-mini') || context.modelId.includes('o1-preview')) {
+ const modelId = typeof context.model === 'string' ? context.model : context.model.modelId
+ if (modelId.includes('o1-mini') || modelId.includes('o1-preview')) {
if (context.isRecursiveCall) {
return null
}
@@ -69,10 +70,11 @@ export async function buildPlugins(
)
}
- // if (!middlewareConfig.enableTool && middlewareConfig.mcpTools && middlewareConfig.mcpTools.length > 0) {
- // plugins.push(createNativeToolUsePlugin())
- // }
- logger.info(
+ if (middlewareConfig.enableUrlContext) {
+ plugins.push(googleToolsPlugin({ urlContext: true }))
+ }
+
+ logger.debug(
'Final plugin list:',
plugins.map((p) => p.name)
)
diff --git a/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts b/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts
index aca53219c5..c1e9b7f17b 100644
--- a/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts
+++ b/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts
@@ -19,7 +19,8 @@ import {
SEARCH_SUMMARY_PROMPT_KNOWLEDGE_ONLY,
SEARCH_SUMMARY_PROMPT_WEB_ONLY
} from '@shared/config/prompts'
-import type { ModelMessage } from 'ai'
+import type { LanguageModel, ModelMessage } from 'ai'
+import { generateText } from 'ai'
import { isEmpty } from 'lodash'
import { MemoryProcessor } from '../../services/MemoryProcessor'
@@ -76,9 +77,7 @@ async function analyzeSearchIntent(
shouldKnowledgeSearch?: boolean
shouldMemorySearch?: boolean
lastAnswer?: ModelMessage
- context: AiRequestContext & {
- isAnalyzing?: boolean
- }
+ context: AiRequestContext
topicId: string
}
): Promise {
@@ -122,9 +121,7 @@ async function analyzeSearchIntent(
logger.error('Provider not found or missing API key')
return getFallbackResult()
}
- // console.log('formattedPrompt', schema)
try {
- context.isAnalyzing = true
logger.info('Starting intent analysis generateText call', {
modelId: model.id,
topicId: options.topicId,
@@ -133,18 +130,16 @@ async function analyzeSearchIntent(
hasKnowledgeSearch: needKnowledgeExtract
})
- const { text: result } = await context.executor
- .generateText(model.id, {
- prompt: formattedPrompt
- })
- .finally(() => {
- context.isAnalyzing = false
- logger.info('Intent analysis generateText call completed', {
- modelId: model.id,
- topicId: options.topicId,
- requestId: context.requestId
- })
+ const { text: result } = await generateText({
+ model: context.model as LanguageModel,
+ prompt: formattedPrompt
+ }).finally(() => {
+ logger.info('Intent analysis generateText call completed', {
+ modelId: model.id,
+ topicId: options.topicId,
+ requestId: context.requestId
})
+ })
const parsedResult = extractInfoFromXML(result)
logger.debug('Intent analysis result', { parsedResult })
@@ -183,7 +178,6 @@ async function storeConversationMemory(
const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState())
if (!globalMemoryEnabled || !assistant.enableMemory) {
- // console.log('Memory storage is disabled')
return
}
@@ -245,25 +239,14 @@ export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string)
// 存储意图分析结果
const intentAnalysisResults: { [requestId: string]: ExtractResults } = {}
const userMessages: { [requestId: string]: ModelMessage } = {}
- let currentContext: AiRequestContext | null = null
return definePlugin({
name: 'search-orchestration',
enforce: 'pre', // 确保在其他插件之前执行
-
- configureContext: (context: AiRequestContext) => {
- if (currentContext) {
- context.isAnalyzing = currentContext.isAnalyzing
- }
- currentContext = context
- },
-
/**
* 🔍 Step 1: 意图识别阶段
*/
onRequestStart: async (context: AiRequestContext) => {
- if (context.isAnalyzing) return
-
// 没开启任何搜索则不进行意图分析
if (!(assistant.webSearchProviderId || assistant.knowledge_bases?.length || assistant.enableMemory)) return
@@ -284,7 +267,6 @@ export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string)
const hasKnowledgeBase = !isEmpty(knowledgeBaseIds)
const knowledgeRecognition = assistant.knowledgeRecognition || 'on'
const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState())
-
const shouldWebSearch = !!assistant.webSearchProviderId
const shouldKnowledgeSearch = hasKnowledgeBase && knowledgeRecognition === 'on'
const shouldMemorySearch = globalMemoryEnabled && assistant.enableMemory
@@ -315,7 +297,6 @@ export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string)
* 🔧 Step 2: 工具配置阶段
*/
transformParams: async (params: any, context: AiRequestContext) => {
- if (context.isAnalyzing) return params
// logger.info('🔧 Configuring tools based on intent...', context.requestId)
try {
@@ -409,7 +390,6 @@ export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string)
// context.isAnalyzing = false
// logger.info('context.isAnalyzing', context, result)
// logger.info('💾 Starting memory storage...', context.requestId)
- if (context.isAnalyzing) return
try {
const messages = context.originalParams.messages
diff --git a/src/renderer/src/aiCore/plugins/telemetryPlugin.ts b/src/renderer/src/aiCore/plugins/telemetryPlugin.ts
index 2083f8a098..6eb66575c5 100644
--- a/src/renderer/src/aiCore/plugins/telemetryPlugin.ts
+++ b/src/renderer/src/aiCore/plugins/telemetryPlugin.ts
@@ -58,91 +58,6 @@ class AdapterTracer {
})
}
- // startSpan(name: string, options?: any, context?: any): Span {
- // // 如果提供了父 SpanContext 且未显式传入 context,则使用父上下文
- // const contextToUse = context ?? this.cachedParentContext ?? otelContext.active()
-
- // const span = this.originalTracer.startSpan(name, options, contextToUse)
-
- // // 标记父子关系,便于在转换阶段兜底重建层级
- // try {
- // if (this.parentSpanContext) {
- // span.setAttribute('trace.parentSpanId', this.parentSpanContext.spanId)
- // span.setAttribute('trace.parentTraceId', this.parentSpanContext.traceId)
- // }
- // if (this.topicId) {
- // span.setAttribute('trace.topicId', this.topicId)
- // }
- // } catch (e) {
- // logger.debug('Failed to set trace parent attributes', e as Error)
- // }
-
- // logger.info('AI SDK span created via AdapterTracer', {
- // spanName: name,
- // spanId: span.spanContext().spanId,
- // traceId: span.spanContext().traceId,
- // parentTraceId: this.parentSpanContext?.traceId,
- // topicId: this.topicId,
- // modelName: this.modelName,
- // traceIdMatches: this.parentSpanContext ? span.spanContext().traceId === this.parentSpanContext.traceId : undefined
- // })
-
- // // 包装 span 的 end 方法,在结束时进行数据转换
- // const originalEnd = span.end.bind(span)
- // span.end = (endTime?: any) => {
- // logger.info('AI SDK span.end() called - about to convert span', {
- // spanName: name,
- // spanId: span.spanContext().spanId,
- // traceId: span.spanContext().traceId,
- // topicId: this.topicId,
- // modelName: this.modelName
- // })
-
- // // 调用原始 end 方法
- // originalEnd(endTime)
-
- // // 转换并保存 span 数据
- // try {
- // logger.info('Converting AI SDK span to SpanEntity', {
- // spanName: name,
- // spanId: span.spanContext().spanId,
- // traceId: span.spanContext().traceId,
- // topicId: this.topicId,
- // modelName: this.modelName
- // })
- // logger.info('spanspanspanspanspanspan', span)
- // const spanEntity = AiSdkSpanAdapter.convertToSpanEntity({
- // span,
- // topicId: this.topicId,
- // modelName: this.modelName
- // })
-
- // // 保存转换后的数据
- // window.api.trace.saveEntity(spanEntity)
-
- // logger.info('AI SDK span converted and saved successfully', {
- // spanName: name,
- // spanId: span.spanContext().spanId,
- // traceId: span.spanContext().traceId,
- // topicId: this.topicId,
- // modelName: this.modelName,
- // hasUsage: !!spanEntity.usage,
- // usage: spanEntity.usage
- // })
- // } catch (error) {
- // logger.error('Failed to convert AI SDK span', error as Error, {
- // spanName: name,
- // spanId: span.spanContext().spanId,
- // traceId: span.spanContext().traceId,
- // topicId: this.topicId,
- // modelName: this.modelName
- // })
- // }
- // }
-
- // return span
- // }
-
startActiveSpan any>(name: string, fn: F): ReturnType
startActiveSpan any>(name: string, options: any, fn: F): ReturnType
startActiveSpan any>(name: string, options: any, context: any, fn: F): ReturnType
diff --git a/src/renderer/src/aiCore/prepareParams/fileProcessor.ts b/src/renderer/src/aiCore/prepareParams/fileProcessor.ts
index 2defe2c711..9339e61a45 100644
--- a/src/renderer/src/aiCore/prepareParams/fileProcessor.ts
+++ b/src/renderer/src/aiCore/prepareParams/fileProcessor.ts
@@ -10,6 +10,7 @@ import { FileTypes } from '@renderer/types'
import { FileMessageBlock } from '@renderer/types/newMessage'
import { findFileBlocks } from '@renderer/utils/messageUtils/find'
import type { FilePart, TextPart } from 'ai'
+import type OpenAI from 'openai'
import { getAiSdkProviderId } from '../provider/factory'
import { getFileSizeLimit, supportsImageInput, supportsLargeFileUpload, supportsPdfInput } from './modelCapabilities'
@@ -112,6 +113,86 @@ export async function handleGeminiFileUpload(file: FileMetadata, model: Model):
return null
}
+/**
+ * 处理OpenAI大文件上传
+ */
+export async function handleOpenAILargeFileUpload(
+ file: FileMetadata,
+ model: Model
+): Promise<(FilePart & { id?: string }) | null> {
+ const provider = getProviderByModel(model)
+ // 如果模型为qwen-long系列,文档中要求purpose需要为'file-extract'
+ if (['qwen-long', 'qwen-doc'].some((modelName) => model.name.includes(modelName))) {
+ file = {
+ ...file,
+ // 该类型并不在OpenAI定义中,但符合sdk规范,强制断言
+ purpose: 'file-extract' as OpenAI.FilePurpose
+ }
+ }
+ try {
+ // 检查文件是否已经上传过
+ const fileMetadata = await window.api.fileService.retrieve(provider, file.id)
+ if (fileMetadata.status === 'success' && fileMetadata.originalFile?.file) {
+ // 断言OpenAIFile对象
+ const remoteFile = fileMetadata.originalFile.file as OpenAI.Files.FileObject
+ // 判断用途是否一致
+ if (remoteFile.purpose !== file.purpose) {
+ logger.warn(`File ${file.origin_name} purpose mismatch: ${remoteFile.purpose} vs ${file.purpose}`)
+ throw new Error('File purpose mismatch')
+ }
+ return {
+ type: 'file',
+ filename: file.origin_name,
+ mediaType: '',
+ data: `fileid://${remoteFile.id}`
+ }
+ }
+ } catch (error) {
+ logger.error(`Failed to retrieve file ${file.origin_name}:`, error as Error)
+ return null
+ }
+ try {
+ // 如果文件未上传,执行上传
+ const uploadResult = await window.api.fileService.upload(provider, file)
+ if (uploadResult.originalFile?.file) {
+ // 断言OpenAIFile对象
+ const remoteFile = uploadResult.originalFile.file as OpenAI.Files.FileObject
+ logger.info(`File ${file.origin_name} uploaded.`)
+ return {
+ type: 'file',
+ filename: remoteFile.filename,
+ mediaType: '',
+ data: `fileid://${remoteFile.id}`
+ }
+ }
+ } catch (error) {
+ logger.error(`Failed to upload file ${file.origin_name}:`, error as Error)
+ }
+
+ return null
+}
+
+/**
+ * 大文件上传路由函数
+ */
+export async function handleLargeFileUpload(
+ file: FileMetadata,
+ model: Model
+): Promise<(FilePart & { id?: string }) | null> {
+ const provider = getProviderByModel(model)
+ const aiSdkId = getAiSdkProviderId(provider)
+
+ if (['google', 'google-generative-ai', 'google-vertex'].includes(aiSdkId)) {
+ return await handleGeminiFileUpload(file, model)
+ }
+
+ if (provider.type === 'openai') {
+ return await handleOpenAILargeFileUpload(file, model)
+ }
+
+ return null
+}
+
/**
* 将文件块转换为FilePart(用于原生文件支持)
*/
@@ -127,7 +208,7 @@ export async function convertFileBlockToFilePart(fileBlock: FileMessageBlock, mo
// 如果支持大文件上传(如Gemini File API),尝试上传
if (supportsLargeFileUpload(model)) {
logger.info(`Large PDF file ${file.origin_name} (${file.size} bytes) attempting File API upload`)
- const uploadResult = await handleGeminiFileUpload(file, model)
+ const uploadResult = await handleLargeFileUpload(file, model)
if (uploadResult) {
return uploadResult
}
diff --git a/src/renderer/src/aiCore/prepareParams/messageConverter.ts b/src/renderer/src/aiCore/prepareParams/messageConverter.ts
index d11f25fc2c..4c2d5baba6 100644
--- a/src/renderer/src/aiCore/prepareParams/messageConverter.ts
+++ b/src/renderer/src/aiCore/prepareParams/messageConverter.ts
@@ -13,7 +13,15 @@ import {
findThinkingBlocks,
getMainTextContent
} from '@renderer/utils/messageUtils/find'
-import type { AssistantModelMessage, FilePart, ImagePart, ModelMessage, TextPart, UserModelMessage } from 'ai'
+import type {
+ AssistantModelMessage,
+ FilePart,
+ ImagePart,
+ ModelMessage,
+ SystemModelMessage,
+ TextPart,
+ UserModelMessage
+} from 'ai'
import { convertFileBlockToFilePart, convertFileBlockToTextPart } from './fileProcessor'
@@ -27,7 +35,7 @@ export async function convertMessageToSdkParam(
message: Message,
isVisionModel = false,
model?: Model
-): Promise {
+): Promise {
const content = getMainTextContent(message)
const fileBlocks = findFileBlocks(message)
const imageBlocks = findImageBlocks(message)
@@ -48,7 +56,7 @@ async function convertMessageToUserModelMessage(
imageBlocks: ImageMessageBlock[],
isVisionModel = false,
model?: Model
-): Promise {
+): Promise {
const parts: Array = []
if (content) {
parts.push({ type: 'text', text: content })
@@ -85,6 +93,19 @@ async function convertMessageToUserModelMessage(
if (model) {
const filePart = await convertFileBlockToFilePart(fileBlock, model)
if (filePart) {
+ // 判断filePart是否为string
+ if (typeof filePart.data === 'string' && filePart.data.startsWith('fileid://')) {
+ return [
+ {
+ role: 'system',
+ content: filePart.data
+ },
+ {
+ role: 'user',
+ content: parts.length > 0 ? parts : ''
+ }
+ ]
+ }
parts.push(filePart)
logger.debug(`File ${file.origin_name} processed as native file format`)
processed = true
@@ -159,7 +180,7 @@ export async function convertMessagesToSdkMessages(messages: Message[], model: M
for (const message of messages) {
const sdkMessage = await convertMessageToSdkParam(message, isVision, model)
- sdkMessages.push(sdkMessage)
+ sdkMessages.push(...(Array.isArray(sdkMessage) ? sdkMessage : [sdkMessage]))
}
return sdkMessages
diff --git a/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts b/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts
index a70576ff11..4a3c3f4bbf 100644
--- a/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts
+++ b/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts
@@ -10,26 +10,61 @@ import { FileTypes } from '@renderer/types'
import { getAiSdkProviderId } from '../provider/factory'
+// 工具函数:基于模型名和提供商判断是否支持某特性
+function modelSupportValidator(
+ model: Model,
+ {
+ supportedModels = [],
+ unsupportedModels = [],
+ supportedProviders = [],
+ unsupportedProviders = []
+ }: {
+ supportedModels?: string[]
+ unsupportedModels?: string[]
+ supportedProviders?: string[]
+ unsupportedProviders?: string[]
+ }
+): boolean {
+ const provider = getProviderByModel(model)
+ const aiSdkId = getAiSdkProviderId(provider)
+
+ // 黑名单:命中不支持的模型直接拒绝
+ if (unsupportedModels.some((name) => model.name.includes(name))) {
+ return false
+ }
+
+ // 黑名单:命中不支持的提供商直接拒绝,常用于某些提供商的同名模型并不具备原模型的某些特性
+ if (unsupportedProviders.includes(aiSdkId)) {
+ return false
+ }
+
+ // 白名单:命中支持的模型名
+ if (supportedModels.some((name) => model.name.includes(name))) {
+ return true
+ }
+
+ // 回退到提供商判断
+ return supportedProviders.includes(aiSdkId)
+}
+
/**
* 检查模型是否支持原生PDF输入
*/
export function supportsPdfInput(model: Model): boolean {
- // 基于AI SDK文档,这些提供商支持PDF输入
- const supportedProviders = [
- 'openai',
- 'azure-openai',
- 'anthropic',
- 'google',
- 'google-generative-ai',
- 'google-vertex',
- 'bedrock',
- 'amazon-bedrock'
- ]
-
- const provider = getProviderByModel(model)
- const aiSdkId = getAiSdkProviderId(provider)
-
- return supportedProviders.some((provider) => aiSdkId === provider)
+ // 基于AI SDK文档,以下模型或提供商支持PDF输入
+ return modelSupportValidator(model, {
+ supportedModels: ['qwen-long', 'qwen-doc'],
+ supportedProviders: [
+ 'openai',
+ 'azure-openai',
+ 'anthropic',
+ 'google',
+ 'google-generative-ai',
+ 'google-vertex',
+ 'bedrock',
+ 'amazon-bedrock'
+ ]
+ })
}
/**
@@ -43,11 +78,11 @@ export function supportsImageInput(model: Model): boolean {
* 检查提供商是否支持大文件上传(如Gemini File API)
*/
export function supportsLargeFileUpload(model: Model): boolean {
- const provider = getProviderByModel(model)
- const aiSdkId = getAiSdkProviderId(provider)
-
- // 目前主要是Gemini系列支持大文件上传
- return ['google', 'google-generative-ai', 'google-vertex'].includes(aiSdkId)
+ // 基于AI SDK文档,以下模型或提供商支持大文件上传
+ return modelSupportValidator(model, {
+ supportedModels: ['qwen-long', 'qwen-doc'],
+ supportedProviders: ['google', 'google-generative-ai', 'google-vertex']
+ })
}
/**
@@ -67,6 +102,11 @@ export function getFileSizeLimit(model: Model, fileType: FileTypes): number {
return 20 * 1024 * 1024 // 20MB
}
+ // Dashscope如果模型支持大文件上传优先使用File API上传
+ if (aiSdkId === 'dashscope' && supportsLargeFileUpload(model)) {
+ return 0 // 使用较小的默认值
+ }
+
// 其他提供商没有明确限制,使用较大的默认值
// 这与Legacy架构中的实现一致,让提供商自行处理文件大小
return Infinity
diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts
index d72010d03b..ff51b07973 100644
--- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts
+++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts
@@ -3,23 +3,28 @@
* 构建AI SDK的流式和非流式参数
*/
+import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge'
+import { vertex } from '@ai-sdk/google-vertex/edge'
import { loggerService } from '@logger'
import {
isGenerateImageModel,
isOpenRouterBuiltInWebSearchModel,
isReasoningModel,
isSupportedReasoningEffortModel,
+ isSupportedThinkingTokenClaudeModel,
isSupportedThinkingTokenModel,
isWebSearchModel
} from '@renderer/config/models'
import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService'
-import type { Assistant, MCPTool, Provider } from '@renderer/types'
+import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import type { ModelMessage } from 'ai'
import { stepCountIs } from 'ai'
+import { getAiSdkProviderId } from '../provider/factory'
import { setupToolsConfig } from '../utils/mcp'
import { buildProviderOptions } from '../utils/options'
+import { getAnthropicThinkingBudget } from '../utils/reasoning'
import { getTemperature, getTopP } from './modelParameters'
const logger = loggerService.withContext('parameterBuilder')
@@ -54,8 +59,9 @@ export async function buildStreamTextParams(
const { mcpTools } = options
const model = assistant.model || getDefaultModel()
+ const aiSdkProviderId = getAiSdkProviderId(provider)
- const { maxTokens } = getAssistantSettings(assistant)
+ let { maxTokens } = getAssistantSettings(assistant)
// 这三个变量透传出来,交给下面启用插件/中间件
// 也可以在外部构建好再传入buildStreamTextParams
@@ -65,17 +71,20 @@ export async function buildStreamTextParams(
assistant.settings?.reasoning_effort !== undefined) ||
(isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model)))
+ // 判断是否使用内置搜索
+ // 条件:没有外部搜索提供商 && (用户开启了内置搜索 || 模型强制使用内置搜索)
+ const hasExternalSearch = !!options.webSearchProviderId
const enableWebSearch =
- (assistant.enableWebSearch && isWebSearchModel(model)) ||
- isOpenRouterBuiltInWebSearchModel(model) ||
- model.id.includes('sonar') ||
- false
+ !hasExternalSearch &&
+ ((assistant.enableWebSearch && isWebSearchModel(model)) ||
+ isOpenRouterBuiltInWebSearchModel(model) ||
+ model.id.includes('sonar'))
const enableUrlContext = assistant.enableUrlContext || false
const enableGenerateImage = !!(isGenerateImageModel(model) && assistant.enableGenerateImage)
- const tools = setupToolsConfig(mcpTools)
+ let tools = setupToolsConfig(mcpTools)
// if (webSearchProviderId) {
// tools['builtin_web_search'] = webSearchTool(webSearchProviderId)
@@ -88,6 +97,36 @@ export async function buildStreamTextParams(
enableGenerateImage
})
+ // NOTE: ai-sdk会把maxToken和budgetToken加起来
+ if (
+ enableReasoning &&
+ maxTokens !== undefined &&
+ isSupportedThinkingTokenClaudeModel(model) &&
+ (provider.type === 'anthropic' || provider.type === 'aws-bedrock')
+ ) {
+ maxTokens -= getAnthropicThinkingBudget(assistant, model)
+ }
+
+ // google-vertex | google-vertex-anthropic
+ if (enableWebSearch) {
+ if (!tools) {
+ tools = {}
+ }
+ if (aiSdkProviderId === 'google-vertex') {
+ tools.google_search = vertex.tools.googleSearch({})
+ } else if (aiSdkProviderId === 'google-vertex-anthropic') {
+ tools.web_search = vertexAnthropic.tools.webSearch_20250305({})
+ }
+ }
+
+ // google-vertex
+ if (enableUrlContext && aiSdkProviderId === 'google-vertex') {
+ if (!tools) {
+ tools = {}
+ }
+ tools.url_context = vertex.tools.urlContext({})
+ }
+
// 构建基础参数
const params: StreamTextParams = {
messages: sdkMessages,
@@ -97,10 +136,12 @@ export async function buildStreamTextParams(
abortSignal: options.requestOptions?.signal,
headers: options.requestOptions?.headers,
providerOptions,
- tools,
stopWhen: stepCountIs(10),
maxRetries: 0
}
+ if (tools) {
+ params.tools = tools
+ }
if (assistant.prompt) {
params.system = assistant.prompt
}
diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts
index ddbb178f3f..654b06114c 100644
--- a/src/renderer/src/aiCore/provider/providerConfig.ts
+++ b/src/renderer/src/aiCore/provider/providerConfig.ts
@@ -15,7 +15,7 @@ import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useV
import { getProviderByModel } from '@renderer/services/AssistantService'
import { loggerService } from '@renderer/services/LoggerService'
import store from '@renderer/store'
-import type { Model, Provider } from '@renderer/types'
+import { isSystemProvider, type Model, type Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { cloneDeep, isEmpty } from 'lodash'
@@ -61,14 +61,16 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
// return createVertexProvider(provider)
// }
- if (provider.id === 'aihubmix') {
- return aihubmixProviderCreator(model, provider)
- }
- if (provider.id === 'newapi') {
- return newApiResolverCreator(model, provider)
- }
- if (provider.id === 'vertexai') {
- return vertexAnthropicProviderCreator(model, provider)
+ if (isSystemProvider(provider)) {
+ if (provider.id === 'aihubmix') {
+ return aihubmixProviderCreator(model, provider)
+ }
+ if (provider.id === 'new-api') {
+ return newApiResolverCreator(model, provider)
+ }
+ if (provider.id === 'vertexai') {
+ return vertexAnthropicProviderCreator(model, provider)
+ }
}
return provider
}
diff --git a/src/renderer/src/aiCore/provider/providerInitialization.ts b/src/renderer/src/aiCore/provider/providerInitialization.ts
index cf3366d70a..3c188313b9 100644
--- a/src/renderer/src/aiCore/provider/providerInitialization.ts
+++ b/src/renderer/src/aiCore/provider/providerInitialization.ts
@@ -39,6 +39,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
creatorFunctionName: 'createAmazonBedrock',
supportsImageGeneration: true,
aliases: ['aws-bedrock']
+ },
+ {
+ id: 'perplexity',
+ name: 'Perplexity',
+ import: () => import('@ai-sdk/perplexity'),
+ creatorFunctionName: 'createPerplexity',
+ supportsImageGeneration: false,
+ aliases: ['perplexity']
}
] as const
diff --git a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts
index 9c1101f49d..f56f650388 100644
--- a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts
+++ b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts
@@ -23,8 +23,6 @@ export const knowledgeSearchTool = (
Pre-extracted search queries: "${extractedKeywords.question.join(', ')}"
Rewritten query: "${extractedKeywords.rewrite}"
-This tool searches for relevant information and formats results for easy citation. The returned sources should be cited using [1], [2], etc. format in your response.
-
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
inputSchema: z.object({
@@ -35,99 +33,102 @@ Call this tool to execute the search. You can optionally provide additional cont
}),
execute: async ({ additionalContext }) => {
- try {
- // 获取助手的知识库配置
- const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id)
- const hasKnowledgeBase = !isEmpty(knowledgeBaseIds)
- const knowledgeRecognition = assistant.knowledgeRecognition || 'on'
+ // try {
+ // 获取助手的知识库配置
+ const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id)
+ const hasKnowledgeBase = !isEmpty(knowledgeBaseIds)
+ const knowledgeRecognition = assistant.knowledgeRecognition || 'on'
- // 检查是否有知识库
- if (!hasKnowledgeBase) {
- return {
- summary: 'No knowledge base configured for this assistant.',
- knowledgeReferences: [],
- instructions: ''
+ // 检查是否有知识库
+ if (!hasKnowledgeBase) {
+ return []
+ }
+
+ let finalQueries = [...extractedKeywords.question]
+ let finalRewrite = extractedKeywords.rewrite
+
+ if (additionalContext?.trim()) {
+ // 如果大模型提供了额外上下文,使用更具体的描述
+ const cleanContext = additionalContext.trim()
+ if (cleanContext) {
+ finalQueries = [cleanContext]
+ finalRewrite = cleanContext
+ }
+ }
+
+ // 检查是否需要搜索
+ if (finalQueries[0] === 'not_needed') {
+ return []
+ }
+
+ // 构建搜索条件
+ let searchCriteria: { question: string[]; rewrite: string }
+
+ if (knowledgeRecognition === 'off') {
+ // 直接模式:使用用户消息内容
+ const directContent = userMessage || finalQueries[0] || 'search'
+ searchCriteria = {
+ question: [directContent],
+ rewrite: directContent
+ }
+ } else {
+ // 自动模式:使用意图识别的结果
+ searchCriteria = {
+ question: finalQueries,
+ rewrite: finalRewrite
+ }
+ }
+
+ // 构建 ExtractResults 对象
+ const extractResults: ExtractResults = {
+ websearch: undefined,
+ knowledge: searchCriteria
+ }
+
+ // 执行知识库搜索
+ const knowledgeReferences = await processKnowledgeSearch(extractResults, knowledgeBaseIds, topicId)
+ const knowledgeReferencesData = knowledgeReferences.map((ref: KnowledgeReference) => ({
+ id: ref.id,
+ content: ref.content,
+ sourceUrl: ref.sourceUrl,
+ type: ref.type,
+ file: ref.file,
+ metadata: ref.metadata
+ }))
+
+ // TODO 在工具函数中添加搜索缓存机制
+ // const searchCacheKey = `${topicId}-${JSON.stringify(finalQueries)}`
+
+ // 返回结果
+ return knowledgeReferencesData
+ },
+ toModelOutput: (results) => {
+ let summary = 'No search needed based on the query analysis.'
+ if (results.length > 0) {
+ summary = `Found ${results.length} relevant sources. Use [number] format to cite specific information.`
+ }
+ const referenceContent = `\`\`\`json\n${JSON.stringify(results, null, 2)}\n\`\`\``
+ const fullInstructions = REFERENCE_PROMPT.replace(
+ '{question}',
+ "Based on the knowledge references, please answer the user's question with proper citations."
+ ).replace('{references}', referenceContent)
+
+ return {
+ type: 'content',
+ value: [
+ {
+ type: 'text',
+ text: 'This tool searches for relevant information and formats results for easy citation. The returned sources should be cited using [1], [2], etc. format in your response.'
+ },
+ {
+ type: 'text',
+ text: summary
+ },
+ {
+ type: 'text',
+ text: fullInstructions
}
- }
-
- let finalQueries = [...extractedKeywords.question]
- let finalRewrite = extractedKeywords.rewrite
-
- if (additionalContext?.trim()) {
- // 如果大模型提供了额外上下文,使用更具体的描述
- const cleanContext = additionalContext.trim()
- if (cleanContext) {
- finalQueries = [cleanContext]
- finalRewrite = cleanContext
- }
- }
-
- // 检查是否需要搜索
- if (finalQueries[0] === 'not_needed') {
- return {
- summary: 'No search needed based on the query analysis.',
- knowledgeReferences: [],
- instructions: ''
- }
- }
-
- // 构建搜索条件
- let searchCriteria: { question: string[]; rewrite: string }
-
- if (knowledgeRecognition === 'off') {
- // 直接模式:使用用户消息内容
- const directContent = userMessage || finalQueries[0] || 'search'
- searchCriteria = {
- question: [directContent],
- rewrite: directContent
- }
- } else {
- // 自动模式:使用意图识别的结果
- searchCriteria = {
- question: finalQueries,
- rewrite: finalRewrite
- }
- }
-
- // 构建 ExtractResults 对象
- const extractResults: ExtractResults = {
- websearch: undefined,
- knowledge: searchCriteria
- }
-
- // 执行知识库搜索
- const knowledgeReferences = await processKnowledgeSearch(extractResults, knowledgeBaseIds, topicId)
- const knowledgeReferencesData = knowledgeReferences.map((ref: KnowledgeReference) => ({
- id: ref.id,
- content: ref.content,
- sourceUrl: ref.sourceUrl,
- type: ref.type,
- file: ref.file,
- metadata: ref.metadata
- }))
-
- // const referenceContent = `\`\`\`json\n${JSON.stringify(knowledgeReferencesData, null, 2)}\n\`\`\``
- // TODO 在工具函数中添加搜索缓存机制
- // const searchCacheKey = `${topicId}-${JSON.stringify(finalQueries)}`
- // 可以在插件层面管理已搜索的查询,避免重复搜索
- const fullInstructions = REFERENCE_PROMPT.replace(
- '{question}',
- "Based on the knowledge references, please answer the user's question with proper citations."
- ).replace('{references}', 'knowledgeReferences:')
-
- // 返回结果
- return {
- summary: `Found ${knowledgeReferencesData.length} relevant sources. Use [number] format to cite specific information.`,
- knowledgeReferences: knowledgeReferencesData,
- instructions: fullInstructions
- }
- } catch (error) {
- // 返回空对象而不是抛出错误,避免中断对话流程
- return {
- summary: `Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
- knowledgeReferences: [],
- instructions: ''
- }
+ ]
}
}
})
diff --git a/src/renderer/src/aiCore/tools/MemorySearchTool.ts b/src/renderer/src/aiCore/tools/MemorySearchTool.ts
index 6430692d4c..8e67363595 100644
--- a/src/renderer/src/aiCore/tools/MemorySearchTool.ts
+++ b/src/renderer/src/aiCore/tools/MemorySearchTool.ts
@@ -1,6 +1,5 @@
import store from '@renderer/store'
import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory'
-import type { Assistant } from '@renderer/types'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
@@ -19,133 +18,29 @@ export const memorySearchTool = () => {
limit: z.number().min(1).max(20).default(5).describe('Maximum number of memories to return')
}),
execute: async ({ query, limit = 5 }) => {
- // console.log('🧠 [memorySearchTool] Searching memories:', { query, limit })
-
- try {
- const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState())
- if (!globalMemoryEnabled) {
- return []
- }
-
- const memoryConfig = selectMemoryConfig(store.getState())
- if (!memoryConfig.llmApiClient || !memoryConfig.embedderApiClient) {
- // console.warn('Memory search skipped: embedding or LLM model not configured')
- return []
- }
-
- const currentUserId = selectCurrentUserId(store.getState())
- const processorConfig = MemoryProcessor.getProcessorConfig(memoryConfig, 'default', currentUserId)
-
- const memoryProcessor = new MemoryProcessor()
- const relevantMemories = await memoryProcessor.searchRelevantMemories(query, processorConfig, limit)
-
- if (relevantMemories?.length > 0) {
- // console.log('🧠 [memorySearchTool] Found memories:', relevantMemories.length)
- return relevantMemories
- }
- return []
- } catch (error) {
- // console.error('🧠 [memorySearchTool] Error:', error)
+ const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState())
+ if (!globalMemoryEnabled) {
return []
}
+
+ const memoryConfig = selectMemoryConfig(store.getState())
+ if (!memoryConfig.llmApiClient || !memoryConfig.embedderApiClient) {
+ return []
+ }
+
+ const currentUserId = selectCurrentUserId(store.getState())
+ const processorConfig = MemoryProcessor.getProcessorConfig(memoryConfig, 'default', currentUserId)
+
+ const memoryProcessor = new MemoryProcessor()
+ const relevantMemories = await memoryProcessor.searchRelevantMemories(query, processorConfig, limit)
+
+ if (relevantMemories?.length > 0) {
+ return relevantMemories
+ }
+ return []
}
})
}
-// 方案4: 为第二个工具也使用类型断言
-type MessageRole = 'user' | 'assistant' | 'system'
-type MessageType = {
- content: string
- role: MessageRole
-}
-type MemorySearchWithExtractionInput = {
- userMessage: MessageType
- lastAnswer?: MessageType
-}
-
-/**
- * 🧠 智能记忆搜索工具(带上下文提取)
- * 从用户消息和对话历史中自动提取关键词进行记忆搜索
- */
-export const memorySearchToolWithExtraction = (assistant: Assistant) => {
- return tool({
- name: 'memory_search_with_extraction',
- description: 'Search memories with automatic keyword extraction from conversation context',
- inputSchema: z.object({
- userMessage: z.object({
- content: z.string().describe('The main content of the user message'),
- role: z.enum(['user', 'assistant', 'system']).describe('Message role')
- }),
- lastAnswer: z
- .object({
- content: z.string().describe('The main content of the last assistant response'),
- role: z.enum(['user', 'assistant', 'system']).describe('Message role')
- })
- .optional()
- }) as z.ZodSchema,
- execute: async ({ userMessage }) => {
- // console.log('🧠 [memorySearchToolWithExtraction] Processing:', { userMessage, lastAnswer })
-
- try {
- const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState())
- if (!globalMemoryEnabled || !assistant.enableMemory) {
- return {
- extractedKeywords: 'Memory search disabled',
- searchResults: []
- }
- }
-
- const memoryConfig = selectMemoryConfig(store.getState())
- if (!memoryConfig.llmApiClient || !memoryConfig.embedderApiClient) {
- // console.warn('Memory search skipped: embedding or LLM model not configured')
- return {
- extractedKeywords: 'Memory models not configured',
- searchResults: []
- }
- }
-
- // 🔍 使用用户消息内容作为搜索关键词
- const content = userMessage.content
-
- if (!content) {
- return {
- extractedKeywords: 'No content to search',
- searchResults: []
- }
- }
-
- const currentUserId = selectCurrentUserId(store.getState())
- const processorConfig = MemoryProcessor.getProcessorConfig(memoryConfig, assistant.id, currentUserId)
-
- const memoryProcessor = new MemoryProcessor()
- const relevantMemories = await memoryProcessor.searchRelevantMemories(
- content,
- processorConfig,
- 5 // Limit to top 5 most relevant memories
- )
-
- if (relevantMemories?.length > 0) {
- // console.log('🧠 [memorySearchToolWithExtraction] Found memories:', relevantMemories.length)
- return {
- extractedKeywords: content,
- searchResults: relevantMemories
- }
- }
-
- return {
- extractedKeywords: content,
- searchResults: []
- }
- } catch (error) {
- // console.error('🧠 [memorySearchToolWithExtraction] Error:', error)
- return {
- extractedKeywords: 'Search failed',
- searchResults: []
- }
- }
- }
- })
-}
export type MemorySearchToolInput = InferToolInput>
export type MemorySearchToolOutput = InferToolOutput>
-export type MemorySearchToolWithExtractionOutput = InferToolOutput>
diff --git a/src/renderer/src/aiCore/tools/WebSearchTool.ts b/src/renderer/src/aiCore/tools/WebSearchTool.ts
index 28c2cf19a3..77260f9bb8 100644
--- a/src/renderer/src/aiCore/tools/WebSearchTool.ts
+++ b/src/renderer/src/aiCore/tools/WebSearchTool.ts
@@ -30,8 +30,6 @@ Relevant links: ${extractedKeywords.links.join(', ')}`
: ''
}
-This tool searches for relevant information and formats results for easy citation. The returned sources should be cited using [1], [2], etc. format in your response.
-
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
inputSchema: z.object({
@@ -58,40 +56,27 @@ Call this tool to execute the search. You can optionally provide additional cont
}
// 检查是否需要搜索
if (finalQueries[0] === 'not_needed') {
- return {
- summary: 'No search needed based on the query analysis.',
- searchResults,
- sources: '',
- instructions: ''
- }
+ return searchResults
}
- try {
- // 构建 ExtractResults 结构用于 processWebsearch
- const extractResults: ExtractResults = {
- websearch: {
- question: finalQueries,
- links: extractedKeywords.links
- }
- }
- searchResults = await WebSearchService.processWebsearch(webSearchProvider!, extractResults, requestId)
- } catch (error) {
- return {
- summary: `Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
- sources: [],
- instructions: ''
+ // 构建 ExtractResults 结构用于 processWebsearch
+ const extractResults: ExtractResults = {
+ websearch: {
+ question: finalQueries,
+ links: extractedKeywords.links
}
}
- if (searchResults.results.length === 0) {
- return {
- summary: 'No search results found for the given query.',
- sources: [],
- instructions: ''
- }
+ searchResults = await WebSearchService.processWebsearch(webSearchProvider!, extractResults, requestId)
+
+ return searchResults
+ },
+ toModelOutput: (results) => {
+ let summary = 'No search needed based on the query analysis.'
+ if (results.query && results.results.length > 0) {
+ summary = `Found ${results.results.length} relevant sources. Use [number] format to cite specific information.`
}
- const results = searchResults.results
- const citationData = results.map((result, index) => ({
+ const citationData = results.results.map((result, index) => ({
number: index + 1,
title: result.title,
content: result.content,
@@ -99,18 +84,27 @@ Call this tool to execute the search. You can optionally provide additional cont
}))
// 🔑 返回引用友好的格式,复用 REFERENCE_PROMPT 逻辑
- // const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
-
- // 构建完整的引用指导文本
+ const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
const fullInstructions = REFERENCE_PROMPT.replace(
'{question}',
"Based on the search results, please answer the user's question with proper citations."
- ).replace('{references}', 'searchResults:')
-
+ ).replace('{references}', referenceContent)
return {
- summary: `Found ${citationData.length} relevant sources. Use [number] format to cite specific information.`,
- searchResults,
- instructions: fullInstructions
+ type: 'content',
+ value: [
+ {
+ type: 'text',
+ text: 'This tool searches for relevant information and formats results for easy citation. The returned sources should be cited using [1], [2], etc. format in your response.'
+ },
+ {
+ type: 'text',
+ text: summary
+ },
+ {
+ type: 'text',
+ text: fullInstructions
+ }
+ ]
}
}
})
diff --git a/src/renderer/src/aiCore/utils/mcp.ts b/src/renderer/src/aiCore/utils/mcp.ts
index 01f7adbf50..e7f6b5a393 100644
--- a/src/renderer/src/aiCore/utils/mcp.ts
+++ b/src/renderer/src/aiCore/utils/mcp.ts
@@ -1,7 +1,5 @@
import { loggerService } from '@logger'
-// import { AiSdkTool, ToolCallResult } from '@renderer/aiCore/tools/types'
import { MCPTool, MCPToolResponse } from '@renderer/types'
-import { Chunk, ChunkType } from '@renderer/types/chunk'
import { callMCPTool, getMcpServerByTool, isToolAutoApproved } from '@renderer/utils/mcp-tools'
import { requestToolConfirmation } from '@renderer/utils/userConfirmation'
import { type Tool, type ToolSet } from 'ai'
@@ -33,8 +31,36 @@ export function convertMcpToolsToAiSdkTools(mcpTools: MCPTool[]): ToolSet {
tools[mcpTool.name] = tool({
description: mcpTool.description || `Tool from ${mcpTool.serverName}`,
inputSchema: jsonSchema(mcpTool.inputSchema as JSONSchema7),
- execute: async (params, { toolCallId, experimental_context }) => {
- const { onChunk } = experimental_context as { onChunk: (chunk: Chunk) => void }
+ execute: async (params, { toolCallId }) => {
+ // 检查是否启用自动批准
+ const server = getMcpServerByTool(mcpTool)
+ const isAutoApproveEnabled = isToolAutoApproved(mcpTool, server)
+
+ let confirmed = true
+
+ if (!isAutoApproveEnabled) {
+ // 请求用户确认
+ logger.debug(`Requesting user confirmation for tool: ${mcpTool.name}`)
+ confirmed = await requestToolConfirmation(toolCallId)
+ }
+
+ if (!confirmed) {
+ // 用户拒绝执行工具
+ logger.debug(`User cancelled tool execution: ${mcpTool.name}`)
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `User declined to execute tool "${mcpTool.name}".`
+ }
+ ],
+ isError: false
+ }
+ }
+
+ // 用户确认或自动批准,执行工具
+ logger.debug(`Executing tool: ${mcpTool.name}`)
+
// 创建适配的 MCPToolResponse 对象
const toolResponse: MCPToolResponse = {
id: toolCallId,
@@ -44,53 +70,18 @@ export function convertMcpToolsToAiSdkTools(mcpTools: MCPTool[]): ToolSet {
toolCallId
}
- try {
- // 检查是否启用自动批准
- const server = getMcpServerByTool(mcpTool)
- const isAutoApproveEnabled = isToolAutoApproved(mcpTool, server)
+ const result = await callMCPTool(toolResponse)
- let confirmed = true
- if (!isAutoApproveEnabled) {
- // 请求用户确认
- logger.debug(`Requesting user confirmation for tool: ${mcpTool.name}`)
- confirmed = await requestToolConfirmation(toolResponse.id)
- }
-
- if (!confirmed) {
- // 用户拒绝执行工具
- logger.debug(`User cancelled tool execution: ${mcpTool.name}`)
- return {
- content: [
- {
- type: 'text',
- text: `User declined to execute tool "${mcpTool.name}".`
- }
- ],
- isError: false
- }
- }
-
- // 用户确认或自动批准,执行工具
- toolResponse.status = 'invoking'
- logger.debug(`Executing tool: ${mcpTool.name}`)
-
- onChunk({
- type: ChunkType.MCP_TOOL_IN_PROGRESS,
- responses: [toolResponse]
- })
-
- const result = await callMCPTool(toolResponse)
-
- // 返回结果,AI SDK 会处理序列化
- if (result.isError) {
- throw new Error(result.content?.[0]?.text || 'Tool execution failed')
- }
- // 返回工具执行结果
- return result
- } catch (error) {
- logger.error(`MCP Tool execution failed: ${mcpTool.name}`, { error })
- throw error
+ // 返回结果,AI SDK 会处理序列化
+ if (result.isError) {
+ // throw new Error(result.content?.[0]?.text || 'Tool execution failed')
+ return Promise.reject(result)
}
+ // 返回工具执行结果
+ return result
+ // } catch (error) {
+ // logger.error(`MCP Tool execution failed: ${mcpTool.name}`, { error })
+ // }
}
})
}
diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts
index a244619264..f85c8c7879 100644
--- a/src/renderer/src/aiCore/utils/options.ts
+++ b/src/renderer/src/aiCore/utils/options.ts
@@ -120,6 +120,9 @@ export function buildProviderOptions(
case 'google-vertex':
providerSpecificOptions = buildGeminiProviderOptions(assistant, model, capabilities)
break
+ case 'google-vertex-anthropic':
+ providerSpecificOptions = buildAnthropicProviderOptions(assistant, model, capabilities)
+ break
default:
// 对于其他 provider,使用通用的构建逻辑
providerSpecificOptions = {
@@ -137,10 +140,16 @@ export function buildProviderOptions(
...providerSpecificOptions,
...getCustomParameters(assistant)
}
+ // vertex需要映射到google或anthropic
+ const rawProviderKey =
+ {
+ 'google-vertex': 'google',
+ 'google-vertex-anthropic': 'anthropic'
+ }[rawProviderId] || rawProviderId
// 返回 AI Core SDK 要求的格式:{ 'providerId': providerOptions }
return {
- [rawProviderId]: providerSpecificOptions
+ [rawProviderKey]: providerSpecificOptions
}
}
diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts
index 507b2cd9ce..385d8183c5 100644
--- a/src/renderer/src/aiCore/utils/reasoning.ts
+++ b/src/renderer/src/aiCore/utils/reasoning.ts
@@ -310,6 +310,26 @@ export function getOpenAIReasoningParams(assistant: Assistant, model: Model): Re
return {}
}
+export function getAnthropicThinkingBudget(assistant: Assistant, model: Model): number {
+ const { maxTokens, reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
+ if (maxTokens === undefined || reasoningEffort === undefined) {
+ return 0
+ }
+ const effortRatio = EFFORT_RATIO[reasoningEffort]
+
+ const budgetTokens = Math.max(
+ 1024,
+ Math.floor(
+ Math.min(
+ (findTokenLimit(model.id)?.max! - findTokenLimit(model.id)?.min!) * effortRatio +
+ findTokenLimit(model.id)?.min!,
+ (maxTokens || DEFAULT_MAX_TOKENS) * effortRatio
+ )
+ )
+ )
+ return budgetTokens
+}
+
/**
* 获取 Anthropic 推理参数
* 从 AnthropicAPIClient 中提取的逻辑
@@ -331,19 +351,7 @@ export function getAnthropicReasoningParams(assistant: Assistant, model: Model):
// Claude 推理参数
if (isSupportedThinkingTokenClaudeModel(model)) {
- const { maxTokens } = getAssistantSettings(assistant)
- const effortRatio = EFFORT_RATIO[reasoningEffort]
-
- const budgetTokens = Math.max(
- 1024,
- Math.floor(
- Math.min(
- (findTokenLimit(model.id)?.max! - findTokenLimit(model.id)?.min!) * effortRatio +
- findTokenLimit(model.id)?.min!,
- (maxTokens || DEFAULT_MAX_TOKENS) * effortRatio
- )
- )
- )
+ const budgetTokens = getAnthropicThinkingBudget(assistant, model)
return {
thinking: {
diff --git a/src/renderer/src/assets/images/banner.png b/src/renderer/src/assets/images/banner.png
deleted file mode 100644
index e29198cf82..0000000000
Binary files a/src/renderer/src/assets/images/banner.png and /dev/null differ
diff --git a/src/renderer/src/assets/styles/CommandListPopover.css b/src/renderer/src/assets/styles/CommandListPopover.css
new file mode 100644
index 0000000000..a1bb27ec57
--- /dev/null
+++ b/src/renderer/src/assets/styles/CommandListPopover.css
@@ -0,0 +1,60 @@
+.command-list-popover {
+ /* Base styles are handled inline for theme support */
+
+ /* Arrow styles based on placement */
+}
+
+.command-list-popover[data-placement^='bottom'] {
+ transform-origin: top center;
+ animation: slideDownAndFadeIn 0.15s ease-out;
+}
+
+.command-list-popover[data-placement^='top'] {
+ transform-origin: bottom center;
+ animation: slideUpAndFadeIn 0.15s ease-out;
+}
+
+.command-list-popover[data-placement*='start'] {
+ transform-origin: left center;
+}
+
+.command-list-popover[data-placement*='end'] {
+ transform-origin: right center;
+}
+
+@keyframes slideDownAndFadeIn {
+ 0% {
+ opacity: 0;
+ transform: translateY(-8px) scale(0.95);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes slideUpAndFadeIn {
+ 0% {
+ opacity: 0;
+ transform: translateY(8px) scale(0.95);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+/* Ensure smooth scrolling in virtual list */
+.command-list-popover .dynamic-virtual-list {
+ scroll-behavior: smooth;
+}
+
+/* Better focus indicators */
+.command-list-popover [data-index] {
+ position: relative;
+}
+
+.command-list-popover [data-index]:focus-visible {
+ outline: 2px solid var(--color-primary, #1677ff);
+ outline-offset: -2px;
+}
diff --git a/src/renderer/src/assets/styles/CommandListPopover.scss b/src/renderer/src/assets/styles/CommandListPopover.scss
deleted file mode 100644
index e2521c57b3..0000000000
--- a/src/renderer/src/assets/styles/CommandListPopover.scss
+++ /dev/null
@@ -1,59 +0,0 @@
-.command-list-popover {
- // Base styles are handled inline for theme support
-
- // Arrow styles based on placement
- &[data-placement^='bottom'] {
- transform-origin: top center;
- animation: slideDownAndFadeIn 0.15s ease-out;
- }
-
- &[data-placement^='top'] {
- transform-origin: bottom center;
- animation: slideUpAndFadeIn 0.15s ease-out;
- }
-
- &[data-placement*='start'] {
- transform-origin: left center;
- }
-
- &[data-placement*='end'] {
- transform-origin: right center;
- }
-}
-
-@keyframes slideDownAndFadeIn {
- 0% {
- opacity: 0;
- transform: translateY(-8px) scale(0.95);
- }
- 100% {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-}
-
-@keyframes slideUpAndFadeIn {
- 0% {
- opacity: 0;
- transform: translateY(8px) scale(0.95);
- }
- 100% {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-}
-
-// Ensure smooth scrolling in virtual list
-.command-list-popover .dynamic-virtual-list {
- scroll-behavior: smooth;
-}
-
-// Better focus indicators
-.command-list-popover [data-index] {
- position: relative;
-
- &:focus-visible {
- outline: 2px solid var(--color-primary, #1677ff);
- outline-offset: -2px;
- }
-}
diff --git a/src/renderer/src/assets/styles/animation.scss b/src/renderer/src/assets/styles/animation.css
similarity index 94%
rename from src/renderer/src/assets/styles/animation.scss
rename to src/renderer/src/assets/styles/animation.css
index 9b8b428c5c..13ca0be75b 100644
--- a/src/renderer/src/assets/styles/animation.scss
+++ b/src/renderer/src/assets/styles/animation.css
@@ -10,14 +10,14 @@
}
}
-// 电磁波扩散效果
+/* 电磁波扩散效果 */
.animation-pulse {
--pulse-color: 59, 130, 246;
--pulse-size: 8px;
animation: animation-pulse 1.5s infinite;
}
-// Modal动画
+/* Modal动画 */
@keyframes animation-move-down-in {
0% {
transform: translate3d(0, 100%, 0);
@@ -54,7 +54,7 @@
animation-duration: 0.25s;
}
-// 旋转动画
+/* 旋转动画 */
@keyframes animation-rotate {
from {
transform: rotate(0deg);
@@ -69,7 +69,7 @@
animation: animation-rotate 0.75s linear infinite;
}
-// 定位高亮动画
+/* 定位高亮动画 */
@keyframes animation-locate-highlight {
0% {
background-color: transparent;
diff --git a/src/renderer/src/assets/styles/ant.css b/src/renderer/src/assets/styles/ant.css
new file mode 100644
index 0000000000..30005ff738
--- /dev/null
+++ b/src/renderer/src/assets/styles/ant.css
@@ -0,0 +1,238 @@
+@import './container.css';
+
+/* Modal 关闭按钮不应该可拖拽,以确保点击正常 */
+.ant-modal-close {
+ -webkit-app-region: no-drag;
+}
+
+/* 普通 Drawer 内容不应该可拖拽 */
+.ant-drawer-content {
+ -webkit-app-region: no-drag;
+}
+
+/* minapp-drawer 有自己的拖拽规则 */
+
+/* 下拉菜单和弹出框内容不应该可拖拽 */
+.ant-dropdown,
+.ant-dropdown-menu,
+.ant-popover-content,
+.ant-tooltip-content,
+.ant-popconfirm {
+ -webkit-app-region: no-drag;
+}
+
+#inputbar {
+ resize: none;
+}
+
+.ant-image-preview-switch-left {
+ -webkit-app-region: no-drag;
+}
+
+.ant-btn:not(:disabled):focus-visible {
+ outline: none;
+}
+
+/* Align lucide icon in Button */
+.ant-btn .ant-btn-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.ant-tabs-tabpane:focus-visible {
+ outline: none;
+}
+
+.ant-tabs-tab-btn {
+ outline: none !important;
+}
+
+.ant-segmented-group {
+ gap: 4px;
+}
+
+.minapp-drawer .ant-drawer-content-wrapper {
+ box-shadow: none;
+}
+
+.minapp-drawer .ant-drawer-header {
+ position: absolute;
+ -webkit-app-region: drag;
+ min-height: calc(var(--navbar-height) + 0.5px);
+ margin-top: -0.5px;
+ border-bottom: none;
+}
+
+.minapp-drawer .ant-drawer-body {
+ padding: 0;
+ margin-top: var(--navbar-height);
+ overflow: hidden;
+ /* 手动展开 @extend #content-container 的内容 */
+ background-color: var(--color-background);
+}
+
+.minapp-drawer .minapp-mask {
+ background-color: transparent !important;
+}
+
+[navbar-position='left'] .minapp-drawer {
+ max-width: calc(100vw - var(--sidebar-width));
+}
+
+[navbar-position='left'] .minapp-drawer .ant-drawer-header {
+ width: calc(100vw - var(--sidebar-width));
+}
+
+[navbar-position='top'] .minapp-drawer {
+ max-width: 100vw;
+}
+
+[navbar-position='top'] .minapp-drawer .ant-drawer-header {
+ width: 100vw;
+}
+
+.ant-drawer-header {
+ /* 普通 drawer header 不应该可拖拽,除非被 minapp-drawer 覆盖 */
+ -webkit-app-region: no-drag;
+}
+
+.message-attachments .ant-upload-list-item:hover {
+ background-color: initial !important;
+}
+
+.ant-dropdown-menu .ant-dropdown-menu-sub {
+ max-height: 80vh;
+ width: max-content;
+ overflow-y: auto;
+ overflow-x: hidden;
+ border: 0.5px solid var(--color-border);
+}
+
+.ant-dropdown {
+ background-color: var(--ant-color-bg-elevated);
+ overflow: hidden;
+ border-radius: var(--ant-border-radius-lg);
+ user-select: none;
+}
+
+.ant-dropdown .ant-dropdown-menu {
+ max-height: 80vh;
+ overflow-y: auto;
+ border: 0.5px solid var(--color-border);
+}
+
+/* Align lucide icon in dropdown menu item extra */
+.ant-dropdown .ant-dropdown-menu .ant-dropdown-menu-submenu-expand-icon,
+.ant-dropdown .ant-dropdown-menu .ant-dropdown-menu-item-extra {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.ant-dropdown .ant-dropdown-arrow + .ant-dropdown-menu {
+ border: none;
+}
+
+.ant-select-dropdown {
+ border: 0.5px solid var(--color-border);
+}
+
+.ant-dropdown-menu-submenu {
+ background-color: var(--ant-color-bg-elevated);
+ overflow: hidden;
+ border-radius: var(--ant-border-radius-lg);
+}
+
+.ant-dropdown-menu-submenu .ant-dropdown-menu-submenu-title {
+ align-items: center;
+}
+
+.ant-popover .ant-popover-inner {
+ border: 0.5px solid var(--color-border);
+}
+
+.ant-popover .ant-popover-inner .ant-popover-inner-content {
+ max-height: 70vh;
+ overflow-y: auto;
+}
+
+.ant-popover .ant-popover-arrow + .ant-popover-content .ant-popover-inner {
+ border: none;
+}
+
+.ant-modal:not(.ant-modal-confirm) .ant-modal-confirm-body-has-title {
+ padding: 16px 0 0 0;
+}
+
+.ant-modal:not(.ant-modal-confirm) .ant-modal-content {
+ border-radius: 10px;
+ border: 0.5px solid var(--color-border);
+ padding: 0 0 8px 0;
+}
+
+.ant-modal:not(.ant-modal-confirm) .ant-modal-content .ant-modal-close {
+ margin-right: 2px;
+}
+
+.ant-modal:not(.ant-modal-confirm) .ant-modal-content .ant-modal-header {
+ padding: 16px 16px 0 16px;
+ border-radius: 10px;
+}
+
+.ant-modal:not(.ant-modal-confirm) .ant-modal-content .ant-modal-body {
+ /* 保持 body 在视口内,使用标准的最大高度 */
+ max-height: 80vh;
+ overflow-y: auto;
+ padding: 0 16px 0 16px;
+}
+
+.ant-modal:not(.ant-modal-confirm) .ant-modal-content .ant-modal-footer {
+ padding: 0 16px 8px 16px;
+}
+
+.ant-modal:not(.ant-modal-confirm) .ant-modal-content .ant-modal-confirm-btns {
+ margin-bottom: 8px;
+}
+
+.ant-modal.ant-modal-confirm.ant-modal-confirm-confirm .ant-modal-content {
+ padding: 16px;
+}
+
+.ant-collapse:not(.ant-collapse-ghost) {
+ border: 1px solid var(--color-border);
+}
+
+.ant-color-picker .ant-collapse:not(.ant-collapse-ghost) {
+ border: none;
+}
+
+.ant-collapse:not(.ant-collapse-ghost) .ant-collapse-content {
+ border-top: 0.5px solid var(--color-border) !important;
+}
+
+.ant-color-picker .ant-collapse:not(.ant-collapse-ghost) .ant-collapse-content {
+ border-top: none !important;
+}
+
+.ant-slider .ant-slider-handle::after {
+ box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
+}
+
+.ant-splitter-bar .ant-splitter-bar-dragger::before {
+ background-color: var(--color-border) !important;
+ transition:
+ background-color 0.15s ease,
+ width 0.15s ease;
+}
+
+.ant-splitter-bar .ant-splitter-bar-dragger:hover::before {
+ width: 4px !important;
+ background-color: var(--color-primary) !important;
+ transition-delay: 0.15s;
+}
+
+.ant-splitter-bar .ant-splitter-bar-dragger-active::before {
+ width: 4px !important;
+ background-color: var(--color-primary) !important;
+}
diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss
deleted file mode 100644
index ad6d1e0eaf..0000000000
--- a/src/renderer/src/assets/styles/ant.scss
+++ /dev/null
@@ -1,234 +0,0 @@
-@use './container.scss';
-
-/* Modal 关闭按钮不应该可拖拽,以确保点击正常 */
-.ant-modal-close {
- -webkit-app-region: no-drag;
-}
-
-/* 普通 Drawer 内容不应该可拖拽 */
-.ant-drawer-content {
- -webkit-app-region: no-drag;
-}
-
-/* minapp-drawer 有自己的拖拽规则 */
-
-/* 下拉菜单和弹出框内容不应该可拖拽 */
-.ant-dropdown,
-.ant-dropdown-menu,
-.ant-popover-content,
-.ant-tooltip-content,
-.ant-popconfirm {
- -webkit-app-region: no-drag;
-}
-
-#inputbar {
- resize: none;
-}
-
-.ant-image-preview-switch-left {
- -webkit-app-region: no-drag;
-}
-
-.ant-btn:not(:disabled):focus-visible {
- outline: none;
-}
-
-// Align lucide icon in Button
-.ant-btn .ant-btn-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
-}
-
-.ant-tabs-tabpane:focus-visible {
- outline: none;
-}
-
-.ant-tabs-tab-btn {
- outline: none !important;
-}
-
-.ant-segmented-group {
- gap: 4px;
-}
-
-.minapp-drawer {
- [navbar-position='left'] & {
- max-width: calc(100vw - var(--sidebar-width));
- .ant-drawer-header {
- width: calc(100vw - var(--sidebar-width));
- }
- }
- [navbar-position='top'] & {
- max-width: 100vw;
- .ant-drawer-header {
- width: 100vw;
- }
- }
- .ant-drawer-content-wrapper {
- box-shadow: none;
- }
- .ant-drawer-header {
- position: absolute;
- -webkit-app-region: drag;
- min-height: calc(var(--navbar-height) + 0.5px);
- margin-top: -0.5px;
- border-bottom: none;
- }
- .ant-drawer-body {
- padding: 0;
- margin-top: var(--navbar-height);
- overflow: hidden;
- @extend #content-container;
- }
- .minapp-mask {
- background-color: transparent !important;
- }
-}
-
-.ant-drawer-header {
- /* 普通 drawer header 不应该可拖拽,除非被 minapp-drawer 覆盖 */
- -webkit-app-region: no-drag;
-}
-
-.message-attachments {
- .ant-upload-list-item:hover {
- background-color: initial !important;
- }
-}
-
-.ant-dropdown-menu .ant-dropdown-menu-sub {
- max-height: 80vh;
- width: max-content;
- overflow-y: auto;
- overflow-x: hidden;
- border: 0.5px solid var(--color-border);
-}
-.ant-dropdown {
- background-color: var(--ant-color-bg-elevated);
- overflow: hidden;
- border-radius: var(--ant-border-radius-lg);
- user-select: none;
- .ant-dropdown-menu {
- max-height: 80vh;
- overflow-y: auto;
- border: 0.5px solid var(--color-border);
-
- // Align lucide icon in dropdown menu item extra
- .ant-dropdown-menu-submenu-expand-icon,
- .ant-dropdown-menu-item-extra {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- }
- }
- .ant-dropdown-arrow + .ant-dropdown-menu {
- border: none;
- }
-}
-.ant-select-dropdown {
- border: 0.5px solid var(--color-border);
-}
-.ant-dropdown-menu-submenu {
- background-color: var(--ant-color-bg-elevated);
- overflow: hidden;
- border-radius: var(--ant-border-radius-lg);
-
- .ant-dropdown-menu-submenu-title {
- align-items: center;
- }
-}
-
-.ant-popover {
- .ant-popover-inner {
- border: 0.5px solid var(--color-border);
- .ant-popover-inner-content {
- max-height: 70vh;
- overflow-y: auto;
- }
- }
- .ant-popover-arrow + .ant-popover-content {
- .ant-popover-inner {
- border: none;
- }
- }
-}
-
-.ant-modal:not(.ant-modal-confirm) {
- .ant-modal-confirm-body-has-title {
- padding: 16px 0 0 0;
- }
- .ant-modal-content {
- border-radius: 10px;
- border: 0.5px solid var(--color-border);
- padding: 0 0 8px 0;
- .ant-modal-close {
- margin-right: 2px;
- }
- .ant-modal-header {
- padding: 16px 16px 0 16px;
- border-radius: 10px;
- }
- .ant-modal-body {
- /* 保持 body 在视口内,使用标准的最大高度 */
- max-height: 80vh;
- overflow-y: auto;
- padding: 0 16px 0 16px;
- }
- .ant-modal-footer {
- padding: 0 16px 8px 16px;
- }
- .ant-modal-confirm-btns {
- margin-bottom: 8px;
- }
- }
-}
-.ant-modal.ant-modal-confirm.ant-modal-confirm-confirm {
- .ant-modal-content {
- padding: 16px;
- }
-}
-
-.ant-collapse:not(.ant-collapse-ghost) {
- border: 1px solid var(--color-border);
- .ant-color-picker & {
- border: none;
- }
- .ant-collapse-content {
- border-top: 0.5px solid var(--color-border) !important;
- .ant-color-picker & {
- border-top: none !important;
- }
- }
-}
-
-.ant-slider {
- .ant-slider-handle::after {
- box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
- }
-}
-
-.ant-splitter-bar {
- .ant-splitter-bar-dragger {
- &::before {
- background-color: var(--color-border) !important;
- transition:
- background-color 0.15s ease,
- width 0.15s ease;
- }
- &:hover {
- &::before {
- width: 4px !important;
- background-color: var(--color-primary) !important;
- transition-delay: 0.15s;
- }
- }
- }
-
- .ant-splitter-bar-dragger-active {
- &::before {
- width: 4px !important;
- background-color: var(--color-primary) !important;
- }
- }
-}
diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.css
similarity index 91%
rename from src/renderer/src/assets/styles/color.scss
rename to src/renderer/src/assets/styles/color.css
index 517160e76c..5d625937e7 100644
--- a/src/renderer/src/assets/styles/color.scss
+++ b/src/renderer/src/assets/styles/color.css
@@ -19,7 +19,7 @@
--color-background-soft: var(--color-black-soft);
--color-background-mute: var(--color-black-mute);
--color-background-opacity: rgba(34, 34, 34, 0.7);
- --inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu
+ --inner-glow-opacity: 0.3; /* For the glassmorphism effect in the dropdown menu */
--color-primary: #00b96b;
--color-primary-soft: #00b96b99;
@@ -58,16 +58,6 @@
--navbar-background-mac: rgba(20, 20, 20, 0.55);
--navbar-background: #1f1f1f;
- --navbar-height: 44px;
- --sidebar-width: 50px;
- --status-bar-height: 40px;
- --input-bar-height: 100px;
-
- --assistants-width: 275px;
- --topic-list-width: 275px;
- --settings-width: 250px;
- --scrollbar-width: 5px;
-
--chat-background: transparent;
--chat-background-user: rgba(255, 255, 255, 0.08);
--chat-background-assistant: transparent;
@@ -146,11 +136,6 @@
--chat-text-user: var(--color-text);
}
-[navbar-position='left'] {
- --navbar-height: 42px;
- --list-item-border-radius: 20px;
-}
-
[navbar-position='left'][theme-mode='light'] {
--color-list-item: #eee;
--color-list-item-hover: #f5f5f5;
diff --git a/src/renderer/src/assets/styles/container.css b/src/renderer/src/assets/styles/container.css
new file mode 100644
index 0000000000..a8a1852e64
--- /dev/null
+++ b/src/renderer/src/assets/styles/container.css
@@ -0,0 +1,9 @@
+#content-container {
+ background-color: var(--color-background);
+}
+
+[navbar-position='left'] #content-container {
+ border-top: 0.5px solid var(--color-border);
+ border-top-left-radius: 10px;
+ border-left: 0.5px solid var(--color-border);
+}
diff --git a/src/renderer/src/assets/styles/container.scss b/src/renderer/src/assets/styles/container.scss
deleted file mode 100644
index fd2d7f9aec..0000000000
--- a/src/renderer/src/assets/styles/container.scss
+++ /dev/null
@@ -1,11 +0,0 @@
-#content-container {
- background-color: var(--color-background);
-}
-
-[navbar-position='left'] {
- #content-container {
- border-top: 0.5px solid var(--color-border);
- border-top-left-radius: 10px;
- border-left: 0.5px solid var(--color-border);
- }
-}
diff --git a/src/renderer/src/assets/styles/font.css b/src/renderer/src/assets/styles/font.css
new file mode 100644
index 0000000000..8e122fe887
--- /dev/null
+++ b/src/renderer/src/assets/styles/font.css
@@ -0,0 +1,24 @@
+:root {
+ --font-family:
+ var(--user-font-family), Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
+ Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
+ 'Segoe UI Symbol', 'Noto Color Emoji';
+
+ --font-family-serif:
+ serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
+ 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+
+ --code-font-family: var(--user-code-font-family), 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
+}
+
+/* Windows系统专用字体配置 */
+body[os='windows'] {
+ --font-family:
+ var(--user-font-family), 'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui,
+ Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
+ 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+
+ --code-font-family:
+ var(--user-code-font-family), 'Cascadia Code', 'Fira Code', 'Consolas', 'Sarasa Mono SC', 'Microsoft YaHei UI',
+ Courier, monospace;
+}
diff --git a/src/renderer/src/assets/styles/font.scss b/src/renderer/src/assets/styles/font.scss
deleted file mode 100644
index 02d8fee660..0000000000
--- a/src/renderer/src/assets/styles/font.scss
+++ /dev/null
@@ -1,23 +0,0 @@
-:root {
- --font-family:
- Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
- 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
- 'Noto Color Emoji';
-
- --font-family-serif:
- serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
- 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
-
- --code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
-}
-
-// Windows系统专用字体配置
-body[os='windows'] {
- --font-family:
- 'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
- Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
- 'Segoe UI Symbol', 'Noto Color Emoji';
-
- --code-font-family:
- 'Cascadia Code', 'Fira Code', 'Consolas', 'Sarasa Mono SC', 'Microsoft YaHei UI', Courier, monospace;
-}
diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.css
similarity index 55%
rename from src/renderer/src/assets/styles/index.scss
rename to src/renderer/src/assets/styles/index.css
index 974457dc4d..b344e60ae6 100644
--- a/src/renderer/src/assets/styles/index.scss
+++ b/src/renderer/src/assets/styles/index.css
@@ -1,11 +1,12 @@
-@use './color.scss';
-@use './font.scss';
-@use './markdown.scss';
-@use './ant.scss';
-@use './scrollbar.scss';
-@use './container.scss';
-@use './animation.scss';
-@use './richtext.scss';
+@import './color.css';
+@import './font.css';
+@import './markdown.css';
+@import './ant.css';
+@import './scrollbar.css';
+@import './container.css';
+@import './animation.css';
+@import './richtext.css';
+@import './responsive.css';
@import '../fonts/icon-fonts/iconfont.css';
@import '../fonts/ubuntu/ubuntu.css';
@import '../fonts/country-flag-fonts/flag.css';
@@ -14,7 +15,7 @@
*::before,
*::after {
box-sizing: border-box;
- margin: 0;
+ /* margin: 0; */
font-weight: normal;
}
@@ -34,11 +35,11 @@ body,
margin: 0;
}
-#root {
+/* #root {
display: flex;
flex-direction: row;
flex: 1;
-}
+} */
body {
display: flex;
@@ -50,6 +51,7 @@ body {
font-family: var(--font-family);
text-rendering: optimizeLegibility;
transition: background-color 0.3s linear;
+ background-color: unset;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -113,62 +115,58 @@ ul {
word-wrap: break-word;
}
-.bubble:not(.multi-select-mode) {
- .block-wrapper {
- display: flow-root;
- }
+.bubble:not(.multi-select-mode) .block-wrapper {
+ display: flow-root;
+}
- .block-wrapper:last-child > *:last-child {
- margin-bottom: 0;
- }
+.bubble:not(.multi-select-mode) .block-wrapper:last-child > *:last-child {
+ margin-bottom: 0;
+}
- .message-content-container > *:last-child {
- margin-bottom: 0;
- }
+.bubble:not(.multi-select-mode) .message-content-container > *:last-child {
+ margin-bottom: 0;
+}
- .message-thought-container {
- margin-top: 8px;
- }
+.bubble:not(.multi-select-mode) .message-thought-container {
+ margin-top: 8px;
+}
- .message-user {
- .message-header {
- flex-direction: row-reverse;
- text-align: right;
- .message-header-info-wrap {
- flex-direction: row-reverse;
- text-align: right;
- }
- }
- .message-content-container {
- border-radius: 10px;
- padding: 10px 16px 10px 16px;
- background-color: var(--chat-background-user);
- align-self: self-end;
- }
- .MessageFooter {
- margin-top: 2px;
- align-self: self-end;
- }
- }
+.bubble:not(.multi-select-mode) .message-user .message-header {
+ flex-direction: row-reverse;
+ text-align: right;
+}
- .message-assistant {
- .message-content-container {
- padding-left: 0;
- }
- .MessageFooter {
- margin-left: 0;
- }
- }
+.bubble:not(.multi-select-mode) .message-user .message-header .message-header-info-wrap {
+ flex-direction: row-reverse;
+ text-align: right;
+}
- code {
- color: var(--color-text);
- }
- .markdown {
- display: flow-root;
- *:last-child {
- margin-bottom: 0;
- }
- }
+.bubble:not(.multi-select-mode) .message-user .message-content-container {
+ border-radius: 10px;
+ padding: 10px 16px 10px 16px;
+ background-color: var(--chat-background-user);
+ align-self: self-end;
+}
+
+.bubble:not(.multi-select-mode) .message-user .MessageFooter {
+ margin-top: 2px;
+ align-self: self-end;
+}
+
+.bubble:not(.multi-select-mode) .message-assistant .message-content-container {
+ padding-left: 0;
+}
+
+.bubble:not(.multi-select-mode) .message-assistant .MessageFooter {
+ margin-left: 0;
+}
+
+.bubble:not(.multi-select-mode) code {
+ color: var(--color-text);
+}
+
+.bubble:not(.multi-select-mode) .markdown {
+ display: flow-root;
}
.lucide:not(.lucide-custom) {
@@ -184,8 +182,6 @@ ul {
background-color: var(--color-background-highlight-accent);
}
-textarea {
- &::-webkit-resizer {
- display: none;
- }
+textarea::-webkit-resizer {
+ display: none;
}
diff --git a/src/renderer/src/assets/styles/markdown.css b/src/renderer/src/assets/styles/markdown.css
new file mode 100644
index 0000000000..5a1ca236d6
--- /dev/null
+++ b/src/renderer/src/assets/styles/markdown.css
@@ -0,0 +1,388 @@
+.markdown {
+ color: var(--color-text);
+ line-height: 1.6;
+ user-select: text;
+ word-break: break-word;
+}
+
+.markdown h1:first-child,
+.markdown h2:first-child,
+.markdown h3:first-child,
+.markdown h4:first-child,
+.markdown h5:first-child,
+.markdown h6:first-child {
+ margin-top: 0;
+}
+
+.markdown h1,
+.markdown h2,
+.markdown h3,
+.markdown h4,
+.markdown h5,
+.markdown h6 {
+ margin: 1.5em 0 1em 0;
+ line-height: 1.3;
+ font-weight: bold;
+}
+
+.markdown h1 {
+ margin-top: 0;
+ font-size: 2em;
+ border-bottom: 0.5px solid var(--color-border);
+ padding-bottom: 0.3em;
+}
+
+.markdown h2 {
+ font-size: 1.5em;
+ border-bottom: 0.5px solid var(--color-border);
+ padding-bottom: 0.3em;
+}
+
+.markdown h3 {
+ font-size: 1.2em;
+}
+
+.markdown h4 {
+ font-size: 1em;
+}
+
+.markdown h5 {
+ font-size: 0.9em;
+}
+
+.markdown h6 {
+ font-size: 0.8em;
+}
+
+.markdown p {
+ margin: 1.3em 0;
+ white-space: pre-wrap;
+ line-height: 1.6;
+}
+
+.markdown p:last-child {
+ margin-bottom: 5px;
+}
+
+.markdown p:first-child {
+ margin-top: 0;
+}
+
+.markdown p:has(+ ul) {
+ margin-bottom: 0;
+}
+
+.markdown ul {
+ list-style: initial;
+}
+
+.markdown ul,
+.markdown ol {
+ padding-left: 1.5em;
+ margin: 1em 0;
+}
+
+.markdown li {
+ margin-bottom: 0.5em;
+}
+
+.markdown li pre {
+ margin: 1.5em 0 !important;
+}
+
+.markdown li::marker {
+ color: var(--color-text-3);
+}
+
+.markdown li > ul,
+.markdown li > ol {
+ margin: 0.5em 0;
+}
+
+.markdown hr {
+ border: none;
+ border-top: 0.5px solid var(--color-border);
+ margin: 20px 0;
+}
+
+.markdown span {
+ white-space: pre-wrap;
+}
+
+.markdown .katex span {
+ white-space: pre;
+}
+
+.markdown p code,
+.markdown li code {
+ background: var(--color-background-mute);
+ padding: 3px 5px;
+ margin: 0 2px;
+ border-radius: 5px;
+ word-break: keep-all;
+ white-space: pre;
+}
+
+.markdown code {
+ font-family: var(--code-font-family);
+}
+
+.markdown pre {
+ border-radius: 8px;
+ overflow-x: auto;
+ font-family: var(--code-font-family);
+ background-color: var(--color-background-mute);
+}
+
+.markdown pre:has(.special-preview) {
+ background-color: transparent;
+}
+
+.markdown pre:not(pre pre) > code:not(pre pre > code) {
+ padding: 15px;
+ display: block;
+}
+
+.markdown pre pre {
+ margin: 0 !important;
+}
+
+.markdown pre pre code {
+ background: none;
+ padding: 0;
+ border-radius: 0;
+}
+
+.markdown pre + pre {
+ margin-top: 10px;
+}
+
+.markdown .markdown-alert,
+.markdown blockquote {
+ margin: 1.5em 0;
+ padding: 1em 1.5em;
+ background-color: var(--color-background-soft);
+ border-left: 4px solid var(--color-primary);
+ border-radius: 0 8px 8px 0;
+ font-style: italic;
+ position: relative;
+}
+
+.markdown table {
+ --table-border-radius: 8px;
+ margin: 2em 0;
+ font-size: 0.9em;
+ width: 100%;
+ border-radius: var(--table-border-radius);
+ overflow: hidden;
+ border-collapse: separate;
+ border: 0.5px solid var(--color-border);
+ border-spacing: 0;
+}
+
+.markdown th,
+.markdown td {
+ border-right: 0.5px solid var(--color-border);
+ border-bottom: 0.5px solid var(--color-border);
+ padding: 0.5em;
+}
+
+.markdown th:last-child,
+.markdown td:last-child {
+ border-right: none;
+}
+
+.markdown tr:last-child td {
+ border-bottom: none;
+}
+
+.markdown th {
+ background-color: var(--color-background-mute);
+ font-weight: 600;
+ text-align: left;
+}
+
+.markdown tr:hover {
+ background-color: var(--color-background-soft);
+}
+
+.markdown img {
+ max-width: 100%;
+ height: auto;
+ margin: 1em 0;
+}
+
+.markdown a,
+.markdown .link {
+ color: var(--color-link);
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.markdown a:hover,
+.markdown .link:hover {
+ text-decoration: underline;
+}
+
+.markdown strong {
+ font-weight: bold;
+}
+
+.markdown em {
+ font-style: italic;
+}
+
+.markdown del {
+ text-decoration: line-through;
+}
+
+.markdown sup,
+.markdown sub {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+.markdown sup {
+ top: -0.5em;
+ border-radius: 50%;
+ background-color: var(--color-reference);
+ color: var(--color-reference-text);
+ padding: 2px 5px;
+ zoom: 0.8;
+}
+
+.markdown sup > span.link {
+ color: var(--color-reference-text);
+}
+
+.markdown sub {
+ bottom: -0.25em;
+}
+
+.markdown .footnote-ref {
+ font-size: 0.8em;
+ vertical-align: super;
+ line-height: 0;
+ margin: 0 2px;
+ color: var(--color-primary);
+ text-decoration: none;
+}
+
+.markdown .footnote-ref:hover {
+ text-decoration: underline;
+}
+
+.footnotes {
+ margin-top: 1em;
+ margin-bottom: 1em;
+ padding-top: 1em;
+
+ background-color: var(--color-reference-background);
+ border-radius: 8px;
+ padding: 8px 12px;
+}
+
+.footnotes h4 {
+ margin-bottom: 5px;
+ font-size: 12px;
+}
+
+.footnotes a {
+ color: var(--color-link);
+}
+
+.footnotes ol {
+ padding-left: 1em;
+ margin: 0;
+}
+
+.footnotes ol li:last-child {
+ margin-bottom: 0;
+}
+
+.footnotes li {
+ font-size: 0.9em;
+ margin-bottom: 0.5em;
+ color: var(--color-text-light);
+}
+
+.footnotes li p {
+ display: inline;
+ margin: 0;
+}
+
+.footnotes .footnote-backref {
+ font-size: 0.8em;
+ vertical-align: super;
+ line-height: 0;
+ margin-left: 5px;
+ color: var(--color-primary);
+ text-decoration: none;
+}
+
+.footnotes .footnote-backref:hover {
+ text-decoration: underline;
+}
+
+emoji-picker {
+ --border-size: 0;
+}
+
+.block-wrapper + .block-wrapper {
+ margin-top: 1em;
+}
+
+.katex,
+mjx-container {
+ display: inline-block;
+ overflow-x: auto;
+ overflow-y: hidden;
+ overflow-wrap: break-word;
+ vertical-align: middle;
+ max-width: 100%;
+ padding: 1px 2px;
+ margin-top: -2px;
+}
+
+/* Shiki 相关样式 */
+.shiki {
+ font-family: var(--code-font-family);
+ /* 保持行高为初始值,在 shiki 代码块中处理 */
+ line-height: initial;
+}
+
+/* CodeMirror 相关样式 */
+.cm-editor {
+ border-radius: inherit;
+}
+
+.cm-editor.cm-focused {
+ outline: none;
+}
+
+.cm-editor .cm-scroller {
+ font-family: var(--code-font-family);
+ border-radius: inherit;
+}
+
+.cm-editor .cm-scroller .cm-gutters {
+ line-height: 1.6;
+ border-right: none;
+}
+
+.cm-editor .cm-scroller .cm-content {
+ line-height: 1.6;
+ padding-left: 0.25em;
+}
+
+.cm-editor .cm-scroller .cm-lineWrapping * {
+ word-wrap: break-word;
+ white-space: pre-wrap;
+}
+
+.cm-editor .cm-announced {
+ position: absolute;
+ display: none;
+}
diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss
deleted file mode 100644
index 7d4dbf0ce2..0000000000
--- a/src/renderer/src/assets/styles/markdown.scss
+++ /dev/null
@@ -1,379 +0,0 @@
-.markdown {
- color: var(--color-text);
- line-height: 1.6;
- user-select: text;
- word-break: break-word;
-
- h1:first-child,
- h2:first-child,
- h3:first-child,
- h4:first-child,
- h5:first-child,
- h6:first-child {
- margin-top: 0;
- }
-
- h1,
- h2,
- h3,
- h4,
- h5,
- h6 {
- margin: 1.5em 0 1em 0;
- line-height: 1.3;
- font-weight: bold;
- }
-
- h1 {
- margin-top: 0;
- font-size: 2em;
- border-bottom: 0.5px solid var(--color-border);
- padding-bottom: 0.3em;
- }
-
- h2 {
- font-size: 1.5em;
- border-bottom: 0.5px solid var(--color-border);
- padding-bottom: 0.3em;
- }
-
- h3 {
- font-size: 1.2em;
- }
-
- h4 {
- font-size: 1em;
- }
-
- h5 {
- font-size: 0.9em;
- }
-
- h6 {
- font-size: 0.8em;
- }
-
- p {
- margin: 1.3em 0;
- white-space: pre-wrap;
- line-height: 1.6;
-
- &:last-child {
- margin-bottom: 5px;
- }
-
- &:first-child {
- margin-top: 0;
- }
-
- &:has(+ ul) {
- margin-bottom: 0;
- }
- }
-
- ul {
- list-style: initial;
- }
-
- ul,
- ol {
- padding-left: 1.5em;
- margin: 1em 0;
- }
-
- li {
- margin-bottom: 0.5em;
- pre {
- margin: 1.5em 0 !important;
- }
- &::marker {
- color: var(--color-text-3);
- }
- }
-
- li > ul,
- li > ol {
- margin: 0.5em 0;
- }
-
- hr {
- border: none;
- border-top: 0.5px solid var(--color-border);
- margin: 20px 0;
- }
-
- span {
- white-space: pre-wrap;
- }
-
- .katex span {
- white-space: pre;
- }
-
- p code,
- li code {
- background: var(--color-background-mute);
- padding: 3px 5px;
- margin: 0 2px;
- border-radius: 5px;
- word-break: keep-all;
- white-space: pre;
- }
-
- code {
- font-family: var(--code-font-family);
- }
-
- pre {
- border-radius: 8px;
- overflow-x: auto;
- font-family: var(--code-font-family);
- background-color: var(--color-background-mute);
- &:has(.special-preview) {
- background-color: transparent;
- }
- &:not(pre pre) {
- > code:not(pre pre > code) {
- padding: 15px;
- display: block;
- }
- }
- pre {
- margin: 0 !important;
- code {
- background: none;
- padding: 0;
- border-radius: 0;
- }
- }
- }
-
- pre + pre {
- margin-top: 10px;
- }
-
- .markdown-alert,
- blockquote {
- margin: 1.5em 0;
- padding: 1em 1.5em;
- background-color: var(--color-background-soft);
- border-left: 4px solid var(--color-primary);
- border-radius: 0 8px 8px 0;
- font-style: italic;
- position: relative;
- }
-
- table {
- --table-border-radius: 8px;
- margin: 2em 0;
- font-size: 0.9em;
- width: 100%;
- border-radius: var(--table-border-radius);
- overflow: hidden;
- border-collapse: separate;
- border: 0.5px solid var(--color-border);
- border-spacing: 0;
- }
-
- th,
- td {
- border-right: 0.5px solid var(--color-border);
- border-bottom: 0.5px solid var(--color-border);
- padding: 0.5em;
- &:last-child {
- border-right: none;
- }
- }
-
- tr:last-child td {
- border-bottom: none;
- }
-
- th {
- background-color: var(--color-background-mute);
- font-weight: 600;
- text-align: left;
- }
-
- tr:hover {
- background-color: var(--color-background-soft);
- }
-
- img {
- max-width: 100%;
- height: auto;
- margin: 1em 0;
- }
-
- a,
- .link {
- color: var(--color-link);
- text-decoration: none;
- cursor: pointer;
-
- &:hover {
- text-decoration: underline;
- }
- }
-
- strong {
- font-weight: bold;
- }
-
- em {
- font-style: italic;
- }
-
- del {
- text-decoration: line-through;
- }
-
- sup,
- sub {
- font-size: 75%;
- line-height: 0;
- position: relative;
- vertical-align: baseline;
- }
-
- sup {
- top: -0.5em;
- border-radius: 50%;
- background-color: var(--color-reference);
- color: var(--color-reference-text);
- padding: 2px 5px;
- zoom: 0.8;
- & > span.link {
- color: var(--color-reference-text);
- }
- }
-
- sub {
- bottom: -0.25em;
- }
-
- .footnote-ref {
- font-size: 0.8em;
- vertical-align: super;
- line-height: 0;
- margin: 0 2px;
- color: var(--color-primary);
- text-decoration: none;
-
- &:hover {
- text-decoration: underline;
- }
- }
-}
-
-.footnotes {
- margin-top: 1em;
- margin-bottom: 1em;
- padding-top: 1em;
-
- background-color: var(--color-reference-background);
- border-radius: 8px;
- padding: 8px 12px;
-
- h4 {
- margin-bottom: 5px;
- font-size: 12px;
- }
-
- a {
- color: var(--color-link);
- }
-
- ol {
- padding-left: 1em;
- margin: 0;
- li:last-child {
- margin-bottom: 0;
- }
- }
-
- li {
- font-size: 0.9em;
- margin-bottom: 0.5em;
- color: var(--color-text-light);
-
- p {
- display: inline;
- margin: 0;
- }
- }
-
- .footnote-backref {
- font-size: 0.8em;
- vertical-align: super;
- line-height: 0;
- margin-left: 5px;
- color: var(--color-primary);
- text-decoration: none;
-
- &:hover {
- text-decoration: underline;
- }
- }
-}
-
-emoji-picker {
- --border-size: 0;
-}
-
-.block-wrapper + .block-wrapper {
- margin-top: 1em;
-}
-
-.katex,
-mjx-container {
- display: inline-block;
- overflow-x: auto;
- overflow-y: hidden;
- overflow-wrap: break-word;
- vertical-align: middle;
- max-width: 100%;
- padding: 1px 2px;
- margin-top: -2px;
-}
-
-/* Shiki 相关样式 */
-.shiki {
- font-family: var(--code-font-family);
- // 保持行高为初始值,在 shiki 代码块中处理
- line-height: initial;
-}
-
-/* CodeMirror 相关样式 */
-.cm-editor {
- border-radius: inherit;
-
- &.cm-focused {
- outline: none;
- }
-
- .cm-scroller {
- font-family: var(--code-font-family);
- border-radius: inherit;
-
- .cm-gutters {
- line-height: 1.6;
- border-right: none;
- }
-
- .cm-content {
- line-height: 1.6;
- padding-left: 0.25em;
- }
-
- .cm-lineWrapping * {
- word-wrap: break-word;
- white-space: pre-wrap;
- }
- }
-
- .cm-announced {
- position: absolute;
- display: none;
- }
-}
diff --git a/src/renderer/src/assets/styles/responsive.css b/src/renderer/src/assets/styles/responsive.css
new file mode 100644
index 0000000000..e84cc1e69b
--- /dev/null
+++ b/src/renderer/src/assets/styles/responsive.css
@@ -0,0 +1,27 @@
+/* xl, xxl, default style */
+:root {
+ --navbar-height: 44px;
+ --sidebar-width: 50px;
+ --status-bar-height: 40px;
+ --input-bar-height: 100px;
+
+ --assistants-width: 275px;
+ --topic-list-width: 275px;
+ --settings-width: 250px;
+
+ --scrollbar-width: 5px;
+}
+
+[navbar-position='left'] {
+ --navbar-height: 42px;
+ --list-item-border-radius: 20px;
+}
+
+/* lg */
+@media (max-width: 1080px) {
+ :root {
+ --assistants-width: 210px;
+ --topic-list-width: 210px;
+ --settings-width: 210px;
+ }
+}
diff --git a/src/renderer/src/assets/styles/richtext.css b/src/renderer/src/assets/styles/richtext.css
new file mode 100644
index 0000000000..547a5e17bb
--- /dev/null
+++ b/src/renderer/src/assets/styles/richtext.css
@@ -0,0 +1,523 @@
+.tiptap {
+ /* 预留5px给scrollbar */
+ padding: 12px 55px 12px 60px;
+ outline: none;
+ min-height: 120px;
+ overflow-wrap: break-word;
+ word-break: break-word;
+ font-size: var(--editor-font-size, 16px);
+}
+
+.tiptap:focus {
+ outline: none;
+}
+
+.tiptap :first-child {
+ margin-top: 0;
+}
+
+.tiptap h1:first-child,
+.tiptap h2:first-child,
+.tiptap h3:first-child,
+.tiptap h4:first-child,
+.tiptap h5:first-child,
+.tiptap h6:first-child {
+ margin-top: 0;
+}
+
+.tiptap h1,
+.tiptap h2,
+.tiptap h3,
+.tiptap h4,
+.tiptap h5,
+.tiptap h6 {
+ margin: 1.5rem 0 1rem 0;
+ line-height: 1.1;
+ text-wrap: pretty;
+ font-weight: 600;
+}
+
+.tiptap h1 code,
+.tiptap h2 code,
+.tiptap h3 code,
+.tiptap h4 code,
+.tiptap h5 code,
+.tiptap h6 code {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+.tiptap h1 {
+ margin-top: 0;
+ font-size: 2rem;
+}
+
+.tiptap h2 {
+ font-size: 1.5rem;
+}
+
+.tiptap h3 {
+ font-size: 1.2rem;
+}
+
+.tiptap h4,
+.tiptap h5,
+.tiptap h6 {
+ font-size: 1rem;
+}
+
+.tiptap p {
+ margin: 1.1rem 0 0.5rem 0;
+ white-space: normal;
+ overflow-wrap: break-word;
+ word-break: break-word;
+ width: 100%;
+ line-height: 1.6;
+ hyphens: auto;
+}
+
+.tiptap p:has(+ ul) {
+ margin-bottom: 0;
+}
+
+.tiptap a {
+ color: var(--color-link);
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.tiptap a:hover {
+ text-decoration: underline;
+}
+
+.tiptap blockquote {
+ border-left: 4px solid var(--color-primary);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+}
+
+.tiptap code {
+ background-color: var(--color-inline-code-background);
+ border-radius: 0.4rem;
+ color: var(--color-inline-code-text);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ font-family: var(--code-font-family);
+}
+
+.tiptap pre {
+ background: var(--color-code-background);
+ border-radius: 0.5rem;
+ color: var(--color-text);
+ font-family: var(--code-font-family);
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--color-border-soft);
+}
+
+.tiptap pre code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ border: none;
+}
+
+.tiptap hr {
+ border: none;
+ border-top: 1px solid var(--color-gray-2);
+ margin: 2rem 0;
+}
+
+.tiptap em {
+ font-style: italic;
+}
+
+.tiptap u {
+ text-decoration: underline;
+}
+
+.tiptap strong,
+.tiptap strong * {
+ font-weight: 600;
+}
+
+.tiptap .placeholder {
+ position: relative;
+}
+
+.tiptap .placeholder:before {
+ content: attr(data-placeholder);
+ position: absolute;
+ color: var(--color-text-secondary);
+ opacity: 0.6;
+ pointer-events: none;
+ font-style: italic;
+ left: 0;
+ right: 0;
+}
+
+/* Ensure drag handles and plus buttons remain interactive */
+.tiptap .placeholder .drag-handle,
+.tiptap .placeholder .plus-button {
+ pointer-events: auto;
+}
+
+/* Show placeholder only when focused or when it's the only empty node */
+.tiptap .placeholder.has-focus:before {
+ opacity: 0.8;
+}
+
+.tiptap img {
+ max-width: 800px;
+ width: 100%;
+ height: auto;
+}
+
+.tiptap table {
+ border-collapse: collapse;
+ margin: 0;
+ /* Allow action endpoints (rendered as decorations) to slightly overflow table edges */
+ overflow: visible;
+ table-layout: fixed;
+ width: 100%;
+}
+
+.tiptap td,
+.tiptap th {
+ border: 1px solid var(--color-border-soft);
+ box-sizing: border-box;
+ display: table-cell;
+ min-width: 120px;
+ padding: 6px 8px;
+ position: relative;
+ vertical-align: top;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.tiptap td > *,
+.tiptap th > * {
+ margin-bottom: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.tiptap th,
+.tiptap th * {
+ background-color: var(--color-gray-3);
+ font-weight: bold;
+ text-align: left;
+}
+
+.tiptap .selectedCell {
+ position: relative; /* 确保伪元素定位 */
+}
+
+.tiptap .selectedCell::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ border: 0 solid var(--color-primary);
+ border-radius: 0;
+}
+
+.tiptap .selectedCell.selection-top::after {
+ border-top-width: 2px;
+}
+
+.tiptap .selectedCell.selection-bottom::after {
+ border-bottom-width: 2px;
+}
+
+.tiptap .selectedCell.selection-left::after {
+ border-left-width: 2px;
+}
+
+.tiptap .selectedCell.selection-right::after {
+ border-right-width: 2px;
+}
+
+.tiptap .column-resize-handle {
+ background-color: var(--color-primary);
+ bottom: -2px;
+ pointer-events: none;
+ position: absolute;
+ right: -2px;
+ top: 0;
+ width: 4px;
+}
+
+.tiptap table:has(.selectedCell) {
+ caret-color: transparent !important;
+ user-select: none !important;
+}
+
+.tiptap table:has(.selectedCell) *::selection {
+ background: transparent !important;
+}
+
+.tiptap table:has(.selectedCell) .column-resize-handle {
+ display: none;
+}
+
+/* Position row action buttons relative to first column cells */
+.tiptap table tbody tr td:first-child,
+.tiptap table tbody tr th:first-child {
+ position: relative;
+}
+
+/* Position column action buttons relative to first row cells */
+.tiptap table tbody tr:first-child td,
+.tiptap table tbody tr:first-child th {
+ position: relative;
+}
+
+.tiptap .tableWrapper {
+ position: relative;
+ margin: 1rem 0;
+ display: grid;
+ grid-template-columns: 1fr 25px;
+ grid-template-rows: 1fr 25px;
+ grid-template-areas:
+ 'table column-btn'
+ 'row-btn corner';
+ gap: 5px;
+}
+
+.tiptap .tableWrapper .table-container {
+ grid-area: table;
+ overflow-x: auto;
+ overflow-y: visible;
+}
+
+.tiptap .tableWrapper .table-container::-webkit-scrollbar {
+ cursor: default;
+}
+
+.tiptap .tableWrapper .table-container::-webkit-scrollbar:horizontal {
+ cursor: default;
+}
+
+.tiptap .tableWrapper .table-container::-webkit-scrollbar-thumb {
+ cursor: default;
+}
+
+.tiptap .tableWrapper .table-container::-webkit-scrollbar-track {
+ cursor: default;
+}
+
+.tiptap .tableWrapper .table-container table {
+ width: max-content;
+ min-width: 100%;
+}
+
+.tiptap .tableWrapper .add-row-button,
+.tiptap .tableWrapper .add-column-button {
+ border: 1px solid var(--color-border);
+ background: var(--color-bg-base);
+ border-radius: 4px;
+ font-size: 12px;
+ line-height: 1;
+ cursor: pointer;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text);
+ z-index: 20;
+ transition: all 0.2s ease;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ pointer-events: auto;
+}
+
+.tiptap .tableWrapper .add-row-button:hover,
+.tiptap .tableWrapper .add-column-button:hover {
+ background: var(--color-primary);
+ color: white;
+ border-color: var(--color-primary);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
+}
+
+.tiptap .tableWrapper .add-row-button:active,
+.tiptap .tableWrapper .add-column-button:active {
+ transform: scale(0.98);
+}
+
+.tiptap .tableWrapper .add-row-button::before,
+.tiptap .tableWrapper .add-column-button::before {
+ content: '+';
+ font-weight: bold;
+}
+
+.tiptap .tableWrapper .add-row-button {
+ grid-area: row-btn;
+}
+
+.tiptap .tableWrapper .add-column-button {
+ grid-area: column-btn;
+}
+
+.tiptap .tableWrapper:hover .add-row-button,
+.tiptap .tableWrapper:has(.add-row-button:hover) .add-row-button,
+.tiptap .tableWrapper:has(.add-column-button:hover) .add-row-button,
+.tiptap .tableWrapper:hover .add-column-button,
+.tiptap .tableWrapper:has(.add-row-button:hover) .add-column-button,
+.tiptap .tableWrapper:has(.add-column-button:hover) .add-column-button {
+ display: flex;
+}
+
+/* Do not show in readonly even on hover */
+.tiptap .tableWrapper.is-readonly .add-row-button,
+.tiptap .tableWrapper.is-readonly:hover .add-row-button,
+.tiptap .tableWrapper.is-readonly .add-column-button,
+.tiptap .tableWrapper.is-readonly:hover .add-column-button {
+ display: none !important;
+}
+
+.tiptap .tableWrapper .add-row-button:hover,
+.tiptap .tableWrapper .add-column-button:hover {
+ display: flex !important;
+}
+
+/* Row/Column action triggers (visible on cell selection) */
+.tiptap .tableWrapper .row-action-trigger,
+.tiptap .tableWrapper .column-action-trigger {
+ position: absolute;
+ height: 20px;
+ border-radius: 8px;
+ background: var(--color-primary);
+ color: #fff;
+ border: 1px solid var(--color-primary);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ line-height: 1;
+ z-index: 30;
+ pointer-events: auto;
+}
+
+.tiptap .tableWrapper .row-action-trigger::before,
+.tiptap .tableWrapper .column-action-trigger::before {
+ content: '•••';
+}
+
+.tiptap.resize-cursor {
+ cursor: ew-resize;
+ cursor: col-resize;
+}
+
+.tiptap ul,
+.tiptap ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+}
+
+.tiptap ul li p,
+.tiptap ol li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+}
+
+/* Reduce spacing for nested lists */
+.tiptap ul ul,
+.tiptap ul ol,
+.tiptap ol ul,
+.tiptap ol ol {
+ margin: 0.5rem 0.5rem 0.5rem 0.2rem;
+}
+
+.tiptap ul {
+ list-style: disc;
+}
+
+.tiptap ol {
+ list-style: decimal;
+}
+
+.tiptap ul[data-type='taskList'] {
+ list-style: none;
+ margin-left: 0;
+ padding: 0;
+}
+
+.tiptap ul[data-type='taskList'] li {
+ align-items: center;
+ display: flex;
+}
+
+.tiptap ul[data-type='taskList'] li > label {
+ flex: 0 0 auto;
+ margin-right: 0.5rem;
+ user-select: none;
+ display: flex;
+ align-items: center;
+}
+
+.tiptap ul[data-type='taskList'] li > div {
+ flex: 1 1 auto;
+}
+
+.tiptap ul[data-type='taskList'] li > div p {
+ margin: 0;
+}
+
+/* Checked task item appearance */
+.tiptap ul[data-type='taskList'] li[data-checked='true'] > div {
+ color: var(--color-text-2);
+ text-decoration: line-through;
+}
+
+.tiptap ul[data-type='taskList'] input[type='checkbox'] {
+ cursor: pointer;
+}
+
+/* Use primary color for checked checkbox */
+.tiptap ul[data-type='taskList'] input[type='checkbox']:checked {
+ accent-color: var(--color-primary);
+ background-color: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+.tiptap ul[data-type='taskList'] ul[data-type='taskList'] {
+ margin: 0;
+}
+
+/* Math block */
+.tiptap .block-math-inner {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 1.2rem;
+}
+
+/* Bottom spacer to create viewport padding */
+.tiptap::after {
+ content: '';
+ display: block;
+ height: 50px;
+ pointer-events: none;
+}
+
+/* Code block wrapper and header styles */
+.code-block-wrapper {
+ position: relative;
+}
+
+.code-block-wrapper .code-block-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ position: absolute;
+ top: 4px;
+ right: 6px;
+ opacity: 0;
+ transition: opacity 0.2s;
+}
+
+.code-block-wrapper:hover .code-block-header {
+ opacity: 1;
+}
diff --git a/src/renderer/src/assets/styles/richtext.scss b/src/renderer/src/assets/styles/richtext.scss
deleted file mode 100644
index 7b4e4bdad5..0000000000
--- a/src/renderer/src/assets/styles/richtext.scss
+++ /dev/null
@@ -1,508 +0,0 @@
-.tiptap {
- // 预留5px给scrollbar
- padding: 12px 55px 12px 60px;
- outline: none;
- min-height: 120px;
- overflow-wrap: break-word;
- word-break: break-word;
-
- &:focus {
- outline: none;
- }
-
- :first-child {
- margin-top: 0;
- }
-
- h1:first-child,
- h2:first-child,
- h3:first-child,
- h4:first-child,
- h5:first-child,
- h6:first-child {
- margin-top: 0;
- }
-
- h1,
- h2,
- h3,
- h4,
- h5,
- h6 {
- margin: 1.5rem 0 1rem 0;
- line-height: 1.1;
- text-wrap: pretty;
- font-weight: 600;
-
- code {
- font-size: inherit;
- font-weight: inherit;
- }
- }
-
- h1 {
- margin-top: 0;
- font-size: 2rem;
- }
-
- h2 {
- font-size: 1.5rem;
- }
-
- h3 {
- font-size: 1.2rem;
- }
-
- h4,
- h5,
- h6 {
- font-size: 1rem;
- }
-
- p {
- margin: 1.1rem 0 0.5rem 0;
- white-space: normal;
- overflow-wrap: break-word;
- word-break: break-word;
- width: 100%;
- line-height: 1.6;
- hyphens: auto;
-
- &:has(+ ul) {
- margin-bottom: 0;
- }
- }
-
- a {
- color: var(--color-link);
- text-decoration: none;
- cursor: pointer;
-
- &:hover {
- text-decoration: underline;
- }
- }
-
- blockquote {
- border-left: 4px solid var(--color-primary);
- margin: 1.5rem 0;
- padding-left: 1rem;
- }
-
- code {
- background-color: var(--color-inline-code-background);
- border-radius: 0.4rem;
- color: var(--color-inline-code-text);
- font-size: 0.85rem;
- padding: 0.25em 0.3em;
- font-family: var(--code-font-family);
- }
-
- pre {
- background: var(--color-code-background);
- border-radius: 0.5rem;
- color: var(--color-text);
- font-family: var(--code-font-family);
- margin: 1.5rem 0;
- padding: 0.75rem 1rem;
- border: 1px solid var(--color-border-soft);
-
- code {
- background: none;
- color: inherit;
- font-size: 0.8rem;
- padding: 0;
- border: none;
- }
- }
-
- hr {
- border: none;
- border-top: 1px solid var(--color-gray-2);
- margin: 2rem 0;
- }
-
- em {
- font-style: italic;
- }
-
- u {
- text-decoration: underline;
- }
-
- strong,
- strong * {
- font-weight: 600;
- }
-
- .placeholder {
- position: relative;
-
- &:before {
- content: attr(data-placeholder);
- position: absolute;
- color: var(--color-text-secondary);
- opacity: 0.6;
- pointer-events: none;
- font-style: italic;
- left: 0;
- right: 0;
- }
-
- /* Ensure drag handles and plus buttons remain interactive */
- .drag-handle,
- .plus-button {
- pointer-events: auto;
- }
- }
-
- /* Show placeholder only when focused or when it's the only empty node */
- .placeholder.has-focus:before {
- opacity: 0.8;
- }
-
- img {
- max-width: 800px;
- width: 100%;
- height: auto;
- }
-
- table {
- border-collapse: collapse;
- margin: 0;
- /* Allow action endpoints (rendered as decorations) to slightly overflow table edges */
- overflow: visible;
- table-layout: fixed;
- width: 100%;
-
- td,
- th {
- border: 1px solid var(--color-border-soft);
- box-sizing: border-box;
- display: table-cell;
- min-width: 120px;
- padding: 6px 8px;
- position: relative;
- vertical-align: top;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-
- > * {
- margin-bottom: 0;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- }
-
- th,
- th * {
- background-color: var(--color-gray-3);
- font-weight: bold;
- text-align: left;
- }
-
- .selectedCell {
- position: relative; // 确保伪元素定位
- }
- .selectedCell::after {
- content: '';
- position: absolute;
- inset: 0;
- pointer-events: none;
- border: 0 solid var(--color-primary);
- border-radius: 0;
- }
- .selectedCell.selection-top::after {
- border-top-width: 2px;
- }
- .selectedCell.selection-bottom::after {
- border-bottom-width: 2px;
- }
- .selectedCell.selection-left::after {
- border-left-width: 2px;
- }
- .selectedCell.selection-right::after {
- border-right-width: 2px;
- }
-
- .column-resize-handle {
- background-color: var(--color-primary);
- bottom: -2px;
- pointer-events: none;
- position: absolute;
- right: -2px;
- top: 0;
- width: 4px;
- }
-
- &:has(.selectedCell) {
- caret-color: transparent !important;
- user-select: none !important;
-
- *::selection {
- background: transparent !important;
- }
-
- .column-resize-handle {
- display: none;
- }
- }
-
- // Position row action buttons relative to first column cells
- tbody tr td:first-child,
- tbody tr th:first-child {
- position: relative;
- }
-
- // Position column action buttons relative to first row cells
- tbody tr:first-child td,
- tbody tr:first-child th {
- position: relative;
- }
- }
-
- .tableWrapper {
- position: relative;
- margin: 1rem 0;
- display: grid;
- grid-template-columns: 1fr 25px;
- grid-template-rows: 1fr 25px;
- grid-template-areas:
- 'table column-btn'
- 'row-btn corner';
- gap: 5px;
-
- .table-container {
- grid-area: table;
- overflow-x: auto;
- overflow-y: visible;
-
- &::-webkit-scrollbar {
- cursor: default;
- }
-
- &::-webkit-scrollbar:horizontal {
- cursor: default;
- }
-
- &::-webkit-scrollbar-thumb {
- cursor: default;
- }
-
- &::-webkit-scrollbar-track {
- cursor: default;
- }
-
- table {
- width: max-content;
- min-width: 100%;
- }
- }
-
- .add-row-button,
- .add-column-button {
- border: 1px solid var(--color-border);
- background: var(--color-bg-base);
- border-radius: 4px;
- font-size: 12px;
- line-height: 1;
- cursor: pointer;
- display: none;
- align-items: center;
- justify-content: center;
- color: var(--color-text);
- z-index: 20;
- transition: all 0.2s ease;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- pointer-events: auto;
-
- &:hover {
- background: var(--color-primary);
- color: white;
- border-color: var(--color-primary);
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
- }
-
- &:active {
- transform: scale(0.98);
- }
-
- &::before {
- content: '+';
- font-weight: bold;
- }
- }
-
- .add-row-button {
- grid-area: row-btn;
- }
-
- .add-column-button {
- grid-area: column-btn;
- }
-
- &:hover,
- &:has(.add-row-button:hover),
- &:has(.add-column-button:hover) {
- .add-row-button,
- .add-column-button {
- display: flex;
- }
- }
-
- /* Do not show in readonly even on hover */
- &.is-readonly,
- &.is-readonly:hover {
- .add-row-button,
- .add-column-button {
- display: none !important;
- }
- }
-
- .add-row-button:hover,
- .add-column-button:hover {
- display: flex !important;
- }
-
- /* Row/Column action triggers (visible on cell selection) */
- .row-action-trigger,
- .column-action-trigger {
- position: absolute;
- height: 20px;
- border-radius: 8px;
- background: var(--color-primary);
- color: #fff;
- border: 1px solid var(--color-primary);
- display: none;
- align-items: center;
- justify-content: center;
- font-size: 12px;
- line-height: 1;
- z-index: 30;
- pointer-events: auto;
- }
-
- .row-action-trigger::before,
- .column-action-trigger::before {
- content: '•••';
- }
- }
-
- &.resize-cursor {
- cursor: ew-resize;
- cursor: col-resize;
- }
-
- ul,
- ol {
- padding: 0 1rem;
- margin: 1.25rem 1rem 1.25rem 0.4rem;
-
- li p {
- margin-top: 0.25em;
- margin-bottom: 0.25em;
- }
-
- // Reduce spacing for nested lists
- ul,
- ol {
- margin: 0.5rem 0.5rem 0.5rem 0.2rem;
- }
- }
-
- ul {
- list-style: disc;
- }
-
- ol {
- list-style: decimal;
- }
-
- ul[data-type='taskList'] {
- list-style: none;
- margin-left: 0;
- padding: 0;
-
- li {
- align-items: center;
- display: flex;
-
- > label {
- flex: 0 0 auto;
- margin-right: 0.5rem;
- user-select: none;
- display: flex;
- align-items: center;
- }
-
- > div {
- flex: 1 1 auto;
-
- p {
- margin: 0;
- }
- }
- }
-
- /* Checked task item appearance */
- li[data-checked='true'] {
- > div {
- color: var(--color-text-2);
- text-decoration: line-through;
- }
- }
-
- input[type='checkbox'] {
- cursor: pointer;
- }
-
- /* Use primary color for checked checkbox */
- input[type='checkbox']:checked {
- accent-color: var(--color-primary);
- background-color: var(--color-primary);
- border-color: var(--color-primary);
- }
-
- ul[data-type='taskList'] {
- margin: 0;
- }
- }
-
- /* Math block */
- .block-math-inner {
- display: flex;
- justify-content: center;
- align-items: center;
- font-size: 1.2rem;
- }
-
- /* Bottom spacer to create viewport padding */
- &::after {
- content: '';
- display: block;
- height: 50px;
- pointer-events: none;
- }
-}
-
-// Code block wrapper and header styles
-.code-block-wrapper {
- position: relative;
-
- .code-block-header {
- display: flex;
- align-items: center;
- gap: 6px;
- position: absolute;
- top: 4px;
- right: 6px;
- opacity: 0;
- transition: opacity 0.2s;
- }
-
- &:hover .code-block-header {
- opacity: 1;
- }
-}
diff --git a/src/renderer/src/assets/styles/scrollbar.scss b/src/renderer/src/assets/styles/scrollbar.css
similarity index 87%
rename from src/renderer/src/assets/styles/scrollbar.scss
rename to src/renderer/src/assets/styles/scrollbar.css
index 555e68f334..461384381e 100644
--- a/src/renderer/src/assets/styles/scrollbar.scss
+++ b/src/renderer/src/assets/styles/scrollbar.css
@@ -31,17 +31,19 @@ body[theme-mode='light'] {
::-webkit-scrollbar-thumb {
border-radius: var(--scrollbar-thumb-radius);
background: var(--color-scrollbar-thumb);
- &:hover {
- background: var(--color-scrollbar-thumb-hover);
- }
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-scrollbar-thumb-hover);
}
pre:not(.shiki)::-webkit-scrollbar-thumb {
border-radius: 0;
background: rgba(0, 0, 0, 0.08);
- &:hover {
- background: rgba(0, 0, 0, 0.15);
- }
+}
+
+pre:not(.shiki)::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 0, 0, 0.15);
}
.shiki-dark {
@@ -71,7 +73,8 @@ pre:not(.shiki)::-webkit-scrollbar-thumb {
.rc-virtual-list-scrollbar-thumb {
border-radius: var(--scrollbar-thumb-radius) !important;
background: var(--color-scrollbar-thumb) !important;
- &:hover {
- background: var(--color-scrollbar-thumb-hover) !important;
- }
+}
+
+.rc-virtual-list-scrollbar-thumb:hover {
+ background: var(--color-scrollbar-thumb-hover) !important;
}
diff --git a/src/renderer/src/assets/styles/selection-toolbar.css b/src/renderer/src/assets/styles/selection-toolbar.css
new file mode 100644
index 0000000000..6efcb57d1f
--- /dev/null
+++ b/src/renderer/src/assets/styles/selection-toolbar.css
@@ -0,0 +1,72 @@
+@import './font.css';
+
+html {
+ font-family: var(--font-family);
+}
+
+:root {
+ /* Basic Colors */
+ --color-error: #f44336;
+
+ --selection-toolbar-color-error: var(--color-error);
+
+ /* Toolbar */
+ --selection-toolbar-height: 36px; /* default: 36px max: 42px */
+ --selection-toolbar-font-size: 14px; /* default: 14px */
+
+ --selection-toolbar-logo-display: flex; /* values: flex | none */
+ --selection-toolbar-logo-size: 22px; /* default: 22px */
+ --selection-toolbar-logo-border-width: 0.5px 0 0.5px 0.5px; /* default: none */
+ --selection-toolbar-logo-border-style: solid; /* default: none */
+ --selection-toolbar-logo-border-color: rgba(255, 255, 255, 0.2);
+ --selection-toolbar-logo-margin: 0; /* default: 0 */
+ --selection-toolbar-logo-padding: 0 6px 0 8px; /* default: 0 4px 0 8px */
+ --selection-toolbar-logo-background: transparent; /* default: transparent */
+
+ /* DO NOT MODIFY THESE VALUES, IF YOU DON'T KNOW WHAT YOU ARE DOING */
+ --selection-toolbar-padding: 0; /* default: 0 */
+ --selection-toolbar-margin: 2px 3px 5px 3px; /* default: 2px 3px 5px 3px */
+ /* ------------------------------------------------------------ */
+
+ --selection-toolbar-border-radius: 10px;
+ --selection-toolbar-border: none;
+ --selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
+ --selection-toolbar-background: rgba(20, 20, 20, 0.95);
+
+ /* Buttons */
+ --selection-toolbar-buttons-border-width: 0.5px 0.5px 0.5px 0;
+ --selection-toolbar-buttons-border-style: solid;
+ --selection-toolbar-buttons-border-color: rgba(255, 255, 255, 0.2);
+ --selection-toolbar-buttons-border-radius: 0 var(--selection-toolbar-border-radius)
+ var(--selection-toolbar-border-radius) 0;
+
+ --selection-toolbar-button-icon-size: 16px; /* default: 16px */
+ --selection-toolbar-button-direction: row; /* default: row | column */
+ --selection-toolbar-button-text-margin: 0 0 0 0; /* default: 0 0 0 0 */
+ --selection-toolbar-button-margin: 0; /* default: 0 */
+ --selection-toolbar-button-padding: 0 8px; /* default: 0 8px */
+ --selection-toolbar-button-last-padding: 0 12px 0 8px;
+ --selection-toolbar-button-border-radius: 0; /* default: 0 */
+ --selection-toolbar-button-border: none; /* default: none */
+ --selection-toolbar-button-box-shadow: none; /* default: none */
+
+ --selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9);
+ --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
+ --selection-toolbar-button-bgcolor: transparent; /* default: transparent */
+ --selection-toolbar-button-bgcolor-hover: #333333;
+}
+
+[theme-mode='light'] {
+ --selection-toolbar-border: none;
+ --selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.1);
+ --selection-toolbar-background: rgba(245, 245, 245, 0.95);
+
+ /* Buttons */
+ --selection-toolbar-buttons-border-color: rgba(0, 0, 0, 0.08);
+
+ --selection-toolbar-logo-border-color: rgba(0, 0, 0, 0.08);
+
+ --selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
+ --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
+ --selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04);
+}
diff --git a/src/renderer/src/assets/styles/selection-toolbar.scss b/src/renderer/src/assets/styles/selection-toolbar.scss
deleted file mode 100644
index cf3c672c45..0000000000
--- a/src/renderer/src/assets/styles/selection-toolbar.scss
+++ /dev/null
@@ -1,72 +0,0 @@
-@use './font.scss';
-
-html {
- font-family: var(--font-family);
-}
-
-:root {
- // Basic Colors
- --color-error: #f44336;
-
- --selection-toolbar-color-error: var(--color-error);
-
- // Toolbar
- --selection-toolbar-height: 36px; // default: 36px max: 42px
- --selection-toolbar-font-size: 14px; // default: 14px
-
- --selection-toolbar-logo-display: flex; // values: flex | none
- --selection-toolbar-logo-size: 22px; // default: 22px
- --selection-toolbar-logo-border-width: 0.5px 0 0.5px 0.5px; // default: none
- --selection-toolbar-logo-border-style: solid; // default: none
- --selection-toolbar-logo-border-color: rgba(255, 255, 255, 0.2);
- --selection-toolbar-logo-margin: 0; // default: 0
- --selection-toolbar-logo-padding: 0 6px 0 8px; // default: 0 4px 0 8px
- --selection-toolbar-logo-background: transparent; // default: transparent
-
- // DO NOT MODIFY THESE VALUES, IF YOU DON'T KNOW WHAT YOU ARE DOING
- --selection-toolbar-padding: 0; // default: 0
- --selection-toolbar-margin: 2px 3px 5px 3px; // default: 2px 3px 5px 3px
- // ------------------------------------------------------------
-
- --selection-toolbar-border-radius: 10px;
- --selection-toolbar-border: none;
- --selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
- --selection-toolbar-background: rgba(20, 20, 20, 0.95);
-
- // Buttons
- --selection-toolbar-buttons-border-width: 0.5px 0.5px 0.5px 0;
- --selection-toolbar-buttons-border-style: solid;
- --selection-toolbar-buttons-border-color: rgba(255, 255, 255, 0.2);
- --selection-toolbar-buttons-border-radius: 0 var(--selection-toolbar-border-radius)
- var(--selection-toolbar-border-radius) 0;
-
- --selection-toolbar-button-icon-size: 16px; // default: 16px
- --selection-toolbar-button-direction: row; // default: row | column
- --selection-toolbar-button-text-margin: 0 0 0 0; // default: 0 0 0 0
- --selection-toolbar-button-margin: 0; // default: 0
- --selection-toolbar-button-padding: 0 8px; // default: 0 8px
- --selection-toolbar-button-last-padding: 0 12px 0 8px;
- --selection-toolbar-button-border-radius: 0; // default: 0
- --selection-toolbar-button-border: none; // default: none
- --selection-toolbar-button-box-shadow: none; // default: none
-
- --selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9);
- --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
- --selection-toolbar-button-bgcolor: transparent; // default: transparent
- --selection-toolbar-button-bgcolor-hover: #333333;
-}
-
-[theme-mode='light'] {
- --selection-toolbar-border: none;
- --selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.1);
- --selection-toolbar-background: rgba(245, 245, 245, 0.95);
-
- // Buttons
- --selection-toolbar-buttons-border-color: rgba(0, 0, 0, 0.08);
-
- --selection-toolbar-logo-border-color: rgba(0, 0, 0, 0.08);
-
- --selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
- --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
- --selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04);
-}
diff --git a/src/renderer/src/assets/styles/tailwind.css b/src/renderer/src/assets/styles/tailwind.css
new file mode 100644
index 0000000000..4d9e0d4f00
--- /dev/null
+++ b/src/renderer/src/assets/styles/tailwind.css
@@ -0,0 +1,165 @@
+@import 'tailwindcss' source('../../../../renderer');
+@import 'tw-animate-css';
+
+/* heroui */
+@plugin '../../hero.ts';
+@source '../../../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
+
+@custom-variant dark (&:is(.dark *));
+
+/* 如需自定义:
+1. 清晰地组织自定义 CSS 到相应的层中。
+2. 基础样式(如全局重置、链接样式)放入 base 层;
+3. 可复用的组件样式(如果仍使用 @apply 或原生 CSS 嵌套创建)放入 components 层;
+4. 新的自定义工具类放入 utilities 层。
+*/
+/*To customize:
+1. Clearly organize custom CSS into the corresponding layers.
+2. Place basic styles (such as global reset and link styles) in the base layer;
+3. Put reusable component styles (if still created using @ apply or native CSS nesting) into the components layer;
+4. Put the new custom utility class into the utilities layer.
+*/
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.141 0.005 285.823);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.005 285.823);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.005 285.823);
+ --primary: oklch(0.21 0.006 285.885);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.967 0.001 286.375);
+ --muted-foreground: oklch(0.552 0.016 285.938);
+ --accent: oklch(0.967 0.001 286.375);
+ --accent-foreground: oklch(0.21 0.006 285.885);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.92 0.004 286.32);
+ --input: oklch(0.92 0.004 286.32);
+ --ring: oklch(0.705 0.015 286.067);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
+ --sidebar-primary: oklch(0.21 0.006 285.885);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.92 0.004 286.32);
+ --sidebar-ring: oklch(0.705 0.015 286.067);
+}
+
+.dark {
+ --background: oklch(0.141 0.005 285.823);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.21 0.006 285.885);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.21 0.006 285.885);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.92 0.004 286.32);
+ --primary-foreground: oklch(0.21 0.006 285.885);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --accent: oklch(0.274 0.006 286.033);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.552 0.016 285.938);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.21 0.006 285.885);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.274 0.006 286.033);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.552 0.016 285.938);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --animate-marquee: marquee var(--duration) infinite linear;
+ --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
+ @keyframes marquee {
+ from {
+ transform: translateX(0);
+ }
+ to {
+ transform: translateX(calc(-100% - var(--gap)));
+ }
+ }
+ @keyframes marquee-vertical {
+ from {
+ transform: translateY(0);
+ }
+ to {
+ transform: translateY(calc(-100% - var(--gap)));
+ }
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+
+ /* To disable drag title bar on toast. tailwind css doesn't provide such class name. */
+ .hero-toast {
+ -webkit-app-region: no-drag;
+ }
+}
+
+:root {
+ background-color: unset;
+}
diff --git a/src/renderer/src/components/ActionTools/__tests__/useImageTools.test.tsx b/src/renderer/src/components/ActionTools/__tests__/useImageTools.test.tsx
index 6083a508df..b59bf8aaea 100644
--- a/src/renderer/src/components/ActionTools/__tests__/useImageTools.test.tsx
+++ b/src/renderer/src/components/ActionTools/__tests__/useImageTools.test.tsx
@@ -43,8 +43,8 @@ vi.mock('@renderer/context/ThemeProvider', () => ({
// Mock navigator.clipboard
const mockWrite = vi.fn()
-// Mock window.message
-const mockMessage = {
+// Mock window.toast
+const mockedToast = {
success: vi.fn(),
error: vi.fn()
}
@@ -68,8 +68,8 @@ describe('useImageTools', () => {
writable: true
})
- Object.defineProperty(global.window, 'message', {
- value: mockMessage,
+ Object.defineProperty(global.window, 'toast', {
+ value: mockedToast,
writable: true
})
@@ -284,7 +284,7 @@ describe('useImageTools', () => {
expect(mocks.svgToPngBlob).toHaveBeenCalledWith(mockSvg)
expect(mockWrite).toHaveBeenCalled()
- expect(mockMessage.success).toHaveBeenCalledWith('message.copy.success')
+ expect(mockedToast.success).toHaveBeenCalledWith('message.copy.success')
})
it('should download image as PNG and SVG', async () => {
@@ -364,13 +364,13 @@ describe('useImageTools', () => {
await act(async () => {
await result.current.copy()
})
- expect(mockMessage.error).toHaveBeenCalledWith('message.copy.failed')
+ expect(mockedToast.error).toHaveBeenCalledWith('message.copy.failed')
// 下载失败
await act(async () => {
await result.current.download('png')
})
- expect(mockMessage.error).toHaveBeenCalledWith('message.download.failed')
+ expect(mockedToast.error).toHaveBeenCalledWith('message.download.failed')
})
})
@@ -420,7 +420,7 @@ describe('useImageTools', () => {
await result.current.dialog()
})
- expect(mockMessage.error).toHaveBeenCalledWith('message.dialog.failed')
+ expect(mockedToast.error).toHaveBeenCalledWith('message.dialog.failed')
})
it('should do nothing when no element is found', async () => {
diff --git a/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx b/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx
index e02d8846a9..3481b92797 100644
--- a/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx
+++ b/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx
@@ -210,10 +210,10 @@ export const useImageTools = (
const blob = await svgToPngBlob(imgElement)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
- window.message.success(t('message.copy.success'))
+ window.toast.success(t('message.copy.success'))
} catch (error) {
logger.error('Copy failed:', error as Error)
- window.message.error(t('message.copy.failed'))
+ window.toast.error(t('message.copy.failed'))
}
}, [getCleanImgElement, t])
@@ -243,7 +243,7 @@ export const useImageTools = (
}
} catch (error) {
logger.error('Download failed:', error as Error)
- window.message.error(t('message.download.failed'))
+ window.toast.error(t('message.download.failed'))
}
},
[getCleanImgElement, prefix, t]
@@ -262,7 +262,7 @@ export const useImageTools = (
await ImagePreviewService.show(imgElement, { format: 'svg' })
} catch (error) {
logger.error('Dialog preview failed:', error as Error)
- window.message.error(t('message.dialog.failed'))
+ window.toast.error(t('message.dialog.failed'))
}
}, [getCleanImgElement, t])
diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx
index f9c364eb64..a2e61f6ba0 100644
--- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx
+++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx
@@ -50,7 +50,7 @@ const HtmlArtifactsCard: FC = ({ html, onSave, isStreaming = false }) =>
const handleDownload = async () => {
const fileName = `${getFileNameFromHtmlTitle(title) || 'html-artifact'}.html`
await window.api.file.save(fileName, htmlContent)
- window.message.success({ content: t('message.download.success'), key: 'download' })
+ window.toast.success(t('message.download.success'))
}
return (
diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx
index d072e88c4b..9453866f20 100644
--- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx
+++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx
@@ -1,6 +1,6 @@
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
import { CopyIcon, FilePngIcon } from '@renderer/components/Icons'
-import { isLinux, isMac, isWin } from '@renderer/config/constant'
+import { isMac } from '@renderer/config/constant'
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
import { classNames } from '@renderer/utils'
import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats'
@@ -62,7 +62,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht
await captureScrollableIframeAsBlob(previewFrameRef, async (blob) => {
if (blob) {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
- window.message.success(t('message.copy.success'))
+ window.toast.success(t('message.copy.success'))
}
})
}
@@ -102,7 +102,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht
- e.stopPropagation()}>
+ e.stopPropagation()}>
`
+const HeaderRight = styled.div`
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
- padding-right: ${({ $isFullscreen }) => ($isFullscreen ? (isWin ? '136px' : isLinux ? '120px' : '12px') : '12px')};
+ padding-right: 12px;
`
const TitleText = styled(Typography.Text)`
diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx
index bd4dd753a6..6a2afd5582 100644
--- a/src/renderer/src/components/CodeBlockView/view.tsx
+++ b/src/renderer/src/components/CodeBlockView/view.tsx
@@ -19,7 +19,7 @@ import { BasicPreviewHandles } from '@renderer/components/Preview'
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
import { pyodideService } from '@renderer/services/PyodideService'
import { getExtensionByLanguage } from '@renderer/utils/code-language'
-import { extractHtmlTitle } from '@renderer/utils/formats'
+import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats'
import dayjs from 'dayjs'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -133,15 +133,15 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave
const handleCopySource = useCallback(() => {
navigator.clipboard.writeText(children)
- window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
+ window.toast.success(t('code_block.copy.success'))
}, [children, t])
const handleDownloadSource = useCallback(() => {
let fileName = ''
// 尝试提取 HTML 标题
- if (language === 'html' && children.includes('')) {
- fileName = extractHtmlTitle(children) || ''
+ if (language === 'html') {
+ fileName = getFileNameFromHtmlTitle(extractHtmlTitle(children)) || ''
}
// 默认使用日期格式命名
diff --git a/src/renderer/src/components/ContextMenu/index.tsx b/src/renderer/src/components/ContextMenu/index.tsx
index 610afa695f..3af2fa2e3f 100644
--- a/src/renderer/src/components/ContextMenu/index.tsx
+++ b/src/renderer/src/components/ContextMenu/index.tsx
@@ -22,10 +22,10 @@ const ContextMenu: React.FC = ({ children }) => {
navigator.clipboard
.writeText(selectedText)
.then(() => {
- window.message.success({ content: t('message.copied'), key: 'copy-message' })
+ window.toast.success(t('message.copied'))
})
.catch(() => {
- window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' })
+ window.toast.error(t('message.copy.failed'))
})
}
}
diff --git a/src/renderer/src/components/CopyButton.tsx b/src/renderer/src/components/CopyButton.tsx
index bdc34a0675..cfa80a02c5 100644
--- a/src/renderer/src/components/CopyButton.tsx
+++ b/src/renderer/src/components/CopyButton.tsx
@@ -32,10 +32,10 @@ const CopyButton: FC = ({
navigator.clipboard
.writeText(textToCopy)
.then(() => {
- window.message?.success(t('message.copy.success'))
+ window.toast?.success(t('message.copy.success'))
})
.catch(() => {
- window.message?.error(t('message.copy.failed'))
+ window.toast?.error(t('message.copy.failed'))
})
}
diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/src/renderer/src/components/ErrorBoundary.tsx
index 5bfeaeb620..1ee3967e9c 100644
--- a/src/renderer/src/components/ErrorBoundary.tsx
+++ b/src/renderer/src/components/ErrorBoundary.tsx
@@ -1,10 +1,10 @@
+import { Button } from '@heroui/button'
import { formatErrorMessage } from '@renderer/utils/error'
-import { Alert, Button, Space } from 'antd'
+import { Alert, Space } from 'antd'
import { ComponentType, ReactNode } from 'react'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
-
const DefaultFallback: ComponentType = (props: FallbackProps): ReactNode => {
const { t } = useTranslation()
const { error } = props
@@ -23,10 +23,10 @@ const DefaultFallback: ComponentType = (props: FallbackProps): Re
type="error"
action={
-
+
{t('error.boundary.default.devtools')}
-
+
{t('error.boundary.default.reload')}
diff --git a/src/renderer/src/components/HorizontalScrollContainer/index.tsx b/src/renderer/src/components/HorizontalScrollContainer/index.tsx
new file mode 100644
index 0000000000..ed5cdc52de
--- /dev/null
+++ b/src/renderer/src/components/HorizontalScrollContainer/index.tsx
@@ -0,0 +1,179 @@
+import Scrollbar from '@renderer/components/Scrollbar'
+import { ChevronRight } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
+import styled from 'styled-components'
+
+/**
+ * 水平滚动容器
+ * @param children 子元素
+ * @param dependencies 依赖项
+ * @param scrollDistance 滚动距离
+ * @param className 类名
+ * @param gap 间距
+ * @param expandable 是否可展开
+ */
+export interface HorizontalScrollContainerProps {
+ children: React.ReactNode
+ dependencies?: readonly unknown[]
+ scrollDistance?: number
+ className?: string
+ gap?: string
+ expandable?: boolean
+}
+
+const HorizontalScrollContainer: React.FC = ({
+ children,
+ dependencies = [],
+ scrollDistance = 200,
+ className,
+ gap = '8px',
+ expandable = false
+}) => {
+ const scrollRef = useRef(null)
+ const [canScroll, setCanScroll] = useState(false)
+ const [isExpanded, setIsExpanded] = useState(false)
+ const [isScrolledToEnd, setIsScrolledToEnd] = useState(false)
+
+ const handleScrollRight = (event: React.MouseEvent) => {
+ scrollRef.current?.scrollBy({ left: scrollDistance, behavior: 'smooth' })
+ event.stopPropagation()
+ }
+
+ const handleContainerClick = (e: React.MouseEvent) => {
+ if (expandable) {
+ // 确保不是点击了其他交互元素(如 tag 的关闭按钮)
+ const target = e.target as HTMLElement
+ if (!target.closest('[data-no-expand]')) {
+ setIsExpanded(!isExpanded)
+ }
+ }
+ }
+
+ const checkScrollability = () => {
+ const scrollElement = scrollRef.current
+ if (scrollElement) {
+ const parentElement = scrollElement.parentElement
+ const availableWidth = parentElement ? parentElement.clientWidth : scrollElement.clientWidth
+
+ // 确保容器不会超出可用宽度
+ const canScrollValue = scrollElement.scrollWidth > Math.min(availableWidth, scrollElement.clientWidth)
+ setCanScroll(canScrollValue)
+
+ // 检查是否滚动到最右侧
+ if (canScrollValue) {
+ const isAtEnd = Math.abs(scrollElement.scrollLeft + scrollElement.clientWidth - scrollElement.scrollWidth) <= 1
+ setIsScrolledToEnd(isAtEnd)
+ } else {
+ setIsScrolledToEnd(false)
+ }
+ }
+ }
+
+ useEffect(() => {
+ const scrollElement = scrollRef.current
+ if (!scrollElement) return
+
+ checkScrollability()
+
+ const handleScroll = () => {
+ checkScrollability()
+ }
+
+ const resizeObserver = new ResizeObserver(checkScrollability)
+ resizeObserver.observe(scrollElement)
+
+ scrollElement.addEventListener('scroll', handleScroll)
+ window.addEventListener('resize', checkScrollability)
+
+ return () => {
+ resizeObserver.disconnect()
+ scrollElement.removeEventListener('scroll', handleScroll)
+ window.removeEventListener('resize', checkScrollability)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, dependencies)
+
+ return (
+
+
+ {children}
+
+ {canScroll && !isExpanded && !isScrolledToEnd && (
+
+
+
+ )}
+
+ )
+}
+
+const Container = styled.div<{ $expandable?: boolean; $disableHoverButton?: boolean }>`
+ display: flex;
+ align-items: center;
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: 100%;
+ position: relative;
+ cursor: ${(props) => (props.$expandable ? 'pointer' : 'default')};
+
+ ${(props) =>
+ !props.$disableHoverButton &&
+ `
+ &:hover {
+ .scroll-right-button {
+ opacity: 1;
+ }
+ }
+ `}
+`
+
+const ScrollContent = styled(Scrollbar)<{
+ $gap: string
+ $isExpanded?: boolean
+ $expandable?: boolean
+}>`
+ display: flex;
+ overflow-x: ${(props) => (props.$expandable && props.$isExpanded ? 'hidden' : 'auto')};
+ overflow-y: hidden;
+ white-space: ${(props) => (props.$expandable && props.$isExpanded ? 'normal' : 'nowrap')};
+ gap: ${(props) => props.$gap};
+ flex-wrap: ${(props) => (props.$expandable && props.$isExpanded ? 'wrap' : 'nowrap')};
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+`
+
+const ScrollButton = styled.div`
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 1;
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+ cursor: pointer;
+ background: var(--color-background);
+ border-radius: 50%;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow:
+ 0 6px 16px 0 rgba(0, 0, 0, 0.08),
+ 0 3px 6px -4px rgba(0, 0, 0, 0.12),
+ 0 9px 28px 8px rgba(0, 0, 0, 0.05);
+ color: var(--color-text-2);
+
+ &:hover {
+ color: var(--color-text);
+ background: var(--color-list-item);
+ }
+`
+
+export default HorizontalScrollContainer
diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx
index a83685e4db..b17f7397c0 100644
--- a/src/renderer/src/components/Icons/SVGIcon.tsx
+++ b/src/renderer/src/components/Icons/SVGIcon.tsx
@@ -1,5 +1,5 @@
import { lightbulbVariants } from '@renderer/utils/motionVariants'
-import { motion } from 'framer-motion'
+import { motion } from 'motion/react'
import { SVGProps } from 'react'
export const StreamlineGoodHealthAndWellBeing = (
diff --git a/src/renderer/src/components/ImageViewer.tsx b/src/renderer/src/components/ImageViewer.tsx
index 1f1cbccb43..179babeaf6 100644
--- a/src/renderer/src/components/ImageViewer.tsx
+++ b/src/renderer/src/components/ImageViewer.tsx
@@ -62,10 +62,10 @@ const ImageViewer: React.FC = ({ src, style, ...props }) => {
])
}
- window.message.success(t('message.copy.success'))
+ window.toast.success(t('message.copy.success'))
} catch (error) {
logger.error('Failed to copy image:', error as Error)
- window.message.error(t('message.copy.failed'))
+ window.toast.error(t('message.copy.failed'))
}
}
@@ -77,7 +77,7 @@ const ImageViewer: React.FC = ({ src, style, ...props }) => {
icon: ,
onClick: () => {
navigator.clipboard.writeText(src)
- window.message.success(t('message.copy.success'))
+ window.toast.success(t('message.copy.success'))
}
},
{
diff --git a/src/renderer/src/components/InputEmbeddingDimension.tsx b/src/renderer/src/components/InputEmbeddingDimension.tsx
index 7d7f452d01..056ebaea50 100644
--- a/src/renderer/src/components/InputEmbeddingDimension.tsx
+++ b/src/renderer/src/components/InputEmbeddingDimension.tsx
@@ -35,13 +35,13 @@ const InputEmbeddingDimension = ({
const handleFetchDimension = useCallback(async () => {
if (!model) {
logger.warn('Failed to get embedding dimensions: no model')
- window.message.error(t('knowledge.embedding_model_required'))
+ window.toast.error(t('knowledge.embedding_model_required'))
return
}
if (!provider) {
logger.warn('Failed to get embedding dimensions: no provider')
- window.message.error(t('knowledge.provider_not_found'))
+ window.toast.error(t('knowledge.provider_not_found'))
return
}
@@ -56,7 +56,7 @@ const InputEmbeddingDimension = ({
onChange?.(dimension)
} catch (error) {
logger.error(t('message.error.get_embedding_dimensions'), error as Error)
- window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
+ window.toast.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
} finally {
setLoading(false)
}
diff --git a/src/renderer/src/components/LocalBackupManager.tsx b/src/renderer/src/components/LocalBackupManager.tsx
index c7b86c94bc..fdc64222db 100644
--- a/src/renderer/src/components/LocalBackupManager.tsx
+++ b/src/renderer/src/components/LocalBackupManager.tsx
@@ -45,7 +45,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
total: files.length
}))
} catch (error: any) {
- window.message.error(`${t('settings.data.local.backup.manager.fetch.error')}: ${error.message}`)
+ window.toast.error(`${t('settings.data.local.backup.manager.fetch.error')}: ${error.message}`)
} finally {
setLoading(false)
}
@@ -90,13 +90,13 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
for (const key of selectedRowKeys) {
await window.api.backup.deleteLocalBackupFile(key.toString(), localBackupDir)
}
- window.message.success(
+ window.toast.success(
t('settings.data.local.backup.manager.delete.success.multiple', { count: selectedRowKeys.length })
)
setSelectedRowKeys([])
await fetchBackupFiles()
} catch (error: any) {
- window.message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
+ window.toast.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
} finally {
setDeleting(false)
}
@@ -123,7 +123,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
message.success(t('settings.data.local.backup.manager.delete.success.single'))
await fetchBackupFiles()
} catch (error: any) {
- window.message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
+ window.toast.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
} finally {
setDeleting(false)
}
@@ -150,7 +150,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
message.success(t('settings.data.local.backup.manager.restore.success'))
onClose() // Close the modal
} catch (error: any) {
- window.message.error(`${t('settings.data.local.backup.manager.restore.error')}: ${error.message}`)
+ window.toast.error(`${t('settings.data.local.backup.manager.restore.error')}: ${error.message}`)
} finally {
setRestoring(false)
}
diff --git a/src/renderer/src/components/MinApp/MinApp.tsx b/src/renderer/src/components/MinApp/MinApp.tsx
index 1fce7299b2..3a7255d199 100644
--- a/src/renderer/src/components/MinApp/MinApp.tsx
+++ b/src/renderer/src/components/MinApp/MinApp.tsx
@@ -91,14 +91,14 @@ const MinApp: FC = ({ app, onClick, size = 60, isLast }) => {
const customApps = JSON.parse(content)
const updatedApps = customApps.filter((customApp: MinAppType) => customApp.id !== app.id)
await window.api.file.writeWithId('custom-minapps.json', JSON.stringify(updatedApps, null, 2))
- window.message.success(t('settings.miniapps.custom.remove_success'))
+ window.toast.success(t('settings.miniapps.custom.remove_success'))
const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
updateDefaultMinApps(reloadedApps)
updateMinapps(minapps.filter((item) => item.id !== app.id))
updatePinnedMinapps(pinned.filter((item) => item.id !== app.id))
updateDisabledMinapps(disabled.filter((item) => item.id !== app.id))
} catch (error) {
- window.message.error(t('settings.miniapps.custom.remove_error'))
+ window.toast.error(t('settings.miniapps.custom.remove_error'))
logger.error('Failed to remove custom mini app:', error as Error)
}
}
@@ -135,6 +135,7 @@ const Container = styled.div`
align-items: center;
cursor: pointer;
overflow: hidden;
+ min-height: 85px;
`
const IconContainer = styled.div`
diff --git a/src/renderer/src/components/MinApp/MinAppTabsPool.tsx b/src/renderer/src/components/MinApp/MinAppTabsPool.tsx
new file mode 100644
index 0000000000..af2c255f5f
--- /dev/null
+++ b/src/renderer/src/components/MinApp/MinAppTabsPool.tsx
@@ -0,0 +1,143 @@
+import { loggerService } from '@logger'
+import WebviewContainer from '@renderer/components/MinApp/WebviewContainer'
+import { useRuntime } from '@renderer/hooks/useRuntime'
+import { useNavbarPosition } from '@renderer/hooks/useSettings'
+import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
+import { WebviewTag } from 'electron'
+import React, { useEffect, useRef } from 'react'
+import { useLocation } from 'react-router-dom'
+import styled from 'styled-components'
+
+/**
+ * Mini-app WebView pool for Tab 模式 (顶部导航).
+ *
+ * 与 Popup 模式相似,但独立存在:
+ * - 仅在 isTopNavbar=true 且访问 /apps 路由时显示
+ * - 保证已打开的 keep-alive 小程序对应的 不被卸载,只通过 display 切换
+ * - LRU 淘汰通过 openedKeepAliveMinapps 变化自动移除 DOM
+ *
+ * 后续可演进:与 Popup 共享同一实例(方案 B)。
+ */
+const logger = loggerService.withContext('MinAppTabsPool')
+
+const MinAppTabsPool: React.FC = () => {
+ const { openedKeepAliveMinapps, currentMinappId } = useRuntime()
+ const { isTopNavbar } = useNavbarPosition()
+ const location = useLocation()
+
+ // webview refs(池内部自用,用于控制显示/隐藏)
+ const webviewRefs = useRef>(new Map())
+
+ // 使用集中工具进行更稳健的路由判断
+ const isAppDetail = (() => {
+ const pathname = location.pathname
+ if (pathname === '/apps') return false
+ if (!pathname.startsWith('/apps/')) return false
+ const parts = pathname.split('/').filter(Boolean) // ['apps', '', ...]
+ return parts.length >= 2
+ })()
+ const shouldShow = isTopNavbar && isAppDetail
+
+ // 组合当前需要渲染的列表(保持顺序即可)
+ const apps = openedKeepAliveMinapps
+
+ /** 设置 ref 回调 */
+ const handleSetRef = (appid: string, el: WebviewTag | null) => {
+ if (el) {
+ webviewRefs.current.set(appid, el)
+ } else {
+ webviewRefs.current.delete(appid)
+ }
+ }
+
+ /** WebView 加载完成回调 */
+ const handleLoaded = (appid: string) => {
+ setWebviewLoaded(appid, true)
+ logger.debug(`TabPool webview loaded: ${appid}`)
+ }
+
+ /** 记录导航(暂未外曝 URL 状态,后续可接入全局 URL Map) */
+ const handleNavigate = (appid: string, url: string) => {
+ logger.debug(`TabPool webview navigate: ${appid} -> ${url}`)
+ }
+
+ /** 切换显示状态:仅当前 active 的显示,其余隐藏 */
+ useEffect(() => {
+ webviewRefs.current.forEach((ref, id) => {
+ if (!ref) return
+ const active = id === currentMinappId && shouldShow
+ ref.style.display = active ? 'inline-flex' : 'none'
+ })
+ }, [currentMinappId, shouldShow, apps.length])
+
+ /** 当某个已在 Map 里但不再属于 openedKeepAlive 时,移除引用(React 自身会卸载元素) */
+ useEffect(() => {
+ const existing = Array.from(webviewRefs.current.keys())
+ existing.forEach((id) => {
+ if (!apps.find((a) => a.id === id)) {
+ webviewRefs.current.delete(id)
+ // loaded 状态也清理(LRU 已在其它地方清除,双保险)
+ if (getWebviewLoaded(id)) {
+ setWebviewLoaded(id, false)
+ }
+ }
+ })
+ }, [apps])
+
+ // 不显示时直接 hidden,避免闪烁;仍然保留 DOM 做保活
+ const toolbarHeight = 35 // 与 MinimalToolbar 高度保持一致
+
+ return (
+
+ {apps.map((app) => (
+
+
+
+ ))}
+
+ )
+}
+
+const PoolContainer = styled.div`
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ /* top 在运行时通过 style 注入 (toolbarHeight) */
+ width: 100%;
+ overflow: hidden;
+ border-radius: 0 0 8px 8px;
+ z-index: 1;
+ pointer-events: none;
+ & webview {
+ pointer-events: auto;
+ }
+`
+
+const WebviewWrapper = styled.div<{ $active: boolean }>`
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ /* display 控制在内部 webview 元素上做,这里保持结构稳定 */
+ pointer-events: ${(props) => (props.$active ? 'auto' : 'none')};
+`
+
+export default MinAppTabsPool
diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx
index 13a1a933b7..a915e820c7 100644
--- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx
+++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx
@@ -281,13 +281,6 @@ const MinappPopupContainer: React.FC = () => {
/** the callback function to set the webviews ref */
const handleWebviewSetRef = (appid: string, element: WebviewTag | null) => {
- webviewRefs.current.set(appid, element)
-
- if (!webviewRefs.current.has(appid)) {
- webviewRefs.current.set(appid, null)
- return
- }
-
if (element) {
webviewRefs.current.set(appid, element)
} else {
@@ -399,10 +392,10 @@ const MinappPopupContainer: React.FC = () => {
navigator.clipboard
.writeText(url)
.then(() => {
- window.message.success('URL ' + t('message.copy.success'))
+ window.toast.success('URL ' + t('message.copy.success'))
})
.catch(() => {
- window.message.error('URL ' + t('message.copy.failed'))
+ window.toast.error('URL ' + t('message.copy.failed'))
})
}
@@ -548,6 +541,9 @@ const MinappPopupContainer: React.FC = () => {
},
content: {
backgroundColor: window.root.style.background
+ },
+ body: {
+ borderTopLeftRadius: '10px'
}
}}>
{/* 在所有小程序中显示GoogleLoginTip */}
diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx
index 30dc9ef7f4..21c53efe78 100644
--- a/src/renderer/src/components/MinApp/WebviewContainer.tsx
+++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx
@@ -115,6 +115,7 @@ const WebviewContainer = memo(
= ({ provider, onSuccess, ...buttonProps }) => {
const handleSuccess = (key: string) => {
if (key.trim()) {
onSuccess?.(key)
- window.message.success({ content: t('auth.get_key_success'), key: 'auth-success' })
+ window.toast.success(t('auth.get_key_success'))
}
}
diff --git a/src/renderer/src/components/OGCard.tsx b/src/renderer/src/components/OGCard.tsx
index 8a0036e8e2..93446b6749 100644
--- a/src/renderer/src/components/OGCard.tsx
+++ b/src/renderer/src/components/OGCard.tsx
@@ -1,8 +1,7 @@
-import CherryLogo from '@renderer/assets/images/banner.png'
import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser'
import { Skeleton, Typography } from 'antd'
-import { useEffect, useMemo } from 'react'
+import { useCallback, useEffect, useMemo } from 'react'
import styled from 'styled-components'
const { Title, Paragraph } = Typography
@@ -11,6 +10,8 @@ type Props = {
show: boolean
}
+const IMAGE_HEIGHT = '9rem' // equals h-36
+
export const OGCard = ({ link, show }: Props) => {
const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const
const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph)
@@ -32,6 +33,14 @@ export const OGCard = ({ link, show }: Props) => {
}
}, [parseMetadata, isLoading, show])
+ const GeneratedGraph = useCallback(() => {
+ return (
+
+
{metadata['og:title'] || hostname}
+
+ )
+ }, [hostname, metadata])
+
if (isLoading) {
return
}
@@ -45,7 +54,7 @@ export const OGCard = ({ link, show }: Props) => {
)}
{!hasImage && (
-
+
)}
@@ -113,8 +122,8 @@ const PreviewContainer = styled.div<{ hasImage?: boolean }>`
const PreviewImageContainer = styled.div`
width: 100%;
- height: 140px;
- min-height: 140px;
+ height: ${IMAGE_HEIGHT};
+ min-height: ${IMAGE_HEIGHT};
overflow: hidden;
`
@@ -128,7 +137,7 @@ const PreviewContent = styled.div`
const PreviewImage = styled.img`
width: 100%;
- height: 140px;
+ height: ${IMAGE_HEIGHT};
object-fit: cover;
`
diff --git a/src/renderer/src/components/ObsidianExportDialog.tsx b/src/renderer/src/components/ObsidianExportDialog.tsx
index 76b796319a..9cad3986e7 100644
--- a/src/renderer/src/components/ObsidianExportDialog.tsx
+++ b/src/renderer/src/components/ObsidianExportDialog.tsx
@@ -244,7 +244,7 @@ const PopupContainer: React.FC = ({
content = `---\ntitle: ${state.title}\ncreated: ${state.createdAt}\nsource: ${state.source}\ntags: ${state.tags}\n---\n${markdown}`
}
if (content === '') {
- window.message.error(i18n.t('chat.topics.export.obsidian_export_failed'))
+ window.toast.error(i18n.t('chat.topics.export.obsidian_export_failed'))
return
}
await navigator.clipboard.writeText(content)
diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts
index c4c9459ed0..e69341a864 100644
--- a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts
+++ b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts
@@ -302,11 +302,9 @@ async function getModelForCheck(provider: Provider, t: TFunction): Promise !isEmbeddingModel(model) && !isRerankModel(model))
if (isEmpty(modelsToCheck)) {
- window.message.error({
- key: 'no-models',
- style: { marginTop: '3vh' },
- duration: 5,
- content: t('settings.provider.no_models_for_check')
+ window.toast.error({
+ title: t('settings.provider.no_models_for_check'),
+ timeout: 5000
})
return null
}
diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx
index 75d32b8bc5..83c9389935 100644
--- a/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx
+++ b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx
@@ -61,10 +61,7 @@ const ApiKeyItem: FC = ({
const handleSave = () => {
const result = onUpdate(editValue)
if (!result.isValid) {
- window.message.warning({
- key: 'api-key-error',
- content: result.error
- })
+ window.toast.warning(result.error)
return
}
diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts
index bc230c577d..ad713b40fb 100644
--- a/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts
+++ b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts
@@ -3,10 +3,15 @@ import { PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types
/**
* API key 格式有效性
*/
-export type ApiKeyValidity = {
- isValid: boolean
- error?: string
-}
+export type ApiKeyValidity =
+ | {
+ isValid: true
+ error?: never
+ }
+ | {
+ isValid: false
+ error: string
+ }
export type ApiProvider = Provider | WebSearchProvider | PreprocessProvider
diff --git a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx
index f8789735e9..9a7d7ee93a 100644
--- a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx
+++ b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx
@@ -281,7 +281,7 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => {
resolve({ success: true, savedCount })
} catch (error) {
logger.error('save failed:', error as Error)
- window.message.error(
+ window.toast.error(
t(isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed')
)
setLoading(false)
diff --git a/src/renderer/src/components/Popups/TextEditPopup.tsx b/src/renderer/src/components/Popups/TextEditPopup.tsx
index 65b250b865..6e9f126e2f 100644
--- a/src/renderer/src/components/Popups/TextEditPopup.tsx
+++ b/src/renderer/src/components/Popups/TextEditPopup.tsx
@@ -113,10 +113,7 @@ const PopupContainer: React.FC = ({
}
} catch (error) {
logger.error('Translation failed:', error as Error)
- window.message.error({
- content: t('translate.error.failed'),
- key: 'translate-message'
- })
+ window.toast.error(t('translate.error.failed'))
} finally {
if (isMounted.current) {
setIsTranslating(false)
diff --git a/src/renderer/src/components/Popups/TextFilePreview.tsx b/src/renderer/src/components/Popups/TextFilePreview.tsx
new file mode 100644
index 0000000000..f5fa787d0b
--- /dev/null
+++ b/src/renderer/src/components/Popups/TextFilePreview.tsx
@@ -0,0 +1,105 @@
+import { Modal } from 'antd'
+import { useState } from 'react'
+import styled from 'styled-components'
+
+import CodeEditor from '../CodeEditor'
+import { TopView } from '../TopView'
+
+interface Props {
+ text: string
+ title: string
+ extension?: string
+ resolve: (data: any) => void
+}
+
+const PopupContainer: React.FC = ({ text, title, extension, resolve }) => {
+ const [open, setOpen] = useState(true)
+
+ const onOk = () => {
+ setOpen(false)
+ }
+
+ const onCancel = () => {
+ setOpen(false)
+ }
+
+ const onClose = () => {
+ resolve({})
+ }
+
+ TextFilePreviewPopup.hide = onCancel
+
+ return (
+
+ {extension !== undefined ? (
+
+ ) : (
+ {text}
+ )}
+
+ )
+}
+
+const Text = styled.div`
+ padding: 16px;
+ white-space: pre;
+ cursor: text;
+`
+
+const Editor = styled(CodeEditor)`
+ .cm-line {
+ cursor: text;
+ }
+`
+
+export default class TextFilePreviewPopup {
+ static topviewId = 0
+ static hide() {
+ TopView.hide('TextFilePreviewPopup')
+ }
+ static show(text: string, title: string, extension?: string) {
+ return new Promise((resolve) => {
+ TopView.show(
+ {
+ resolve(v)
+ TopView.hide('TextFilePreviewPopup')
+ }}
+ />,
+ 'TextFilePreviewPopup'
+ )
+ })
+ }
+}
diff --git a/src/renderer/src/components/Popups/UserPopup.tsx b/src/renderer/src/components/Popups/UserPopup.tsx
index 21cd5d4e66..fe424a8c12 100644
--- a/src/renderer/src/components/Popups/UserPopup.tsx
+++ b/src/renderer/src/components/Popups/UserPopup.tsx
@@ -49,7 +49,7 @@ const PopupContainer: React.FC = ({ resolve }) => {
dispatch(setAvatar(emoji))
setEmojiPickerOpen(false)
} catch (error: any) {
- window.message.error(error.message)
+ window.toast.error(error.message)
}
}
const handleReset = async () => {
@@ -58,7 +58,7 @@ const PopupContainer: React.FC = ({ resolve }) => {
dispatch(setAvatar(DefaultAvatar))
setDropdownOpen(false)
} catch (error: any) {
- window.message.error(error.message)
+ window.toast.error(error.message)
}
}
const items = [
@@ -83,7 +83,7 @@ const PopupContainer: React.FC = ({ resolve }) => {
dispatch(setAvatar(await ImageStorage.get('avatar')))
setDropdownOpen(false)
} catch (error: any) {
- window.message.error(error.message)
+ window.toast.error(error.message)
}
}}>
{t('settings.general.image_upload')}
diff --git a/src/renderer/src/components/QuickPanel/provider.tsx b/src/renderer/src/components/QuickPanel/provider.tsx
index c06d337248..57eae70ef2 100644
--- a/src/renderer/src/components/QuickPanel/provider.tsx
+++ b/src/renderer/src/components/QuickPanel/provider.tsx
@@ -32,6 +32,11 @@ export const QuickPanelProvider: React.FC = ({ children
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
}, [])
+ // 添加更新整个列表的方法
+ const updateList = useCallback((newList: QuickPanelListItem[]) => {
+ setList(newList)
+ }, [])
+
const open = useCallback((options: QuickPanelOpenOptions) => {
if (clearTimer.current) {
clearTimeout(clearTimer.current)
@@ -56,7 +61,7 @@ export const QuickPanelProvider: React.FC = ({ children
const close = useCallback(
(action?: QuickPanelCloseAction, searchText?: string) => {
setIsVisible(false)
- onClose?.({ symbol, action, triggerInfo, searchText, item: {} as QuickPanelListItem, multiple: false })
+ onClose?.({ action, searchText, item: {} as QuickPanelListItem, context: this })
clearTimer.current = setTimeout(() => {
setList([])
@@ -68,7 +73,7 @@ export const QuickPanelProvider: React.FC = ({ children
setTriggerInfo(undefined)
}, 200)
},
- [onClose, symbol, triggerInfo]
+ [onClose]
)
useEffect(() => {
@@ -85,6 +90,7 @@ export const QuickPanelProvider: React.FC = ({ children
open,
close,
updateItemSelection,
+ updateList,
isVisible,
symbol,
@@ -103,6 +109,7 @@ export const QuickPanelProvider: React.FC = ({ children
open,
close,
updateItemSelection,
+ updateList,
isVisible,
symbol,
list,
diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts
index d8e2ff26b0..97e072dea0 100644
--- a/src/renderer/src/components/QuickPanel/types.ts
+++ b/src/renderer/src/components/QuickPanel/types.ts
@@ -8,13 +8,10 @@ export type QuickPanelTriggerInfo = {
}
export type QuickPanelCallBackOptions = {
- symbol: string
+ context: QuickPanelContextType
action: QuickPanelCloseAction
item: QuickPanelListItem
searchText?: string
- /** 是否处于多选状态 */
- multiple?: boolean
- triggerInfo?: QuickPanelTriggerInfo
}
export type QuickPanelOpenOptions = {
@@ -68,6 +65,7 @@ export interface QuickPanelContextType {
readonly open: (options: QuickPanelOpenOptions) => void
readonly close: (action?: QuickPanelCloseAction, searchText?: string) => void
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
+ readonly updateList: (newList: QuickPanelListItem[]) => void
readonly isVisible: boolean
readonly symbol: string
readonly list: QuickPanelListItem[]
diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx
index 08878b8478..30955f96f3 100644
--- a/src/renderer/src/components/QuickPanel/view.tsx
+++ b/src/renderer/src/components/QuickPanel/view.tsx
@@ -222,11 +222,10 @@ export const QuickPanelView: React.FC = ({ setInputText }) => {
// 创建更新后的item对象用于回调
const updatedItem = { ...item, isSelected: newSelectedState }
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
- symbol: ctx.symbol,
+ context: ctx,
action,
item: updatedItem,
- searchText: searchText,
- multiple: ctx.multiple
+ searchText: searchText
}
ctx.beforeAction?.(quickPanelCallBackOptions)
@@ -236,11 +235,10 @@ export const QuickPanelView: React.FC = ({ setInputText }) => {
}
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
- symbol: ctx.symbol,
+ context: ctx,
action,
item,
- searchText: searchText,
- multiple: ctx.multiple
+ searchText: searchText
}
ctx.beforeAction?.(quickPanelCallBackOptions)
diff --git a/src/renderer/src/components/RichEditor/CommandListPopover.tsx b/src/renderer/src/components/RichEditor/CommandListPopover.tsx
index f75e651866..4f8df4d20c 100644
--- a/src/renderer/src/components/RichEditor/CommandListPopover.tsx
+++ b/src/renderer/src/components/RichEditor/CommandListPopover.tsx
@@ -1,4 +1,4 @@
-import '@renderer/assets/styles/CommandListPopover.scss'
+import '@renderer/assets/styles/CommandListPopover.css'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { useTheme } from '@renderer/context/ThemeProvider'
diff --git a/src/renderer/src/components/RichEditor/components/YamlFrontMatterNodeView.tsx b/src/renderer/src/components/RichEditor/components/YamlFrontMatterNodeView.tsx
index 67d2c2126b..7b2bde078e 100644
--- a/src/renderer/src/components/RichEditor/components/YamlFrontMatterNodeView.tsx
+++ b/src/renderer/src/components/RichEditor/components/YamlFrontMatterNodeView.tsx
@@ -15,7 +15,7 @@ interface ParsedProperty {
type: 'string' | 'array' | 'date' | 'number' | 'boolean'
}
-const YamlFrontMatterNodeView: React.FC = ({ node, updateAttributes }) => {
+const YamlFrontMatterNodeView: React.FC = ({ node, updateAttributes, editor }) => {
const { t } = useTranslation()
const [editingProperty, setEditingProperty] = useState(null)
const [newPropertyName, setNewPropertyName] = useState('')
@@ -408,6 +408,11 @@ const YamlFrontMatterNodeView: React.FC = ({ node, updateAttribut
)
}
+ // Check if there's content in the entire editor (excluding YAML front matter)
+ const hasContent = useMemo(() => {
+ return editor.getText().trim().length > 0
+ }, [editor])
+
return (
= ({ node, updateAttribut
}
}}>
{
// Prevent node selection when clicking inside properties
e.stopPropagation()
@@ -485,7 +491,7 @@ const YamlFrontMatterNodeView: React.FC = ({ node, updateAttribut
/>
) : (
- setShowAddProperty(true)}>
+ setShowAddProperty(true)}>
@@ -497,7 +503,7 @@ const YamlFrontMatterNodeView: React.FC = ({ node, updateAttribut
)
}
-const PropertiesContainer = styled.div`
+const PropertiesContainer = styled.div<{ hasContent?: boolean }>`
margin: 16px 0;
padding: 0;
display: flex;
@@ -705,7 +711,7 @@ const ArrayInput = styled(Input)`
}
`
-const AddPropertyRow = styled.button`
+const AddPropertyRow = styled.button<{ hasContent?: boolean }>`
display: flex;
align-items: center;
padding: 6px 8px;
@@ -716,16 +722,22 @@ const AddPropertyRow = styled.button`
cursor: pointer;
border-radius: 6px;
width: 100%;
+ opacity: ${({ hasContent }) => (hasContent ? 0 : 1)};
+ transition: opacity 0.2s;
&:hover {
background-color: var(--color-hover);
}
+
+ ${PropertiesContainer}:hover & {
+ opacity: 1;
+ }
`
const AddPropertyText = styled.div`
font-size: 14px;
font-family: var(--font-family);
- color: var(--color-text);
+ color: var(--color-text-secondary);
`
const PropertyActions = styled.div`
diff --git a/src/renderer/src/components/RichEditor/index.tsx b/src/renderer/src/components/RichEditor/index.tsx
index a14af5d0fc..0b9e3876ac 100644
--- a/src/renderer/src/components/RichEditor/index.tsx
+++ b/src/renderer/src/components/RichEditor/index.tsx
@@ -47,7 +47,8 @@ const RichEditor = ({
showTableOfContents = false,
enableContentSearch = false,
isFullWidth = false,
- fontFamily = 'default'
+ fontFamily = 'default',
+ fontSize = 16
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
}: RichEditorProps & { ref?: React.RefObject }) => {
// Use the rich editor hook for complete editor management
@@ -388,6 +389,7 @@ const RichEditor = ({
$maxHeight={maxHeight}
$isFullWidth={isFullWidth}
$fontFamily={fontFamily}
+ $fontSize={fontSize}
onKeyDown={onKeyDownEditor}>
{showToolbar && (
`
display: flex;
flex-direction: column;
@@ -16,6 +17,7 @@ export const RichEditorWrapper = styled.div<{
width: ${({ $isFullWidth }) => ($isFullWidth ? '100%' : '60%')};
margin: ${({ $isFullWidth }) => ($isFullWidth ? '0' : '0 auto')};
font-family: ${({ $fontFamily }) => ($fontFamily === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)')};
+ ${({ $fontSize }) => $fontSize && `--editor-font-size: ${$fontSize}px;`}
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight}px;`}
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight}px;`}
diff --git a/src/renderer/src/components/RichEditor/types.ts b/src/renderer/src/components/RichEditor/types.ts
index 66e8280967..8804210aef 100644
--- a/src/renderer/src/components/RichEditor/types.ts
+++ b/src/renderer/src/components/RichEditor/types.ts
@@ -48,6 +48,8 @@ export interface RichEditorProps {
isFullWidth?: boolean
/** Font family setting */
fontFamily?: 'default' | 'serif'
+ /** Font size in pixels */
+ fontSize?: number
}
export interface ToolbarItem {
diff --git a/src/renderer/src/components/S3BackupManager.tsx b/src/renderer/src/components/S3BackupManager.tsx
index e31b988ae4..4327a6c217 100644
--- a/src/renderer/src/components/S3BackupManager.tsx
+++ b/src/renderer/src/components/S3BackupManager.tsx
@@ -37,7 +37,7 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
const fetchBackupFiles = useCallback(async () => {
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
- window.message.error(t('settings.data.s3.manager.config.incomplete'))
+ window.toast.error(t('settings.data.s3.manager.config.incomplete'))
return
}
@@ -61,7 +61,7 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
total: files.length
}))
} catch (error: any) {
- window.message.error(t('settings.data.s3.manager.files.fetch.error', { message: error.message }))
+ window.toast.error(t('settings.data.s3.manager.files.fetch.error', { message: error.message }))
} finally {
setLoading(false)
}
@@ -84,12 +84,12 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
const handleDeleteSelected = async () => {
if (selectedRowKeys.length === 0) {
- window.message.warning(t('settings.data.s3.manager.select.warning'))
+ window.toast.warning(t('settings.data.s3.manager.select.warning'))
return
}
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
- window.message.error(t('settings.data.s3.manager.config.incomplete'))
+ window.toast.error(t('settings.data.s3.manager.config.incomplete'))
return
}
@@ -118,13 +118,11 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
maxBackups: 0
})
}
- window.message.success(
- t('settings.data.s3.manager.delete.success.multiple', { count: selectedRowKeys.length })
- )
+ window.toast.success(t('settings.data.s3.manager.delete.success.multiple', { count: selectedRowKeys.length }))
setSelectedRowKeys([])
await fetchBackupFiles()
} catch (error: any) {
- window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
+ window.toast.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
} finally {
setDeleting(false)
}
@@ -134,7 +132,7 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
const handleDeleteSingle = async (fileName: string) => {
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
- window.message.error(t('settings.data.s3.manager.config.incomplete'))
+ window.toast.error(t('settings.data.s3.manager.config.incomplete'))
return
}
@@ -160,10 +158,10 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
syncInterval: 0,
maxBackups: 0
})
- window.message.success(t('settings.data.s3.manager.delete.success.single'))
+ window.toast.success(t('settings.data.s3.manager.delete.success.single'))
await fetchBackupFiles()
} catch (error: any) {
- window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
+ window.toast.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
} finally {
setDeleting(false)
}
@@ -173,7 +171,7 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
const handleRestore = async (fileName: string) => {
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
- window.message.error(t('settings.data.s3.manager.config.incomplete'))
+ window.toast.error(t('settings.data.s3.manager.config.incomplete'))
return
}
@@ -188,10 +186,10 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
setRestoring(true)
try {
await (restoreMethod || restoreFromS3)(fileName)
- window.message.success(t('settings.data.s3.restore.success'))
+ window.toast.success(t('settings.data.s3.restore.success'))
onClose() // 关闭模态框
} catch (error: any) {
- window.message.error(t('settings.data.s3.restore.error', { message: error.message }))
+ window.toast.error(t('settings.data.s3.restore.error', { message: error.message }))
} finally {
setRestoring(false)
}
diff --git a/src/renderer/src/components/S3Modals.tsx b/src/renderer/src/components/S3Modals.tsx
index 75c8b31b3a..96d72406fc 100644
--- a/src/renderer/src/components/S3Modals.tsx
+++ b/src/renderer/src/components/S3Modals.tsx
@@ -114,7 +114,7 @@ export function useS3RestoreModal({
const showRestoreModal = useCallback(async () => {
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
- window.message.error({ content: t('settings.data.s3.manager.config.incomplete'), key: 's3-error' })
+ window.toast.error(t('settings.data.s3.manager.config.incomplete'))
return
}
@@ -135,10 +135,7 @@ export function useS3RestoreModal({
})
setBackupFiles(files)
} catch (error: any) {
- window.message.error({
- content: t('settings.data.s3.manager.files.fetch.error', { message: error.message }),
- key: 'list-files-error'
- })
+ window.toast.error(t('settings.data.s3.manager.files.fetch.error', { message: error.message }))
} finally {
setLoadingFiles(false)
}
@@ -146,12 +143,9 @@ export function useS3RestoreModal({
const handleRestore = useCallback(async () => {
if (!selectedFile || !endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
- window.message.error({
- content: !selectedFile
- ? t('settings.data.s3.restore.file.required')
- : t('settings.data.s3.restore.config.incomplete'),
- key: 'restore-error'
- })
+ window.toast.error(
+ !selectedFile ? t('settings.data.s3.restore.file.required') : t('settings.data.s3.restore.config.incomplete')
+ )
return
}
@@ -177,13 +171,10 @@ export function useS3RestoreModal({
maxBackups: 0,
skipBackupFile: false
})
- window.message.success({ content: t('message.restore.success'), key: 's3-restore' })
+ window.toast.success(t('message.restore.success'))
setIsRestoreModalVisible(false)
} catch (error: any) {
- window.message.error({
- content: t('settings.data.s3.restore.error', { message: error.message }),
- key: 'restore-error'
- })
+ window.toast.error(t('settings.data.s3.restore.error', { message: error.message }))
} finally {
setRestoring(false)
}
diff --git a/src/renderer/src/components/Scrollbar/index.tsx b/src/renderer/src/components/Scrollbar/index.tsx
index 60258d8c8b..e50e128d50 100644
--- a/src/renderer/src/components/Scrollbar/index.tsx
+++ b/src/renderer/src/components/Scrollbar/index.tsx
@@ -2,12 +2,12 @@ import { throttle } from 'lodash'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
-interface Props extends Omit, 'onScroll'> {
+export interface ScrollbarProps extends Omit, 'onScroll'> {
ref?: React.Ref
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
}
-const Scrollbar: FC = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
+const Scrollbar: FC = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
const [isScrolling, setIsScrolling] = useState(false)
const timeoutRef = useRef(null)
diff --git a/src/renderer/src/components/Tab/TabContainer.tsx b/src/renderer/src/components/Tab/TabContainer.tsx
index cfe0cd76ab..c1c0dfbd3d 100644
--- a/src/renderer/src/components/Tab/TabContainer.tsx
+++ b/src/renderer/src/components/Tab/TabContainer.tsx
@@ -1,7 +1,7 @@
import { PlusOutlined } from '@ant-design/icons'
import { Sortable, useDndReorder } from '@renderer/components/dnd'
-import Scrollbar from '@renderer/components/Scrollbar'
-import { isLinux, isMac, isWin } from '@renderer/config/constant'
+import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
+import { isMac } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
@@ -14,9 +14,8 @@ import type { Tab } from '@renderer/store/tabs'
import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs'
import { classNames } from '@renderer/utils'
import { ThemeMode } from '@shared/data/preferenceTypes'
-import { Button, Tooltip } from 'antd'
+import { Tooltip } from 'antd'
import {
- ChevronRight,
FileSearch,
Folder,
Hammer,
@@ -33,12 +32,13 @@ import {
Terminal,
X
} from 'lucide-react'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import MinAppIcon from '../Icons/MinAppIcon'
+import MinAppTabsPool from '../MinApp/MinAppTabsPool'
import WindowControls from '../WindowControls'
interface TabsContainerProps {
@@ -97,8 +97,6 @@ const TabsContainer: React.FC = ({ children }) => {
const { hideMinappPopup } = useMinappPopup()
const { minapps } = useMinapps()
const { t } = useTranslation()
- const scrollRef = useRef(null)
- const [canScroll, setCanScroll] = useState(false)
const getTabId = (path: string): string => {
if (path === '/') return 'home'
@@ -174,31 +172,6 @@ const TabsContainer: React.FC = ({ children }) => {
navigate(tab.path)
}
- const handleScrollRight = () => {
- scrollRef.current?.scrollBy({ left: 200, behavior: 'smooth' })
- }
-
- useEffect(() => {
- const scrollElement = scrollRef.current
- if (!scrollElement) return
-
- const checkScrollability = () => {
- setCanScroll(scrollElement.scrollWidth > scrollElement.clientWidth)
- }
-
- checkScrollability()
-
- const resizeObserver = new ResizeObserver(checkScrollability)
- resizeObserver.observe(scrollElement)
-
- window.addEventListener('resize', checkScrollability)
-
- return () => {
- resizeObserver.disconnect()
- window.removeEventListener('resize', checkScrollability)
- }
- }, [tabs])
-
const visibleTabs = useMemo(() => tabs.filter((tab) => !specialTabs.includes(tab.id)), [tabs])
const { onSortEnd } = useDndReorder({
@@ -211,46 +184,39 @@ const TabsContainer: React.FC = ({ children }) => {
return (
-
-
- (
- handleTabClick(tab)}>
-
- {tab.id && {getTabIcon(tab.id, minapps)} }
- {getTabTitle(tab.id)}
-
- {tab.id !== 'home' && (
- {
- e.stopPropagation()
- closeTab(tab.id)
- }}>
-
-
- )}
-
- )}
- />
-
- {canScroll && (
-
-
-
- )}
+
+ (
+ handleTabClick(tab)}>
+
+ {tab.id && {getTabIcon(tab.id, minapps)} }
+ {getTabTitle(tab.id)}
+
+ {tab.id !== 'home' && (
+ {
+ e.stopPropagation()
+ closeTab(tab.id)
+ }}>
+
+
+ )}
+
+ )}
+ />
-
+
= ({ children }) => {
-
+
- {children}
+
+ {/* MiniApp WebView 池(Tab 模式保活) */}
+
+ {children}
+
)
}
@@ -290,9 +260,9 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
align-items: center;
gap: 5px;
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'env(titlebar-area-x)' : '15px')};
- padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
+ padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : '0')};
height: var(--navbar-height);
- min-height: env(titlebar-area-height);
+ min-height: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'env(titlebar-area-height)' : '')};
position: relative;
-webkit-app-region: drag;
@@ -302,36 +272,16 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
z-index: 1;
-webkit-app-region: no-drag;
}
-`
-const TabsArea = styled.div`
- display: flex;
- align-items: center;
- flex: 1 1 auto;
- min-width: 0;
- gap: 6px;
- padding-right: 2rem;
- position: relative;
+ .tab-scroll-container {
+ -webkit-app-region: drag;
- -webkit-app-region: drag;
-
- > * {
- -webkit-app-region: no-drag;
- }
-
- &:hover {
- .scroll-right-button {
- opacity: 1;
+ > * {
+ -webkit-app-region: no-drag;
}
}
`
-const TabsScroll = styled(Scrollbar)`
- &::-webkit-scrollbar {
- display: none;
- }
-`
-
const Tab = styled.div<{ active?: boolean }>`
display: flex;
align-items: center;
@@ -409,22 +359,6 @@ const AddTabButton = styled.div`
}
`
-const ScrollButton = styled(Button)`
- position: absolute;
- right: 4rem;
- top: 50%;
- transform: translateY(-50%);
- z-index: 1;
- opacity: 0;
- transition: opacity 0.2s ease-in-out;
-
- border: none;
- box-shadow:
- 0 6px 16px 0 rgba(0, 0, 0, 0.08),
- 0 3px 6px -4px rgba(0, 0, 0, 0.12),
- 0 9px 28px 8px rgba(0, 0, 0, 0.05);
-`
-
const RightButtonsContainer = styled.div`
display: flex;
align-items: center;
@@ -473,6 +407,7 @@ const TabContent = styled.div`
margin-top: 0;
border-radius: 8px;
overflow: hidden;
+ position: relative; /* 约束 MinAppTabsPool 绝对定位范围 */
`
export default TabsContainer
diff --git a/src/renderer/src/components/ToastPortal.tsx b/src/renderer/src/components/ToastPortal.tsx
new file mode 100644
index 0000000000..b149ae6fdf
--- /dev/null
+++ b/src/renderer/src/components/ToastPortal.tsx
@@ -0,0 +1,32 @@
+import { ToastProvider } from '@heroui/toast'
+import { useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
+
+export const ToastPortal = () => {
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ return () => setMounted(false)
+ }, [])
+
+ if (!mounted) return null
+
+ return createPortal(
+ ,
+ document.body
+ )
+}
diff --git a/src/renderer/src/components/TopView/index.tsx b/src/renderer/src/components/TopView/index.tsx
index 8c2cb4a3bb..d37f9d5077 100644
--- a/src/renderer/src/components/TopView/index.tsx
+++ b/src/renderer/src/components/TopView/index.tsx
@@ -2,10 +2,11 @@
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit'
import { useShortcuts } from '@renderer/hooks/useShortcuts'
-import { message, Modal } from 'antd'
+import { Modal } from 'antd'
import React, { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'
import { Box } from '../Layout'
+import { getToastUtilities } from './toast'
let onPop = () => {}
let onShow = ({ element, id }: { element: React.FC | React.ReactNode; id: string }) => {
@@ -33,7 +34,6 @@ const TopViewContainer: React.FC = ({ children }) => {
const elementsRef = useRef([])
elementsRef.current = elements
- const [messageApi, messageContextHolder] = message.useMessage()
const [modal, modalContextHolder] = Modal.useModal()
const { shortcuts } = useShortcuts()
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
@@ -41,9 +41,9 @@ const TopViewContainer: React.FC = ({ children }) => {
useAppInit()
useEffect(() => {
- window.message = messageApi
window.modal = modal
- }, [messageApi, modal])
+ window.toast = getToastUtilities()
+ }, [modal])
onPop = () => {
const views = [...elementsRef.current]
@@ -96,7 +96,6 @@ const TopViewContainer: React.FC = ({ children }) => {
return (
<>
{children}
- {messageContextHolder}
{modalContextHolder}
{elements.map(({ element: Element, id }) => (
diff --git a/src/renderer/src/components/TopView/toast.ts b/src/renderer/src/components/TopView/toast.ts
new file mode 100644
index 0000000000..b5108315fa
--- /dev/null
+++ b/src/renderer/src/components/TopView/toast.ts
@@ -0,0 +1,72 @@
+import { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast'
+import { RequireSome } from '@renderer/types'
+
+type AddToastProps = Parameters[0]
+type ToastPropsColored = Omit
+
+const createToast = (color: 'danger' | 'success' | 'warning' | 'default') => {
+ return (arg: ToastPropsColored | string): string | null => {
+ if (typeof arg === 'string') {
+ return addToast({ color, title: arg })
+ } else {
+ return addToast({ color, ...arg })
+ }
+ }
+}
+
+// syntatic sugar, oh yeah
+
+/**
+ * Display an error toast notification with red color
+ * @param arg - Toast content (string) or toast options object
+ * @returns Toast ID or null
+ */
+export const error = createToast('danger')
+
+/**
+ * Display a success toast notification with green color
+ * @param arg - Toast content (string) or toast options object
+ * @returns Toast ID or null
+ */
+export const success = createToast('success')
+
+/**
+ * Display a warning toast notification with yellow color
+ * @param arg - Toast content (string) or toast options object
+ * @returns Toast ID or null
+ */
+export const warning = createToast('warning')
+
+/**
+ * Display an info toast notification with default color
+ * @param arg - Toast content (string) or toast options object
+ * @returns Toast ID or null
+ */
+export const info = createToast('default')
+
+/**
+ * Display a loading toast notification that resolves with a promise
+ * @param args - Toast options object containing a promise to resolve
+ * @returns Toast ID or null
+ */
+export const loading = (args: RequireSome) => {
+ // Disappear immediately by default
+ if (args.timeout === undefined) {
+ args.timeout = 1
+ }
+ return addToast(args)
+}
+
+export const getToastUtilities = () =>
+ ({
+ getToastQueue,
+ addToast,
+ closeToast,
+ closeAll,
+ isToastClosing,
+ error,
+ success,
+ warning,
+ info,
+ loading
+ }) as const
diff --git a/src/renderer/src/components/TranslateButton.tsx b/src/renderer/src/components/TranslateButton.tsx
index 9aec798659..104c153240 100644
--- a/src/renderer/src/components/TranslateButton.tsx
+++ b/src/renderer/src/components/TranslateButton.tsx
@@ -53,10 +53,7 @@ const TranslateButton: FC = ({ text, onTranslated, disabled, style, isLoa
onTranslated(translatedText)
} catch (error) {
logger.error('Translation failed:', error as Error)
- window.message.error({
- content: t('translate.error.failed'),
- key: 'translate-message'
- })
+ window.toast.error(t('translate.error.failed'))
} finally {
setIsTranslating(false)
}
diff --git a/src/renderer/src/components/WebdavBackupManager.tsx b/src/renderer/src/components/WebdavBackupManager.tsx
index f04cbef04e..6683cacfe0 100644
--- a/src/renderer/src/components/WebdavBackupManager.tsx
+++ b/src/renderer/src/components/WebdavBackupManager.tsx
@@ -60,7 +60,7 @@ export function WebdavBackupManager({
const fetchBackupFiles = useCallback(async () => {
if (!webdavHost) {
- window.message.error(t('message.error.invalid.webdav'))
+ window.toast.error(t('message.error.invalid.webdav'))
return
}
@@ -78,7 +78,7 @@ export function WebdavBackupManager({
total: files.length
}))
} catch (error: any) {
- window.message.error(`${t('settings.data.webdav.backup.manager.fetch.error')}: ${error.message}`)
+ window.toast.error(`${t('settings.data.webdav.backup.manager.fetch.error')}: ${error.message}`)
} finally {
setLoading(false)
}
@@ -106,7 +106,7 @@ export function WebdavBackupManager({
}
if (!webdavHost) {
- window.message.error(t('message.error.invalid.webdav'))
+ window.toast.error(t('message.error.invalid.webdav'))
return
}
@@ -129,13 +129,13 @@ export function WebdavBackupManager({
webdavPath
} as WebdavConfig)
}
- window.message.success(
+ window.toast.success(
t('settings.data.webdav.backup.manager.delete.success.multiple', { count: selectedRowKeys.length })
)
setSelectedRowKeys([])
await fetchBackupFiles()
} catch (error: any) {
- window.message.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
+ window.toast.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
} finally {
setDeleting(false)
}
@@ -145,7 +145,7 @@ export function WebdavBackupManager({
const handleDeleteSingle = async (fileName: string) => {
if (!webdavHost) {
- window.message.error(t('message.error.invalid.webdav'))
+ window.toast.error(t('message.error.invalid.webdav'))
return
}
@@ -165,10 +165,10 @@ export function WebdavBackupManager({
webdavPass,
webdavPath
} as WebdavConfig)
- window.message.success(t('settings.data.webdav.backup.manager.delete.success.single'))
+ window.toast.success(t('settings.data.webdav.backup.manager.delete.success.single'))
await fetchBackupFiles()
} catch (error: any) {
- window.message.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
+ window.toast.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
} finally {
setDeleting(false)
}
@@ -178,7 +178,7 @@ export function WebdavBackupManager({
const handleRestore = async (fileName: string) => {
if (!webdavHost) {
- window.message.error(customLabels?.invalidConfigMessage || t('message.error.invalid.webdav'))
+ window.toast.error(customLabels?.invalidConfigMessage || t('message.error.invalid.webdav'))
return
}
@@ -193,10 +193,10 @@ export function WebdavBackupManager({
setRestoring(true)
try {
await (restoreMethod || restoreFromWebdav)(fileName)
- window.message.success(t('settings.data.webdav.backup.manager.restore.success'))
+ window.toast.success(t('settings.data.webdav.backup.manager.restore.success'))
onClose() // 关闭模态框
} catch (error: any) {
- window.message.error(`${t('settings.data.webdav.backup.manager.restore.error')}: ${error.message}`)
+ window.toast.error(`${t('settings.data.webdav.backup.manager.restore.error')}: ${error.message}`)
} finally {
setRestoring(false)
}
diff --git a/src/renderer/src/components/WindowControls/WindowControls.styled.ts b/src/renderer/src/components/WindowControls/WindowControls.styled.ts
index 61d208d925..c962e139e5 100644
--- a/src/renderer/src/components/WindowControls/WindowControls.styled.ts
+++ b/src/renderer/src/components/WindowControls/WindowControls.styled.ts
@@ -1,11 +1,15 @@
import styled from 'styled-components'
export const WindowControlsContainer = styled.div`
+ position: fixed;
+ top: 0;
+ right: 0;
display: flex;
align-items: center;
- height: 100%;
+ height: var(--navbar-height);
-webkit-app-region: no-drag;
user-select: none;
+ z-index: 9999;
`
export const ControlButton = styled.button<{ $isClose?: boolean }>`
diff --git a/src/renderer/src/components/WindowControls/index.tsx b/src/renderer/src/components/WindowControls/index.tsx
index 5b0b8c699b..c31476a881 100644
--- a/src/renderer/src/components/WindowControls/index.tsx
+++ b/src/renderer/src/components/WindowControls/index.tsx
@@ -2,17 +2,45 @@ import { isLinux, isWin } from '@renderer/config/constant'
import { Tooltip } from 'antd'
import { Minus, Square, X } from 'lucide-react'
import { useEffect, useState } from 'react'
+import { SVGProps } from 'react'
import { useTranslation } from 'react-i18next'
import { ControlButton, WindowControlsContainer } from './WindowControls.styled'
-// Custom restore icon - two overlapping squares like Windows
-const RestoreIcon: React.FC<{ size?: number }> = ({ size = 14 }) => (
-
- {/* Back square (top-right) */}
-
- {/* Front square (bottom-left) */}
-
+interface WindowRestoreIconProps extends SVGProps {
+ size?: string | number
+}
+
+export const WindowRestoreIcon = ({ size = '1.1em', ...props }: WindowRestoreIconProps) => (
+
+
+
+
)
@@ -67,7 +95,7 @@ const WindowControls: React.FC = () => {
placement="bottom"
mouseEnterDelay={DEFAULT_DELAY}>
- {isMaximized ? : }
+ {isMaximized ? : }
diff --git a/src/renderer/src/components/__tests__/CopyButton.test.tsx b/src/renderer/src/components/__tests__/CopyButton.test.tsx
index dabe863d32..1087ee74fe 100644
--- a/src/renderer/src/components/__tests__/CopyButton.test.tsx
+++ b/src/renderer/src/components/__tests__/CopyButton.test.tsx
@@ -10,8 +10,8 @@ const mockClipboard = {
writeText: mockWriteText
}
-// Mock window.message
-const mockMessage = {
+// Mock window.toast
+const mockedToast = {
success: vi.fn(),
error: vi.fn()
}
@@ -33,7 +33,7 @@ describe('CopyButton', () => {
beforeEach(() => {
// Setup mocks
Object.assign(navigator, { clipboard: mockClipboard })
- Object.assign(window, { message: mockMessage })
+ Object.assign(window, { toast: mockedToast })
// Clear all mocks
vi.clearAllMocks()
@@ -103,8 +103,8 @@ describe('CopyButton', () => {
const clickableElement = copyIcon?.parentElement
await userEvent.click(clickableElement!)
- expect(mockMessage.success).toHaveBeenCalledWith('复制成功')
- expect(mockMessage.error).not.toHaveBeenCalled()
+ expect(mockedToast.success).toHaveBeenCalledWith('复制成功')
+ expect(mockedToast.error).not.toHaveBeenCalled()
})
it('should show error message when copy fails', async () => {
@@ -116,8 +116,8 @@ describe('CopyButton', () => {
const clickableElement = copyIcon?.parentElement
await userEvent.click(clickableElement!)
- expect(mockMessage.error).toHaveBeenCalledWith('复制失败')
- expect(mockMessage.success).not.toHaveBeenCalled()
+ expect(mockedToast.error).toHaveBeenCalledWith('复制失败')
+ expect(mockedToast.success).not.toHaveBeenCalled()
})
it('should apply custom size to icon and label', () => {
diff --git a/src/renderer/src/components/__tests__/InputEmbeddingDimension.test.tsx b/src/renderer/src/components/__tests__/InputEmbeddingDimension.test.tsx
index babc67e6ef..946b08780b 100644
--- a/src/renderer/src/components/__tests__/InputEmbeddingDimension.test.tsx
+++ b/src/renderer/src/components/__tests__/InputEmbeddingDimension.test.tsx
@@ -98,9 +98,9 @@ vi.mock('@renderer/components/Icons', () => ({
)
}))
-// Mock window.message
+// Mock window.toast
Object.assign(window, {
- message: {
+ toast: {
error: vi.fn(),
success: vi.fn()
}
@@ -195,7 +195,7 @@ describe('InputEmbeddingDimension', () => {
// We can skip this check to be explicit.
await userEvent.click(refreshButton, { pointerEventsCheck: 0 })
- expect(window.message.error).not.toHaveBeenCalled()
+ expect(window.toast.error).not.toHaveBeenCalled()
})
it('should show error when API call fails', async () => {
@@ -208,7 +208,7 @@ describe('InputEmbeddingDimension', () => {
await user.click(refreshButton)
await waitFor(() => {
- expect(window.message.error).toHaveBeenCalledWith('获取嵌入维度失败\nAPI Error')
+ expect(window.toast.error).toHaveBeenCalledWith('获取嵌入维度失败\nAPI Error')
})
})
diff --git a/src/renderer/src/components/app/Navbar.tsx b/src/renderer/src/components/app/Navbar.tsx
index 70122355f3..8f88c8d0c1 100644
--- a/src/renderer/src/components/app/Navbar.tsx
+++ b/src/renderer/src/components/app/Navbar.tsx
@@ -2,6 +2,7 @@ import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
+import { useRuntime } from '@renderer/hooks/useRuntime'
import type { FC, PropsWithChildren } from 'react'
import type { HTMLAttributes } from 'react'
import styled from 'styled-components'
@@ -12,16 +13,21 @@ type Props = PropsWithChildren & HTMLAttributes
export const Navbar: FC = ({ children, ...props }) => {
const backgroundColor = useNavBackgroundColor()
+ const isFullscreen = useFullscreen()
const { isTopNavbar } = useNavbarPosition()
+ const { minappShow } = useRuntime()
if (isTopNavbar) {
return null
}
return (
-