Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be94216f4d | |||
| 38ac42af8c | |||
| d11a2cd95c | |||
| 23f61b0d62 | |||
| 961ee22327 | |||
| c7d2588f1a | |||
| 06ab2822be | |||
| bb0ec0a3ec | |||
| 483b4e090e | |||
| 4975c2d9e8 | |||
| 5365fddec9 | |||
| e401685449 | |||
| e195ad4a8f | |||
| 20f5271682 | |||
| 5524571c80 | |||
| cd3031479c | |||
| 1df6e8c732 | |||
| ed2e01491e | |||
| 228ed474ce | |||
| 6829a03437 | |||
| dabfb8dc0e | |||
| 4aa9c9f225 | |||
| 52a980f751 | |||
| 3b7ab2aec8 | |||
| d41e239b89 | |||
| b85040f579 | |||
| 8bcd229849 | |||
| d12515ccb9 | |||
| 499cb52e28 | |||
| 05a318225c | |||
| caad0bc005 | |||
| 067ecb5e8e | |||
| 0f8cbeed11 | |||
| 2ed99c0cb8 | |||
| 0a149e3d9e | |||
| a3a26c69c5 | |||
| 2bafc53b25 | |||
| 09e9b95e08 | |||
| bf2ffb7465 | |||
| 287c96ea2e | |||
| adacb8c638 | |||
| 7a3d08672a | |||
| ec4d106a59 | |||
| fe0c0fac1e | |||
| 4a4a1686d3 | |||
| 37218eef4f | |||
| 3b34efd33a | |||
| cc650b58d3 | |||
| 183b46be9e | |||
| a847b74c32 | |||
| 25c5d671dc | |||
| 87d9c7b410 | |||
| 67a6a6a445 | |||
| 4f8507036a | |||
| acf2f4758f | |||
| abf368e558 | |||
| 0697c79daa | |||
| 3d6a82fb00 | |||
| 97e9e42173 | |||
| 5d8e706c0b | |||
| a8cd2e2eac | |||
| 1e615d69e1 | |||
| 63be1d8cf2 | |||
| f039aa253d | |||
| 5a7521e335 | |||
| 5dac1f5867 | |||
| 1d0fc26025 | |||
| ca597b9b9b | |||
| c76df7fb16 | |||
| 89d5bd817b | |||
| 6c9fc598d4 | |||
| 273475881e | |||
| 6afaf6244c | |||
| 77535b002a | |||
| dec68ee297 | |||
| a8bf55abc2 | |||
| 1481149e51 | |||
| 4f91a321a0 |
@@ -2,8 +2,8 @@ name: Auto I18N
|
||||
|
||||
env:
|
||||
API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
|
||||
MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}}
|
||||
BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}}
|
||||
MODEL: ${{ vars.AUTO_I18N_MODEL || 'deepseek/deepseek-v3.1'}}
|
||||
BASE_URL: ${{ vars.AUTO_I18N_BASE_URL || 'https://api.ppinfra.com/openai'}}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Claude Translator
|
||||
concurrency:
|
||||
group: translator-${{ github.event.comment.id || github.event.issue.number }}
|
||||
group: translator-${{ github.event.comment.id || github.event.issue.number || github.event.review.id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
@@ -8,14 +8,18 @@ on:
|
||||
types: [opened]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
pull_request_review:
|
||||
types: [submitted, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
if: |
|
||||
(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'))
|
||||
(github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') ||
|
||||
(github.event_name == 'pull_request_review' && github.event.sender.type != 'Bot') ||
|
||||
(github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -37,23 +41,44 @@ jobs:
|
||||
# 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: '*'
|
||||
allowed_non_write_users: "*"
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
claude_args: '--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*)'
|
||||
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)"
|
||||
prompt: |
|
||||
你是一个多语言翻译助手。请完成以下任务:
|
||||
你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件:
|
||||
|
||||
- issues
|
||||
- issue_comment
|
||||
- pull_request_review
|
||||
- pull_request_review_comment
|
||||
|
||||
请完成以下任务:
|
||||
|
||||
1. 获取当前事件的完整信息。
|
||||
|
||||
- 如果当前事件是 issues,就获取该 issues 的信息。
|
||||
- 如果当前事件是 issue_comment,就获取该 comment 的信息。
|
||||
- 如果当前事件是 pull_request_review,就获取该 review 的信息。
|
||||
- 如果当前事件是 pull_request_review_comment,就获取该 comment 的信息。
|
||||
|
||||
1. 获取当前issue/comment的完整信息
|
||||
2. 智能检测内容。
|
||||
1. 如果是已经遵循格式要求翻译过的issue/comment,检查翻译内容和原始内容是否匹配。若不匹配,则重新翻译一次令其匹配,并遵循格式要求;若匹配,则跳过任务。
|
||||
2. 如果是未翻译过的issue/comment,检查其内容语言。若不是英文,则翻译成英文;若已经是英文,则跳过任务。
|
||||
|
||||
- 如果获取到的信息是已经遵循格式要求翻译过的内容,则检查翻译内容和原始内容是否匹配。若不匹配,则重新翻译一次令其匹配,并遵循格式要求;
|
||||
- 如果获取到的信息是未翻译过的内容,检查其内容语言。若不是英文,则翻译成英文;
|
||||
- 如果获取到的信息是部分翻译为英文的内容,则将其翻译为英文;
|
||||
- 如果获取到的信息包含了对已翻译内容的引用,则将引用内容清理为仅含英文的内容。引用的内容不能够包含"This xxx was translated by Claude"和"Original Content`等内容。
|
||||
- 如果获取到的信息包含了其他类型的引用,即对非 Claude 翻译的内容的引用,则直接照原样引用,不进行翻译。
|
||||
- 如果获取到的信息是通过邮件回复的内容,则在翻译时应当将邮件内容的引用放到最后。在原始内容和翻译内容中只需要回复的内容本身,不要包含对邮件内容的引用。
|
||||
- 如果获取到的信息本身不需要任何处理,则跳过任务。
|
||||
|
||||
3. 格式要求:
|
||||
|
||||
- 标题:英文翻译(如果非英文)
|
||||
- 内容格式:
|
||||
> [!NOTE]
|
||||
> This issue/comment was translated by Claude.
|
||||
> This issue/comment/review was translated by Claude.
|
||||
|
||||
[英文翻译内容]
|
||||
[翻译内容]
|
||||
|
||||
---
|
||||
<details>
|
||||
@@ -62,15 +87,21 @@ jobs:
|
||||
</details>
|
||||
|
||||
4. 使用gh工具更新:
|
||||
|
||||
- 根据环境信息中的Event类型选择正确的命令:
|
||||
- 如果Event是'issues':gh issue edit [ISSUE_NUMBER] --title "[英文标题]" --body "[翻译内容 + 原始内容]"
|
||||
- 如果Event是'issue_comment':gh api -X PATCH /repos/[REPO]/issues/comments/[COMMENT_ID] -f body="[翻译内容 + 原始内容]"
|
||||
- 如果 Event 是 'issues': gh issue edit [ISSUE_NUMBER] --title "[英文标题]" --body "[翻译内容 + 原始内容]"
|
||||
- 如果 Event 是 'issue_comment': gh api -X PATCH /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }} -f body="[翻译内容 + 原始内容]"
|
||||
- 如果 Event 是 'pull_request_review': gh api -X PUT /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews/${{ github.event.review.id }} -f body="[翻译内容]"
|
||||
- 如果 Event 是 'pull_request_review_comment': gh api -X PATCH /repos/${{ github.repository }}/pulls/comments/${{ github.event.comment.id }} -f body="[翻译内容 + 原始内容]"
|
||||
|
||||
环境信息:
|
||||
- Event: ${{ github.event_name }}
|
||||
- Issue Number: ${{ github.event.issue.number }}
|
||||
- Repository: ${{ github.repository }}
|
||||
- Comment ID: ${{ github.event.comment.id || 'N/A' }} (only available for comment events)
|
||||
- (Review) Comment ID: ${{ github.event.comment.id || 'N/A' }}
|
||||
- Pull Request Number: ${{ github.event.pull_request.number || 'N/A' }}
|
||||
- Review ID: ${{ github.event.review.id || 'N/A' }}
|
||||
|
||||
|
||||
使用以下命令获取完整信息:
|
||||
gh issue view ${{ github.event.issue.number }} --json title,body,comments
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Delete merged branch
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
delete-branch:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
|
||||
steps:
|
||||
- name: Delete merged branch
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `heads/${context.payload.pull_request.head.ref}`,
|
||||
})
|
||||
@@ -98,10 +98,10 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@@ -110,15 +110,15 @@ jobs:
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -127,10 +127,10 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Rename artifacts with nightly format
|
||||
shell: bash
|
||||
|
||||
@@ -10,12 +10,14 @@ on:
|
||||
- main
|
||||
- develop
|
||||
- v2
|
||||
types: [ready_for_review, synchronize, opened]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PRCI: true
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
|
||||
@@ -85,10 +85,10 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@@ -98,15 +98,15 @@ jobs:
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -115,10 +115,10 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Release
|
||||
uses: ncipollo/release-action@v1
|
||||
|
||||
@@ -54,6 +54,8 @@ local
|
||||
.qwen/*
|
||||
.trae/*
|
||||
.claude-code-router/*
|
||||
.codebuddy/*
|
||||
.zed/*
|
||||
CLAUDE.local.md
|
||||
|
||||
# vitest
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
".gitignore",
|
||||
"scripts/cloudflare-worker.js",
|
||||
"src/main/integration/nutstore/sso/lib/**",
|
||||
"src/main/integration/cherryin/index.js",
|
||||
"src/main/integration/cherryai/index.js",
|
||||
"src/main/integration/nutstore/sso/lib/**",
|
||||
"src/renderer/src/ui/**",
|
||||
"packages/**/dist",
|
||||
|
||||
Vendored
+1
-2
@@ -6,7 +6,6 @@
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"vitest.explorer",
|
||||
"oxc.oxc-vscode",
|
||||
"biomejs.biome",
|
||||
"typescriptteam.native-preview"
|
||||
"biomejs.biome"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+1
-2
@@ -47,6 +47,5 @@
|
||||
"search.exclude": {
|
||||
"**/dist/**": true,
|
||||
".yarn/releases/**": true
|
||||
},
|
||||
"typescript.experimental.useTsgo": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
|
||||
// src/get-model-path.ts
|
||||
function getModelPath(modelId) {
|
||||
- return modelId.includes("/") ? modelId : `models/${modelId}`;
|
||||
+ return modelId?.includes("models/") ? modelId : `models/${modelId}`;
|
||||
}
|
||||
|
||||
// src/google-generative-ai-options.ts
|
||||
+4
-17
@@ -125,20 +125,7 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
✨ 新功能:
|
||||
- 集成 Perplexity SDK 和 Anthropic OAuth
|
||||
- 支持 API 服务器模式,提供外部调用接口
|
||||
- 新增字体自定义设置功能
|
||||
- 笔记支持文件夹批量上传
|
||||
- 集成 HeroUI 和 Tailwind CSS 提升界面体验
|
||||
|
||||
🚀 性能优化:
|
||||
- 优化大文件上传,支持 OpenAI 标准文件服务
|
||||
- 重构 MCP 服务,改进错误处理和状态管理
|
||||
|
||||
🐛 问题修复:
|
||||
- 修复 WebSearch RAG 并发问题
|
||||
- 修复翻译页面长文本渲染布局问题
|
||||
- 修复笔记拖拽排序和无限循环问题
|
||||
- 修复 macOS CodeTool 工作目录错误
|
||||
- 修复多个 UI 组件的响应式设计问题
|
||||
Optimized note-taking feature, now able to quickly rename by modifying the title
|
||||
Fixed issue where CherryAI free model could not be used
|
||||
Fixed issue where VertexAI proxy address could not be called normally
|
||||
Fixed issue where built-in tools from service providers could not be called normally
|
||||
|
||||
@@ -34,6 +34,10 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
||||
},
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
||||
warn(warning)
|
||||
}
|
||||
},
|
||||
sourcemap: isDev
|
||||
@@ -111,6 +115,10 @@ export default defineConfig({
|
||||
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
||||
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
|
||||
},
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
||||
warn(warning)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+21
-21
@@ -48,6 +48,27 @@ export default defineConfig([
|
||||
'@eslint-react/no-children-to-array': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'build/**',
|
||||
'dist/**',
|
||||
'out/**',
|
||||
'local/**',
|
||||
'.yarn/**',
|
||||
'.gitignore',
|
||||
'scripts/cloudflare-worker.js',
|
||||
'src/main/integration/nutstore/sso/lib/**',
|
||||
'src/main/integration/cherryai/index.js',
|
||||
'src/main/integration/nutstore/sso/lib/**',
|
||||
'src/renderer/src/ui/**',
|
||||
'packages/**/dist'
|
||||
]
|
||||
},
|
||||
// turn off oxlint supported rules.
|
||||
...oxlint.configs['flat/eslint'],
|
||||
...oxlint.configs['flat/typescript'],
|
||||
...oxlint.configs['flat/unicorn'],
|
||||
{
|
||||
// LoggerService Custom Rules - only apply to src directory
|
||||
files: ['src/**/*.{ts,tsx,js,jsx}'],
|
||||
@@ -110,25 +131,4 @@ export default defineConfig([
|
||||
'i18n/no-template-in-t': 'warn'
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'build/**',
|
||||
'dist/**',
|
||||
'out/**',
|
||||
'local/**',
|
||||
'.yarn/**',
|
||||
'.gitignore',
|
||||
'scripts/cloudflare-worker.js',
|
||||
'src/main/integration/nutstore/sso/lib/**',
|
||||
'src/main/integration/cherryin/index.js',
|
||||
'src/main/integration/nutstore/sso/lib/**',
|
||||
'src/renderer/src/ui/**',
|
||||
'packages/**/dist'
|
||||
]
|
||||
},
|
||||
// turn off oxlint supported rules.
|
||||
...oxlint.configs['flat/eslint'],
|
||||
...oxlint.configs['flat/typescript'],
|
||||
...oxlint.configs['flat/unicorn']
|
||||
])
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.6.0-rc.1",
|
||||
"version": "1.6.2",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -97,10 +97,10 @@
|
||||
"@agentic/exa": "^7.3.3",
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.21",
|
||||
"@ai-sdk/google-vertex": "^3.0.27",
|
||||
"@ai-sdk/mistral": "^2.0.14",
|
||||
"@ai-sdk/perplexity": "^2.0.9",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.29",
|
||||
"@ai-sdk/google-vertex": "^3.0.33",
|
||||
"@ai-sdk/mistral": "^2.0.17",
|
||||
"@ai-sdk/perplexity": "^2.0.11",
|
||||
"@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",
|
||||
@@ -108,7 +108,7 @@
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.840.0",
|
||||
"@aws-sdk/client-s3": "^3.840.0",
|
||||
"@biomejs/biome": "2.2.4",
|
||||
"@cherrystudio/ai-core": "workspace:^1.0.0-alpha.16",
|
||||
"@cherrystudio/ai-core": "workspace:^1.0.0-alpha.18",
|
||||
"@cherrystudio/embedjs": "^0.1.31",
|
||||
"@cherrystudio/embedjs-libsql": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
|
||||
@@ -215,7 +215,7 @@
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"ai": "^5.0.44",
|
||||
"ai": "^5.0.59",
|
||||
"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",
|
||||
@@ -237,7 +237,6 @@
|
||||
"diff": "^8.0.2",
|
||||
"docx": "^9.0.2",
|
||||
"dompurify": "^3.2.6",
|
||||
"dotenv": "^17.2.2",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "37.4.0",
|
||||
"electron-builder": "26.0.15",
|
||||
@@ -333,7 +332,7 @@
|
||||
"tsx": "^4.20.3",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript": "~5.8.2",
|
||||
"undici": "6.21.2",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^10.0.0",
|
||||
@@ -369,7 +368,8 @@
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cherrystudio/ai-core",
|
||||
"version": "1.0.0-alpha.16",
|
||||
"version": "1.0.1",
|
||||
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
@@ -36,15 +36,14 @@
|
||||
"ai": "^5.0.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.17",
|
||||
"@ai-sdk/azure": "^2.0.30",
|
||||
"@ai-sdk/deepseek": "^1.0.17",
|
||||
"@ai-sdk/google": "^2.0.14",
|
||||
"@ai-sdk/openai": "^2.0.30",
|
||||
"@ai-sdk/openai-compatible": "^1.0.17",
|
||||
"@ai-sdk/anthropic": "^2.0.22",
|
||||
"@ai-sdk/azure": "^2.0.42",
|
||||
"@ai-sdk/deepseek": "^1.0.20",
|
||||
"@ai-sdk/openai": "^2.0.42",
|
||||
"@ai-sdk/openai-compatible": "^1.0.19",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.9",
|
||||
"@ai-sdk/xai": "^2.0.18",
|
||||
"@ai-sdk/provider-utils": "^3.0.10",
|
||||
"@ai-sdk/xai": "^2.0.23",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -59,7 +59,7 @@ export function createGoogleOptions(options: ExtractProviderOptions<'google'>) {
|
||||
/**
|
||||
* 创建OpenRouter供应商选项的便捷函数
|
||||
*/
|
||||
export function createOpenRouterOptions(options: ExtractProviderOptions<'openrouter'>) {
|
||||
export function createOpenRouterOptions(options: ExtractProviderOptions<'openrouter'> | Record<string, any>) {
|
||||
return createProviderOptions('openrouter', options)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
export type OpenRouterProviderOptions = {
|
||||
models?: string[]
|
||||
|
||||
/**
|
||||
* https://openrouter.ai/docs/use-cases/reasoning-tokens
|
||||
* One of `max_tokens` or `effort` is required.
|
||||
* If `exclude` is true, reasoning will be removed from the response. Default is false.
|
||||
*/
|
||||
reasoning?: {
|
||||
exclude?: boolean
|
||||
} & (
|
||||
| {
|
||||
max_tokens: number
|
||||
}
|
||||
| {
|
||||
effort: 'high' | 'medium' | 'low'
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* A unique identifier representing your end-user, which can
|
||||
* help OpenRouter to monitor and detect abuse.
|
||||
*/
|
||||
user?: string
|
||||
|
||||
extraBody?: Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Enable usage accounting to get detailed token usage information.
|
||||
* https://openrouter.ai/docs/use-cases/usage-accounting
|
||||
*/
|
||||
usage?: {
|
||||
/**
|
||||
* When true, includes token usage information in the response.
|
||||
*/
|
||||
include: boolean
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,8 @@ import { type AnthropicProviderOptions } from '@ai-sdk/anthropic'
|
||||
import { type GoogleGenerativeAIProviderOptions } from '@ai-sdk/google'
|
||||
import { type OpenAIResponsesProviderOptions } from '@ai-sdk/openai'
|
||||
import { type SharedV2ProviderMetadata } from '@ai-sdk/provider'
|
||||
|
||||
import { type OpenRouterProviderOptions } from './openrouter'
|
||||
import { type XaiProviderOptions } from './xai'
|
||||
import { type XaiProviderOptions } from '@ai-sdk/xai'
|
||||
import { type OpenRouterProviderOptions } from '@openrouter/ai-sdk-provider'
|
||||
|
||||
export type ProviderOptions<T extends keyof SharedV2ProviderMetadata> = SharedV2ProviderMetadata[T]
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// copy from @ai-sdk/xai/xai-chat-options.ts
|
||||
// 如果@ai-sdk/xai暴露出了xaiProviderOptions就删除这个文件
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
const webSourceSchema = z.object({
|
||||
type: z.literal('web'),
|
||||
country: z.string().length(2).optional(),
|
||||
excludedWebsites: z.array(z.string()).max(5).optional(),
|
||||
allowedWebsites: z.array(z.string()).max(5).optional(),
|
||||
safeSearch: z.boolean().optional()
|
||||
})
|
||||
|
||||
const xSourceSchema = z.object({
|
||||
type: z.literal('x'),
|
||||
xHandles: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
const newsSourceSchema = z.object({
|
||||
type: z.literal('news'),
|
||||
country: z.string().length(2).optional(),
|
||||
excludedWebsites: z.array(z.string()).max(5).optional(),
|
||||
safeSearch: z.boolean().optional()
|
||||
})
|
||||
|
||||
const rssSourceSchema = z.object({
|
||||
type: z.literal('rss'),
|
||||
links: z.array(z.url()).max(1) // currently only supports one RSS link
|
||||
})
|
||||
|
||||
const searchSourceSchema = z.discriminatedUnion('type', [
|
||||
webSourceSchema,
|
||||
xSourceSchema,
|
||||
newsSourceSchema,
|
||||
rssSourceSchema
|
||||
])
|
||||
|
||||
export const xaiProviderOptions = z.object({
|
||||
/**
|
||||
* reasoning effort for reasoning models
|
||||
* only supported by grok-3-mini and grok-3-mini-fast models
|
||||
*/
|
||||
reasoningEffort: z.enum(['low', 'high']).optional(),
|
||||
|
||||
searchParameters: z
|
||||
.object({
|
||||
/**
|
||||
* search mode preference
|
||||
* - "off": disables search completely
|
||||
* - "auto": model decides whether to search (default)
|
||||
* - "on": always enables search
|
||||
*/
|
||||
mode: z.enum(['off', 'auto', 'on']),
|
||||
|
||||
/**
|
||||
* whether to return citations in the response
|
||||
* defaults to true
|
||||
*/
|
||||
returnCitations: z.boolean().optional(),
|
||||
|
||||
/**
|
||||
* start date for search data (ISO8601 format: YYYY-MM-DD)
|
||||
*/
|
||||
fromDate: z.string().optional(),
|
||||
|
||||
/**
|
||||
* end date for search data (ISO8601 format: YYYY-MM-DD)
|
||||
*/
|
||||
toDate: z.string().optional(),
|
||||
|
||||
/**
|
||||
* maximum number of search results to consider
|
||||
* defaults to 20
|
||||
*/
|
||||
maxSearchResults: z.number().min(1).max(50).optional(),
|
||||
|
||||
/**
|
||||
* data sources to search from
|
||||
* defaults to ["web", "x"] if not specified
|
||||
*/
|
||||
sources: z.array(searchSourceSchema).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export type XaiProviderOptions = z.infer<typeof xaiProviderOptions>
|
||||
@@ -7,5 +7,9 @@ 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'
|
||||
export { webSearchPlugin } from './webSearchPlugin'
|
||||
export type {
|
||||
PromptToolUseConfig,
|
||||
ToolUseRequestContext,
|
||||
ToolUseResult
|
||||
} from './toolUsePlugin/type'
|
||||
export { webSearchPlugin, type WebSearchPluginConfig } from './webSearchPlugin'
|
||||
|
||||
@@ -261,22 +261,39 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
||||
return params
|
||||
}
|
||||
|
||||
context.mcpTools = params.tools
|
||||
// 分离 provider-defined 和其他类型的工具
|
||||
const providerDefinedTools: ToolSet = {}
|
||||
const promptTools: ToolSet = {}
|
||||
|
||||
// 构建系统提示符
|
||||
for (const [toolName, tool] of Object.entries(params.tools as ToolSet)) {
|
||||
if (tool.type === 'provider-defined') {
|
||||
// provider-defined 类型的工具保留在 tools 参数中
|
||||
providerDefinedTools[toolName] = tool
|
||||
} else {
|
||||
// 其他工具转换为 prompt 模式
|
||||
promptTools[toolName] = tool
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当有非 provider-defined 工具时才保存到 context
|
||||
if (Object.keys(promptTools).length > 0) {
|
||||
context.mcpTools = promptTools
|
||||
}
|
||||
|
||||
// 构建系统提示符(只包含非 provider-defined 工具)
|
||||
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
|
||||
const systemPrompt = buildSystemPrompt(userSystemPrompt, params.tools)
|
||||
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools)
|
||||
let systemMessage: string | null = systemPrompt
|
||||
if (config.createSystemMessage) {
|
||||
// 🎯 如果用户提供了自定义处理函数,使用它
|
||||
systemMessage = config.createSystemMessage(systemPrompt, params, context)
|
||||
}
|
||||
|
||||
// 移除 tools,改为 prompt 模式
|
||||
// 保留 provider-defined tools,移除其他 tools
|
||||
const transformedParams = {
|
||||
...params,
|
||||
...(systemMessage ? { system: systemMessage } : {}),
|
||||
tools: undefined
|
||||
tools: Object.keys(providerDefinedTools).length > 0 ? providerDefinedTools : undefined
|
||||
}
|
||||
context.originalParams = transformedParams
|
||||
return transformedParams
|
||||
@@ -285,8 +302,9 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
||||
let textBuffer = ''
|
||||
// let stepId = ''
|
||||
|
||||
// 如果没有需要 prompt 模式处理的工具,直接返回原始流
|
||||
if (!context.mcpTools) {
|
||||
throw new Error('No tools available')
|
||||
return new TransformStream()
|
||||
}
|
||||
|
||||
// 从 context 中获取或初始化 usage 累加器
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
import { InferToolInput, InferToolOutput } from 'ai'
|
||||
|
||||
import { ProviderOptionsMap } from '../../../options/types'
|
||||
import { OpenRouterSearchConfig } from './openrouter'
|
||||
|
||||
/**
|
||||
* 从 AI SDK 的工具函数中提取参数类型,以确保类型安全。
|
||||
*/
|
||||
type OpenAISearchConfig = Parameters<typeof openai.tools.webSearchPreview>[0]
|
||||
type AnthropicSearchConfig = Parameters<typeof anthropic.tools.webSearch_20250305>[0]
|
||||
type GoogleSearchConfig = Parameters<typeof google.tools.googleSearch>[0]
|
||||
export type OpenAISearchConfig = NonNullable<Parameters<typeof openai.tools.webSearch>[0]>
|
||||
export type OpenAISearchPreviewConfig = NonNullable<Parameters<typeof openai.tools.webSearchPreview>[0]>
|
||||
export type AnthropicSearchConfig = NonNullable<Parameters<typeof anthropic.tools.webSearch_20250305>[0]>
|
||||
export type GoogleSearchConfig = NonNullable<Parameters<typeof google.tools.googleSearch>[0]>
|
||||
export type XAISearchConfig = NonNullable<ProviderOptionsMap['xai']['searchParameters']>
|
||||
|
||||
/**
|
||||
* 插件初始化时接收的完整配置对象
|
||||
@@ -18,10 +22,12 @@ type GoogleSearchConfig = Parameters<typeof google.tools.googleSearch>[0]
|
||||
*/
|
||||
export interface WebSearchPluginConfig {
|
||||
openai?: OpenAISearchConfig
|
||||
'openai-chat'?: OpenAISearchPreviewConfig
|
||||
anthropic?: AnthropicSearchConfig
|
||||
xai?: ProviderOptionsMap['xai']['searchParameters']
|
||||
google?: GoogleSearchConfig
|
||||
'google-vertex'?: GoogleSearchConfig
|
||||
openrouter?: OpenRouterSearchConfig
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,6 +37,7 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
|
||||
google: {},
|
||||
'google-vertex': {},
|
||||
openai: {},
|
||||
'openai-chat': {},
|
||||
xai: {
|
||||
mode: 'on',
|
||||
returnCitations: true,
|
||||
@@ -39,29 +46,44 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
|
||||
},
|
||||
anthropic: {
|
||||
maxUses: 5
|
||||
},
|
||||
openrouter: {
|
||||
plugins: [
|
||||
{
|
||||
id: 'web',
|
||||
max_results: 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export type WebSearchToolOutputSchema = {
|
||||
// Anthropic 工具 - 手动定义
|
||||
anthropicWebSearch: Array<{
|
||||
url: string
|
||||
title: string
|
||||
pageAge: string | null
|
||||
encryptedContent: string
|
||||
type: string
|
||||
}>
|
||||
anthropic: InferToolOutput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
|
||||
|
||||
// OpenAI 工具 - 基于实际输出
|
||||
openaiWebSearch: {
|
||||
// TODO: 上游定义不规范,是unknown
|
||||
// openai: InferToolOutput<ReturnType<typeof openai.tools.webSearch>>
|
||||
openai: {
|
||||
status: 'completed' | 'failed'
|
||||
}
|
||||
'openai-chat': {
|
||||
status: 'completed' | 'failed'
|
||||
}
|
||||
|
||||
// Google 工具
|
||||
googleSearch: {
|
||||
// TODO: 上游定义不规范,是unknown
|
||||
// google: InferToolOutput<ReturnType<typeof google.tools.googleSearch>>
|
||||
google: {
|
||||
webSearchQueries?: string[]
|
||||
groundingChunks?: Array<{
|
||||
web?: { uri: string; title: string }
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export type WebSearchToolInputSchema = {
|
||||
anthropic: InferToolInput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
|
||||
openai: InferToolInput<ReturnType<typeof openai.tools.webSearch>>
|
||||
google: InferToolInput<ReturnType<typeof google.tools.googleSearch>>
|
||||
'openai-chat': InferToolInput<ReturnType<typeof openai.tools.webSearchPreview>>
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
|
||||
import { createXaiOptions, mergeProviderOptions } from '../../../options'
|
||||
import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options'
|
||||
import { definePlugin } from '../../'
|
||||
import type { AiRequestContext } from '../../types'
|
||||
import { DEFAULT_WEB_SEARCH_CONFIG, WebSearchPluginConfig } from './helper'
|
||||
@@ -31,6 +31,13 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'openai-chat': {
|
||||
if (config['openai-chat']) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat'])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'anthropic': {
|
||||
if (config.anthropic) {
|
||||
@@ -56,6 +63,14 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'openrouter': {
|
||||
if (config.openrouter) {
|
||||
const searchOptions = createOpenRouterOptions(config.openrouter)
|
||||
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export type OpenRouterSearchConfig = {
|
||||
plugins?: Array<{
|
||||
id: 'web'
|
||||
/**
|
||||
* Maximum number of search results to include (default: 5)
|
||||
*/
|
||||
max_results?: number
|
||||
/**
|
||||
* Custom search prompt to guide the search query
|
||||
*/
|
||||
search_prompt?: string
|
||||
}>
|
||||
/**
|
||||
* Built-in web search options for models that support native web search
|
||||
*/
|
||||
web_search_options?: {
|
||||
/**
|
||||
* Maximum number of search results to include
|
||||
*/
|
||||
max_results?: number
|
||||
/**
|
||||
* Custom search prompt to guide the search query
|
||||
*/
|
||||
search_prompt?: string
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import { createDeepSeek } from '@ai-sdk/deepseek'
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google'
|
||||
import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
||||
import { LanguageModelV2 } from '@ai-sdk/provider'
|
||||
import { createXai } from '@ai-sdk/xai'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import { customProvider, Provider } from 'ai'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -25,7 +27,8 @@ export const baseProviderIds = [
|
||||
'xai',
|
||||
'azure',
|
||||
'azure-responses',
|
||||
'deepseek'
|
||||
'deepseek',
|
||||
'openrouter'
|
||||
] as const
|
||||
|
||||
/**
|
||||
@@ -38,10 +41,14 @@ export const baseProviderIdSchema = z.enum(baseProviderIds)
|
||||
*/
|
||||
export type BaseProviderId = z.infer<typeof baseProviderIdSchema>
|
||||
|
||||
export const isBaseProvider = (id: ProviderId): id is BaseProviderId => {
|
||||
return baseProviderIdSchema.safeParse(id).success
|
||||
}
|
||||
|
||||
type BaseProvider = {
|
||||
id: BaseProviderId
|
||||
name: string
|
||||
creator: (options: any) => Provider
|
||||
creator: (options: any) => Provider | LanguageModelV2
|
||||
supportsImageGeneration: boolean
|
||||
}
|
||||
|
||||
@@ -119,6 +126,12 @@ export const baseProviders = [
|
||||
name: 'DeepSeek',
|
||||
creator: createDeepSeek,
|
||||
supportsImageGeneration: false
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
name: 'OpenRouter',
|
||||
creator: createOpenRouter,
|
||||
supportsImageGeneration: true
|
||||
}
|
||||
] as const satisfies BaseProvider[]
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum IpcChannel {
|
||||
App_ShowUpdateDialog = 'app:show-update-dialog',
|
||||
App_CheckForUpdate = 'app:check-for-update',
|
||||
App_Reload = 'app:reload',
|
||||
App_Quit = 'app:quit',
|
||||
App_Info = 'app:info',
|
||||
App_Proxy = 'app:proxy',
|
||||
App_SetLaunchToTray = 'app:set-launch-to-tray',
|
||||
@@ -33,6 +34,7 @@ export enum IpcChannel {
|
||||
App_GetBinaryPath = 'app:get-binary-path',
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
App_InstallOvmsBinary = 'app:install-ovms-binary',
|
||||
App_LogToMain = 'app:log-to-main',
|
||||
App_SaveData = 'app:save-data',
|
||||
App_GetDiskInfo = 'app:get-disk-info',
|
||||
@@ -219,6 +221,7 @@ export enum IpcChannel {
|
||||
// system
|
||||
System_GetDeviceType = 'system:getDeviceType',
|
||||
System_GetHostname = 'system:getHostname',
|
||||
System_GetCpuName = 'system:getCpuName',
|
||||
|
||||
// DevTools
|
||||
System_ToggleDevTools = 'system:toggleDevTools',
|
||||
@@ -321,10 +324,23 @@ export enum IpcChannel {
|
||||
|
||||
// CodeTools
|
||||
CodeTools_Run = 'code-tools:run',
|
||||
CodeTools_GetAvailableTerminals = 'code-tools:get-available-terminals',
|
||||
CodeTools_SetCustomTerminalPath = 'code-tools:set-custom-terminal-path',
|
||||
CodeTools_GetCustomTerminalPath = 'code-tools:get-custom-terminal-path',
|
||||
CodeTools_RemoveCustomTerminalPath = 'code-tools:remove-custom-terminal-path',
|
||||
|
||||
// OCR
|
||||
OCR_ocr = 'ocr:ocr',
|
||||
|
||||
// Cherryin
|
||||
Cherryin_GetSignature = 'cherryin:get-signature'
|
||||
// OVMS
|
||||
Ovms_AddModel = 'ovms:add-model',
|
||||
Ovms_StopAddModel = 'ovms:stop-addmodel',
|
||||
Ovms_GetModels = 'ovms:get-models',
|
||||
Ovms_IsRunning = 'ovms:is-running',
|
||||
Ovms_GetStatus = 'ovms:get-status',
|
||||
Ovms_RunOVMS = 'ovms:run-ovms',
|
||||
Ovms_StopOVMS = 'ovms:stop-ovms',
|
||||
|
||||
// CherryAI
|
||||
Cherryai_GetSignature = 'cherryai:get-signature'
|
||||
}
|
||||
|
||||
@@ -217,5 +217,256 @@ export enum codeTools {
|
||||
claudeCode = 'claude-code',
|
||||
geminiCli = 'gemini-cli',
|
||||
openaiCodex = 'openai-codex',
|
||||
iFlowCli = 'iflow-cli'
|
||||
iFlowCli = 'iflow-cli',
|
||||
githubCopilotCli = 'github-copilot-cli'
|
||||
}
|
||||
|
||||
export enum terminalApps {
|
||||
systemDefault = 'Terminal',
|
||||
iterm2 = 'iTerm2',
|
||||
kitty = 'kitty',
|
||||
alacritty = 'Alacritty',
|
||||
wezterm = 'WezTerm',
|
||||
ghostty = 'Ghostty',
|
||||
tabby = 'Tabby',
|
||||
// Windows terminals
|
||||
windowsTerminal = 'WindowsTerminal',
|
||||
powershell = 'PowerShell',
|
||||
cmd = 'CMD',
|
||||
wsl = 'WSL'
|
||||
}
|
||||
|
||||
export interface TerminalConfig {
|
||||
id: string
|
||||
name: string
|
||||
bundleId?: string
|
||||
customPath?: string // For user-configured terminal paths on Windows
|
||||
}
|
||||
|
||||
export interface TerminalConfigWithCommand extends TerminalConfig {
|
||||
command: (directory: string, fullCommand: string) => { command: string; args: string[] }
|
||||
}
|
||||
|
||||
export const MACOS_TERMINALS: TerminalConfig[] = [
|
||||
{
|
||||
id: terminalApps.systemDefault,
|
||||
name: 'Terminal',
|
||||
bundleId: 'com.apple.Terminal'
|
||||
},
|
||||
{
|
||||
id: terminalApps.iterm2,
|
||||
name: 'iTerm2',
|
||||
bundleId: 'com.googlecode.iterm2'
|
||||
},
|
||||
{
|
||||
id: terminalApps.kitty,
|
||||
name: 'kitty',
|
||||
bundleId: 'net.kovidgoyal.kitty'
|
||||
},
|
||||
{
|
||||
id: terminalApps.alacritty,
|
||||
name: 'Alacritty',
|
||||
bundleId: 'org.alacritty'
|
||||
},
|
||||
{
|
||||
id: terminalApps.wezterm,
|
||||
name: 'WezTerm',
|
||||
bundleId: 'com.github.wez.wezterm'
|
||||
},
|
||||
{
|
||||
id: terminalApps.ghostty,
|
||||
name: 'Ghostty',
|
||||
bundleId: 'com.mitchellh.ghostty'
|
||||
},
|
||||
{
|
||||
id: terminalApps.tabby,
|
||||
name: 'Tabby',
|
||||
bundleId: 'org.tabby'
|
||||
}
|
||||
]
|
||||
|
||||
export const WINDOWS_TERMINALS: TerminalConfig[] = [
|
||||
{
|
||||
id: terminalApps.cmd,
|
||||
name: 'Command Prompt'
|
||||
},
|
||||
{
|
||||
id: terminalApps.powershell,
|
||||
name: 'PowerShell'
|
||||
},
|
||||
{
|
||||
id: terminalApps.windowsTerminal,
|
||||
name: 'Windows Terminal'
|
||||
},
|
||||
{
|
||||
id: terminalApps.wsl,
|
||||
name: 'WSL (Ubuntu/Debian)'
|
||||
},
|
||||
{
|
||||
id: terminalApps.alacritty,
|
||||
name: 'Alacritty'
|
||||
},
|
||||
{
|
||||
id: terminalApps.wezterm,
|
||||
name: 'WezTerm'
|
||||
}
|
||||
]
|
||||
|
||||
export const WINDOWS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
{
|
||||
id: terminalApps.cmd,
|
||||
name: 'Command Prompt',
|
||||
command: (_: string, fullCommand: string) => ({
|
||||
command: 'cmd',
|
||||
args: ['/c', 'start', 'cmd', '/k', fullCommand]
|
||||
})
|
||||
},
|
||||
{
|
||||
id: terminalApps.powershell,
|
||||
name: 'PowerShell',
|
||||
command: (_: string, fullCommand: string) => ({
|
||||
command: 'cmd',
|
||||
args: ['/c', 'start', 'powershell', '-NoExit', '-Command', `& '${fullCommand}'`]
|
||||
})
|
||||
},
|
||||
{
|
||||
id: terminalApps.windowsTerminal,
|
||||
name: 'Windows Terminal',
|
||||
command: (_: string, fullCommand: string) => ({
|
||||
command: 'wt',
|
||||
args: ['cmd', '/k', fullCommand]
|
||||
})
|
||||
},
|
||||
{
|
||||
id: terminalApps.wsl,
|
||||
name: 'WSL (Ubuntu/Debian)',
|
||||
command: (_: string, fullCommand: string) => {
|
||||
// Start WSL in a new window and execute the batch file from within WSL using cmd.exe
|
||||
// The batch file will run in Windows context but output will be in WSL terminal
|
||||
return {
|
||||
command: 'cmd',
|
||||
args: ['/c', 'start', 'wsl', '-e', 'bash', '-c', `cmd.exe /c '${fullCommand}' ; exec bash`]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: terminalApps.alacritty,
|
||||
name: 'Alacritty',
|
||||
customPath: '', // Will be set by user in settings
|
||||
command: (_: string, fullCommand: string) => ({
|
||||
command: 'alacritty', // Will be replaced with customPath if set
|
||||
args: ['-e', 'cmd', '/k', fullCommand]
|
||||
})
|
||||
},
|
||||
{
|
||||
id: terminalApps.wezterm,
|
||||
name: 'WezTerm',
|
||||
customPath: '', // Will be set by user in settings
|
||||
command: (_: string, fullCommand: string) => ({
|
||||
command: 'wezterm', // Will be replaced with customPath if set
|
||||
args: ['start', 'cmd', '/k', fullCommand]
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
// Helper function to escape strings for AppleScript
|
||||
const escapeForAppleScript = (str: string): string => {
|
||||
// In AppleScript strings, backslashes and double quotes need to be escaped
|
||||
// When passed through osascript -e with single quotes, we need:
|
||||
// 1. Backslash: \ -> \\
|
||||
// 2. Double quote: " -> \"
|
||||
return str
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||
.replace(/"/g, '\\"') // Then escape double quotes
|
||||
}
|
||||
|
||||
export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
{
|
||||
id: terminalApps.systemDefault,
|
||||
name: 'Terminal',
|
||||
bundleId: 'com.apple.Terminal',
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na Terminal && sleep 0.5 && osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapeForAppleScript(fullCommand)}" in front window'`
|
||||
]
|
||||
})
|
||||
},
|
||||
{
|
||||
id: terminalApps.iterm2,
|
||||
name: 'iTerm2',
|
||||
bundleId: 'com.googlecode.iterm2',
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na iTerm && sleep 0.8 && osascript -e 'on waitUntilRunning()\n repeat 50 times\n tell application "System Events"\n if (exists process "iTerm2") then exit repeat\n end tell\n delay 0.1\n end repeat\nend waitUntilRunning\n\nwaitUntilRunning()\n\ntell application "iTerm2"\n if (count of windows) = 0 then\n create window with default profile\n delay 0.3\n else\n tell current window\n create tab with default profile\n end tell\n delay 0.3\n end if\n tell current session of current window to write text "${escapeForAppleScript(fullCommand)}"\n activate\nend tell'`
|
||||
]
|
||||
})
|
||||
},
|
||||
{
|
||||
id: terminalApps.kitty,
|
||||
name: 'kitty',
|
||||
bundleId: 'net.kovidgoyal.kitty',
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`cd "${_directory}" && open -na kitty --args --directory="${_directory}" sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "kitty" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
{
|
||||
id: terminalApps.alacritty,
|
||||
name: 'Alacritty',
|
||||
bundleId: 'org.alacritty',
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na Alacritty --args --working-directory "${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Alacritty" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
{
|
||||
id: terminalApps.wezterm,
|
||||
name: 'WezTerm',
|
||||
bundleId: 'com.github.wez.wezterm',
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na WezTerm --args start --new-tab --cwd "${_directory}" -- sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "WezTerm" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
{
|
||||
id: terminalApps.ghostty,
|
||||
name: 'Ghostty',
|
||||
bundleId: 'com.mitchellh.ghostty',
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`cd "${_directory}" && open -na Ghostty --args --working-directory="${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Ghostty" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
{
|
||||
id: terminalApps.tabby,
|
||||
name: 'Tabby',
|
||||
bundleId: 'org.tabby',
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`if pgrep -x "Tabby" > /dev/null; then
|
||||
open -na Tabby --args open && sleep 0.3
|
||||
else
|
||||
open -na Tabby --args open && sleep 2
|
||||
fi && osascript -e 'tell application "Tabby" to activate' -e 'set the clipboard to "${escapeForAppleScript(fullCommand)}"' -e 'tell application "System Events" to tell process "Tabby" to keystroke "v" using {command down}' -e 'tell application "System Events" to key code 36'`
|
||||
]
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Privacy Policy</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
body.dark {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
body.dark h1 {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
body.dark h2 {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 12px 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
body.dark p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 12px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 6px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
body.dark li {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body.dark a {
|
||||
color: #4da6ff;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
body.dark .footer {
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
padding-right: 10px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Scrollbar styles - Light mode */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Scrollbar styles - Dark mode */
|
||||
body.dark ::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
body.dark ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
body.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Detect theme
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const theme = urlParams.get('theme');
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.body.classList.add('dark');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="content-wrapper">
|
||||
<h1>Privacy Policy</h1>
|
||||
|
||||
<p>
|
||||
Welcome to Cherry Studio (hereinafter referred to as "the Software" or "we"). We highly value your privacy
|
||||
protection. This Privacy Policy explains how we process and protect your personal information and data.
|
||||
Please read and understand this policy carefully before using the Software:
|
||||
</p>
|
||||
|
||||
<h2>1. Information We Collect</h2>
|
||||
<p>To optimize user experience and improve software quality, we may only collect the following anonymous,
|
||||
non-personal information:</p>
|
||||
<ul>
|
||||
<li>Software version information</li>
|
||||
<li>Activity and usage frequency of software features</li>
|
||||
<li>Anonymous crash and error log information</li>
|
||||
</ul>
|
||||
<p>The above information is completely anonymous, does not involve any personal identity data, and cannot be
|
||||
linked to your personal information.</p>
|
||||
|
||||
<h2>2. Information We Do Not Collect</h2>
|
||||
<p>To maximize the protection of your privacy and security, we explicitly commit that we:</p>
|
||||
<ul>
|
||||
<li>Will not collect, save, transmit, or process model service API Key information you enter into the
|
||||
Software</li>
|
||||
<li>Will not collect, save, transmit, or process any conversation data generated during your use of the
|
||||
Software, including but not limited to chat content, instruction information, knowledge base
|
||||
information, vector data, and other custom content</li>
|
||||
<li>Will not collect, save, transmit, or process any sensitive information that can identify personal
|
||||
identity</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Data Interaction Description</h2>
|
||||
<p>
|
||||
The Software uses API Keys from third-party model service providers that you apply for and configure
|
||||
yourself to complete model calls and conversation functions. The model services you use (such as large
|
||||
models, API interfaces, etc.) are directly provided by third-party providers of your choice. We do not
|
||||
intervene, monitor, or interfere with the data transmission process.
|
||||
</p>
|
||||
<p>
|
||||
Data interactions between you and third-party model services are governed by the privacy policies and user
|
||||
agreements of third-party service providers. We recommend that you fully understand the privacy terms of
|
||||
relevant service providers before use.
|
||||
</p>
|
||||
|
||||
<h2>4. Local Data Security Protection</h2>
|
||||
<p>The Software is a localized application, and all data is stored on your local device by default. We have
|
||||
taken the following measures to ensure data security:</p>
|
||||
<ul>
|
||||
<li>Conversation records, configuration information, and other data are only saved on your local device</li>
|
||||
<li>Data import/export functions are provided to facilitate your independent management and backup of data
|
||||
</li>
|
||||
<li>Your local data will not be uploaded to any server or cloud storage</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Third-Party Services</h2>
|
||||
<p>
|
||||
When using the Software, you may access third-party services (such as AI model APIs, translation services,
|
||||
etc.). The use of these third-party services is governed by their respective terms of service and privacy
|
||||
policies. We strongly recommend that you carefully read and understand the relevant terms before use.
|
||||
</p>
|
||||
|
||||
<h2>6. User Rights</h2>
|
||||
<p>You have complete control over your data:</p>
|
||||
<ul>
|
||||
<li>You can view, modify, and delete all locally stored data at any time</li>
|
||||
<li>You can choose whether to enable specific features or services</li>
|
||||
<li>You can stop using the Software and delete all related data at any time</li>
|
||||
</ul>
|
||||
|
||||
<h2>7. Children's Privacy Protection</h2>
|
||||
<p>The Software is not intended for minors under 18 years of age. If you are a minor, please use the Software
|
||||
under the guidance of a guardian.</p>
|
||||
|
||||
<h2>8. Privacy Policy Updates</h2>
|
||||
<p>
|
||||
We may update this Privacy Policy based on legal requirements or changes in product features. The updated
|
||||
policy will be published in the Software and you will be notified before it takes effect. If you do not
|
||||
agree with the updated terms, you can choose to stop using the Software.
|
||||
</p>
|
||||
|
||||
<h2>9. Contact Us</h2>
|
||||
<p>If you have any questions, suggestions, or complaints about this Privacy Policy, please contact us through
|
||||
the following methods:</p>
|
||||
<ul>
|
||||
<li>
|
||||
GitHub: <a href="https://github.com/CherryHQ/cherry-studio" target="_blank"
|
||||
rel="noopener noreferrer">https://github.com/CherryHQ/cherry-studio</a>
|
||||
</li>
|
||||
<li>Email: support@cherry-ai.com</li>
|
||||
</ul>
|
||||
|
||||
<div class="footer">
|
||||
Last Updated: December 2024
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>隐私协议</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
body.dark {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
body.dark h1 {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
body.dark h2 {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 12px 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
body.dark p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 12px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 6px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
body.dark li {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body.dark a {
|
||||
color: #4da6ff;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
body.dark .footer {
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
overflow-y: auto;
|
||||
padding-right: 10px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 滚动条样式 - 亮色模式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 滚动条样式 - 暗色模式 */
|
||||
body.dark ::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
body.dark ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
body.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// 检测主题
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const theme = urlParams.get('theme');
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.body.classList.add('dark');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="content-wrapper">
|
||||
<h1>隐私协议</h1>
|
||||
|
||||
<p>
|
||||
欢迎使用 Cherry Studio(以下简称"本软件"或"我们")。我们高度重视您的隐私保护,本隐私协议将说明我们如何处理与保护您的个人信息和数据。请在使用本软件前仔细阅读并理解本协议:
|
||||
</p>
|
||||
|
||||
<h2>一、我们收集的信息范围</h2>
|
||||
<p>为了优化用户体验和提升软件质量,我们仅可能会匿名收集以下非个人化信息:</p>
|
||||
<ul>
|
||||
<li>软件版本信息;</li>
|
||||
<li>软件功能的活跃度、使用频次;</li>
|
||||
<li>匿名的崩溃、错误日志信息;</li>
|
||||
</ul>
|
||||
<p>上述信息完全匿名,不会涉及任何个人身份数据,也无法关联到您的个人信息。</p>
|
||||
|
||||
<h2>二、我们不会收集的任何信息</h2>
|
||||
<p>为了最大限度保护您的隐私安全,我们明确承诺:</p>
|
||||
<ul>
|
||||
<li>不会收集、保存、传输或处理您输入到本软件中的模型服务 API Key 信息;</li>
|
||||
<li>不会收集、保存、传输或处理您在使用本软件过程中产生的任何对话数据,包括但不限于聊天内容、指令信息、知识库信息、向量数据及其他自定义内容;</li>
|
||||
<li>不会收集、保存、传输或处理任何可识别个人身份的敏感信息。</li>
|
||||
</ul>
|
||||
|
||||
<h2>三、数据交互说明</h2>
|
||||
<p>
|
||||
本软件采用您自行申请并配置的第三方模型服务提供商的 API Key,以完成相关模型的调用与对话功能。您使用的模型服务(例如大模型、API 接口等)由您选择的第三方提供商直接提供,我们不会介入、监控或干扰数据传输过程。
|
||||
</p>
|
||||
<p>
|
||||
您与第三方模型服务之间的数据交互受第三方服务提供商的隐私政策和用户协议约束,我们建议您在使用前充分了解相关服务商的隐私条款。
|
||||
</p>
|
||||
|
||||
<h2>四、本地数据的安全保护</h2>
|
||||
<p>本软件为本地化应用程序,所有数据默认存储在您的本地设备上。我们采取了以下措施保障数据安全:</p>
|
||||
<ul>
|
||||
<li>对话记录、配置信息等数据仅保存在您的本地设备中;</li>
|
||||
<li>提供数据导入/导出功能,方便您自主管理和备份数据;</li>
|
||||
<li>不会将您的本地数据上传至任何服务器或云端存储。</li>
|
||||
</ul>
|
||||
|
||||
<h2>五、第三方服务</h2>
|
||||
<p>
|
||||
在使用本软件过程中,您可能会接入第三方服务(如 AI 模型 API、翻译服务等)。这些第三方服务的使用受其各自的服务条款和隐私政策约束。我们强烈建议您在使用前仔细阅读并理解相关条款。
|
||||
</p>
|
||||
|
||||
<h2>六、用户权利</h2>
|
||||
<p>您对自己的数据拥有完全的控制权:</p>
|
||||
<ul>
|
||||
<li>您可以随时查看、修改、删除本地存储的所有数据;</li>
|
||||
<li>您可以选择是否启用特定功能或服务;</li>
|
||||
<li>您可以随时停止使用本软件并删除所有相关数据。</li>
|
||||
</ul>
|
||||
|
||||
<h2>七、儿童隐私保护</h2>
|
||||
<p>本软件不面向 18 岁以下的未成年人提供服务。如果您是未成年人,请在监护人的指导下使用本软件。</p>
|
||||
|
||||
<h2>八、隐私政策的更新</h2>
|
||||
<p>
|
||||
我们可能会根据法律法规要求或产品功能的变化更新本隐私协议。更新后的协议将在软件中发布,并在生效前通知您。如果您不同意更新后的条款,您可以选择停止使用本软件。
|
||||
</p>
|
||||
|
||||
<h2>九、联系我们</h2>
|
||||
<p>如果您对本隐私协议有任何疑问、建议或投诉,请通过以下方式联系我们:</p>
|
||||
<ul>
|
||||
<li>
|
||||
GitHub: <a href="https://github.com/CherryHQ/cherry-studio" target="_blank"
|
||||
rel="noopener noreferrer">https://github.com/CherryHQ/cherry-studio</a>
|
||||
</li>
|
||||
<li>Email: support@cherry-ai.com</li>
|
||||
</ul>
|
||||
|
||||
<div class="footer">
|
||||
最后更新日期:2024年12月
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,5 +1,7 @@
|
||||
const https = require('https')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
/**
|
||||
* Downloads a file from a URL with redirect handling
|
||||
@@ -32,4 +34,39 @@ async function downloadWithRedirects(url, destinationPath) {
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { downloadWithRedirects }
|
||||
/**
|
||||
* Downloads a file using PowerShell Invoke-WebRequest command
|
||||
* @param {string} url The URL to download from
|
||||
* @param {string} destinationPath The path to save the file to
|
||||
* @returns {Promise<boolean>} Promise that resolves to true if download succeeds
|
||||
*/
|
||||
async function downloadWithPowerShell(url, destinationPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Only support windows platform for PowerShell download
|
||||
if (process.platform !== 'win32') {
|
||||
return reject(new Error('PowerShell download is only supported on Windows'))
|
||||
}
|
||||
|
||||
const outputDir = path.dirname(destinationPath)
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
// PowerShell command to download the file with progress disabled for faster download
|
||||
const psCommand = `powershell -Command "$ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest '${url}' -OutFile '${destinationPath}'"`
|
||||
|
||||
console.log(`Downloading with PowerShell: ${url}`)
|
||||
execSync(psCommand, { stdio: 'inherit' })
|
||||
|
||||
if (fs.existsSync(destinationPath)) {
|
||||
console.log(`Download completed: ${destinationPath}`)
|
||||
resolve(true)
|
||||
} else {
|
||||
reject(new Error('Download failed: File not found after download'))
|
||||
}
|
||||
} catch (error) {
|
||||
reject(new Error(`PowerShell download failed: ${error.message}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { downloadWithRedirects, downloadWithPowerShell }
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const { downloadWithPowerShell } = require('./download')
|
||||
|
||||
// Base URL for downloading OVMS binaries
|
||||
const OVMS_PKG_NAME = 'ovms250911.zip'
|
||||
const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`]
|
||||
|
||||
/**
|
||||
* Downloads and extracts the OVMS binary for the specified platform
|
||||
*/
|
||||
async function downloadOvmsBinary() {
|
||||
// Create output directory structure - OVMS goes into its own subdirectory
|
||||
const csDir = path.join(os.homedir(), '.cherrystudio')
|
||||
|
||||
// Ensure directories exist
|
||||
fs.mkdirSync(csDir, { recursive: true })
|
||||
|
||||
const csOvmsDir = path.join(csDir, 'ovms')
|
||||
// Delete existing OVMS directory if it exists
|
||||
if (fs.existsSync(csOvmsDir)) {
|
||||
fs.rmSync(csOvmsDir, { recursive: true })
|
||||
}
|
||||
|
||||
const tempdir = os.tmpdir()
|
||||
const tempFilename = path.join(tempdir, 'ovms.zip')
|
||||
|
||||
// Try each URL until one succeeds
|
||||
let downloadSuccess = false
|
||||
let lastError = null
|
||||
|
||||
for (let i = 0; i < OVMS_RELEASE_BASE_URL.length; i++) {
|
||||
const downloadUrl = OVMS_RELEASE_BASE_URL[i]
|
||||
console.log(`Attempting download from URL ${i + 1}/${OVMS_RELEASE_BASE_URL.length}: ${downloadUrl}`)
|
||||
|
||||
try {
|
||||
console.log(`Downloading OVMS from ${downloadUrl} to ${tempFilename}...`)
|
||||
|
||||
// Try PowerShell download first, fallback to Node.js download if it fails
|
||||
await downloadWithPowerShell(downloadUrl, tempFilename)
|
||||
|
||||
// If we get here, download was successful
|
||||
downloadSuccess = true
|
||||
console.log(`Successfully downloaded from: ${downloadUrl}`)
|
||||
break
|
||||
} catch (error) {
|
||||
console.warn(`Download failed from ${downloadUrl}: ${error.message}`)
|
||||
lastError = error
|
||||
|
||||
// Clean up failed download file if it exists
|
||||
if (fs.existsSync(tempFilename)) {
|
||||
try {
|
||||
fs.unlinkSync(tempFilename)
|
||||
} catch (cleanupError) {
|
||||
console.warn(`Failed to clean up temporary file: ${cleanupError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue to next URL if this one failed
|
||||
if (i < OVMS_RELEASE_BASE_URL.length - 1) {
|
||||
console.log(`Trying next URL...`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any download succeeded
|
||||
if (!downloadSuccess) {
|
||||
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
|
||||
return 103
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Extracting to ${csDir}...`)
|
||||
|
||||
// Use tar.exe to extract the ZIP file
|
||||
console.log(`Extracting OVMS to ${csDir}...`)
|
||||
execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' })
|
||||
console.log(`OVMS extracted to ${csDir}`)
|
||||
|
||||
// Clean up temporary file
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Installation directory: ${csDir}`)
|
||||
} catch (error) {
|
||||
console.error(`Error installing OVMS: ${error.message}`)
|
||||
if (fs.existsSync(tempFilename)) {
|
||||
fs.unlinkSync(tempFilename)
|
||||
}
|
||||
|
||||
// Check if ovmsDir is empty and remove it if so
|
||||
try {
|
||||
const ovmsDir = path.join(csDir, 'ovms')
|
||||
const files = fs.readdirSync(ovmsDir)
|
||||
if (files.length === 0) {
|
||||
fs.rmSync(ovmsDir, { recursive: true })
|
||||
console.log(`Removed empty directory: ${ovmsDir}`)
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
|
||||
return 105
|
||||
}
|
||||
|
||||
return 104
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CPU Name and ID
|
||||
*/
|
||||
function getCpuInfo() {
|
||||
const cpuInfo = {
|
||||
name: '',
|
||||
id: ''
|
||||
}
|
||||
|
||||
// Use PowerShell to get CPU information
|
||||
try {
|
||||
const psCommand = `powershell -Command "Get-CimInstance -ClassName Win32_Processor | Select-Object Name, DeviceID | ConvertTo-Json"`
|
||||
const psOutput = execSync(psCommand).toString()
|
||||
const cpuData = JSON.parse(psOutput)
|
||||
|
||||
if (Array.isArray(cpuData)) {
|
||||
cpuInfo.name = cpuData[0].Name || ''
|
||||
cpuInfo.id = cpuData[0].DeviceID || ''
|
||||
} else {
|
||||
cpuInfo.name = cpuData.Name || ''
|
||||
cpuInfo.id = cpuData.DeviceID || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get CPU info: ${error.message}`)
|
||||
}
|
||||
|
||||
return cpuInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to install OVMS
|
||||
*/
|
||||
async function installOvms() {
|
||||
const platform = os.platform()
|
||||
console.log(`Detected platform: ${platform}`)
|
||||
|
||||
const cpuName = getCpuInfo().name
|
||||
console.log(`CPU Name: ${cpuName}`)
|
||||
|
||||
// Check if CPU name contains "Ultra"
|
||||
if (!cpuName.toLowerCase().includes('intel') || !cpuName.toLowerCase().includes('ultra')) {
|
||||
console.error('OVMS installation requires an Intel(R) Core(TM) Ultra CPU.')
|
||||
return 101
|
||||
}
|
||||
|
||||
// only support windows
|
||||
if (platform !== 'win32') {
|
||||
console.error('OVMS installation is only supported on Windows.')
|
||||
return 102
|
||||
}
|
||||
|
||||
return await downloadOvmsBinary()
|
||||
}
|
||||
|
||||
// Run the installation
|
||||
installOvms()
|
||||
.then((retcode) => {
|
||||
if (retcode === 0) {
|
||||
console.log('OVMS installation successful')
|
||||
} else {
|
||||
console.error('OVMS installation failed')
|
||||
}
|
||||
process.exit(retcode)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('OVMS installation failed:', error)
|
||||
process.exit(100)
|
||||
})
|
||||
+1
-1
@@ -21,4 +21,4 @@ export const titleBarOverlayLight = {
|
||||
symbolColor: '#000'
|
||||
}
|
||||
|
||||
global.CHERRYIN_CLIENT_SECRET = import.meta.env.MAIN_VITE_CHERRYIN_CLIENT_SECRET
|
||||
global.CHERRYAI_CLIENT_SECRET = import.meta.env.MAIN_VITE_CHERRYAI_CLIENT_SECRET
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
var _0xe15d9a;const crypto=require("\u0063\u0072\u0079\u0070\u0074\u006F");_0xe15d9a=(988194^988194)+(417607^417603);var _0x9b_0x742=(247379^247387)+(371889^371892);const CLIENT_ID="\u0063\u0068\u0065\u0072\u0072\u0079\u002D\u0073\u0074\u0075\u0064\u0069\u006F";_0x9b_0x742=(202849^202856)+(796590^796585);var _0xa971e=(422203^422203)+(167917^167919);const CLIENT_SECRET_SUFFIX="\u0047\u0076\u0049\u0036\u0049\u0035\u005A\u0072\u0045\u0048\u0063\u0047\u004F\u0057\u006A\u004F\u0035\u0041\u004B\u0068\u004A\u004B\u0047\u006D\u006E\u0077\u0077\u0047\u0066\u004D\u0036\u0032\u0058\u004B\u0070\u0057\u0071\u006B\u006A\u0068\u0076\u007A\u0052\u0055\u0032\u004E\u005A\u0049\u0069\u006E\u004D\u0037\u0037\u0061\u0054\u0047\u0049\u0071\u0068\u0071\u0079\u0073\u0030\u0067";_0xa971e=(607707^607705)+(127822^127823);const CLIENT_SECRET=global['\u0043\u0048\u0045\u0052\u0052\u0059\u0041\u0049\u005F\u0043\u004C\u0049\u0045\u004E\u0054\u005F\u0053\u0045\u0043\u0052\u0045\u0054']+"\u002E"+CLIENT_SECRET_SUFFIX;class SignatureClient{constructor(clientId,clientSecret){this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064']=clientId||CLIENT_ID;this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']=clientSecret||CLIENT_SECRET;this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']=this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']['\u0062\u0069\u006E\u0064'](this);}generateSignature(options){const{'\u006D\u0065\u0074\u0068\u006F\u0064':method,'\u0070\u0061\u0074\u0068':path,'\u0071\u0075\u0065\u0072\u0079':query='','\u0062\u006F\u0064\u0079':body=''}=options;var _0x99a7f=(735625^735624)+(520507^520508);const timestamp=Math['\u0066\u006C\u006F\u006F\u0072'](Date['\u006E\u006F\u0077']()/(351300^352172))['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();_0x99a7f=376728^376729;var _0x733a=(876666^876671)+(658949^658944);let bodyString='';_0x733a="kgclcd".split("").reverse().join("");if(body){if(typeof body==="tcejbo".split("").reverse().join("")){bodyString=JSON['\u0073\u0074\u0072\u0069\u006E\u0067\u0069\u0066\u0079'](body);}else{bodyString=body['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();}}var _0xd8edff;const signatureParts=[method['\u0074\u006F\u0055\u0070\u0070\u0065\u0072\u0043\u0061\u0073\u0065'](),path,query,this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],timestamp,bodyString];_0xd8edff=(929945^929951)+(569907^569915);var _0x9g3c3b=(705579^705579)+(981211^981209);const signatureString=signatureParts['\u006A\u006F\u0069\u006E']("\u000A");_0x9g3c3b=527497^527499;var _0x95b35f=(811203^811200)+(628072^628076);const hmac=crypto['\u0063\u0072\u0065\u0061\u0074\u0065\u0048\u006D\u0061\u0063']("\u0073\u0068\u0061\u0032\u0035\u0036",this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']);_0x95b35f=104120^104112;hmac['\u0075\u0070\u0064\u0061\u0074\u0065'](signatureString);var _0xd0f6g;const signature=hmac['\u0064\u0069\u0067\u0065\u0073\u0074']("xeh".split("").reverse().join(""));_0xd0f6g=(615019^615018)+(266997^266992);return{'X-Client-ID':this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],"\u0058\u002D\u0054\u0069\u006D\u0065\u0073\u0074\u0061\u006D\u0070":timestamp,'X-Signature':signature};}}const signatureClient=new SignatureClient();const generateSignature=signatureClient['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065'];module['\u0065\u0078\u0070\u006F\u0072\u0074\u0073']={'\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065\u0043\u006C\u0069\u0065\u006E\u0074':SignatureClient,"generateSignature":generateSignature};
|
||||
@@ -1 +0,0 @@
|
||||
var _0x6gg;const crypto=require("\u0063\u0072\u0079\u0070\u0074\u006F");_0x6gg='\u006D\u006F\u006C\u006A\u0065\u0065';var _0x111cbe;const CLIENT_ID="oiduts-yrrehc".split("").reverse().join("");_0x111cbe=(977158^977167)+(164595^164594);var _0x6d6adc=(756649^756650)+(497587^497587);const CLIENT_SECRET_SUFFIX="\u0047\u0076\u0049\u0036\u0049\u0035\u005A\u0072\u0045\u0048\u0063\u0047\u004F\u0057\u006A\u004F\u0035\u0041\u004B\u0068\u004A\u004B\u0047\u006D\u006E\u0077\u0077\u0047\u0066\u004D\u0036\u0032\u0058\u004B\u0070\u0057\u0071\u006B\u006A\u0068\u0076\u007A\u0052\u0055\u0032\u004E\u005A\u0049\u0069\u006E\u004D\u0037\u0037\u0061\u0054\u0047\u0049\u0071\u0068\u0071\u0079\u0073\u0030\u0067";_0x6d6adc=233169^233176;const CLIENT_SECRET=global['\u0043\u0048\u0045\u0052\u0052\u0059\u0049\u004E\u005F\u0043\u004C\u0049\u0045\u004E\u0054\u005F\u0053\u0045\u0043\u0052\u0045\u0054']+"\u002E"+CLIENT_SECRET_SUFFIX;class SignatureClient{constructor(clientId,clientSecret){this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064']=clientId||CLIENT_ID;this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']=clientSecret||CLIENT_SECRET;this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']=this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']['\u0062\u0069\u006E\u0064'](this);}generateSignature(options){const{"method":method,"path":path,"query":query='',"body":body=''}=options;const timestamp=Math['\u0066\u006C\u006F\u006F\u0072'](Date['\u006E\u006F\u0077']()/(110765^111429))['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();var _0xe08cc=(212246^212244)+(773521^773523);let bodyString='';_0xe08cc=(606778^606776)+(962748^962740);if(body){if(typeof body==="\u006F\u0062\u006A\u0065\u0063\u0074"){bodyString=JSON['\u0073\u0074\u0072\u0069\u006E\u0067\u0069\u0066\u0079'](body);}else{bodyString=body['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();}}const signatureParts=[method['\u0074\u006F\u0055\u0070\u0070\u0065\u0072\u0043\u0061\u0073\u0065'](),path,query,this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],timestamp,bodyString];var _0x5693g=(936664^936668)+(685268^685277);const signatureString=signatureParts['\u006A\u006F\u0069\u006E']("\u000A");_0x5693g=(266582^266576)+(337322^337315);const hmac=crypto['\u0063\u0072\u0065\u0061\u0074\u0065\u0048\u006D\u0061\u0063']("\u0073\u0068\u0061\u0032\u0035\u0036",this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']);hmac['\u0075\u0070\u0064\u0061\u0074\u0065'](signatureString);var _0x5fba=(354480^354481)+(537437^537434);const signature=hmac['\u0064\u0069\u0067\u0065\u0073\u0074']("\u0068\u0065\u0078");_0x5fba=(249614^249610)+(915906^915914);return{'X-Client-ID':this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],'X-Timestamp':timestamp,'X-Signature':signature};}}const signatureClient=new SignatureClient();const generateSignature=signatureClient['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065'];module['\u0065\u0078\u0070\u006F\u0072\u0074\u0073']={'\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065\u0043\u006C\u0069\u0065\u006E\u0074':SignatureClient,'\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065':generateSignature};
|
||||
+29
-3
@@ -4,7 +4,7 @@ import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { isLinux, isMac, isPortable, isWin } from '@main/constant'
|
||||
import { generateSignature } from '@main/integration/cherryin'
|
||||
import { generateSignature } from '@main/integration/cherryai'
|
||||
import anthropicService from '@main/services/AnthropicService'
|
||||
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
@@ -35,6 +35,7 @@ import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ocrService } from './services/ocr/OcrService'
|
||||
import OvmsManager from './services/OvmsManager'
|
||||
import { proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
@@ -81,6 +82,7 @@ const obsidianVaultService = new ObsidianVaultService()
|
||||
const vertexAIService = VertexAIService.getInstance()
|
||||
const memoryService = MemoryService.getInstance()
|
||||
const dxtService = new DxtService()
|
||||
const ovmsManager = new OvmsManager()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater()
|
||||
@@ -126,6 +128,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_Reload, () => mainWindow.reload())
|
||||
ipcMain.handle(IpcChannel.App_Quit, () => app.quit())
|
||||
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
|
||||
|
||||
// Update
|
||||
@@ -431,6 +434,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
// system
|
||||
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
|
||||
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
|
||||
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model)
|
||||
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
win && win.webContents.toggleDevTools()
|
||||
@@ -709,6 +713,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
|
||||
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
|
||||
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
|
||||
ipcMain.handle(IpcChannel.App_InstallOvmsBinary, () => runInstallScript('install-ovms.js'))
|
||||
|
||||
//copilot
|
||||
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage.bind(CopilotService))
|
||||
@@ -824,12 +829,33 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// CodeTools
|
||||
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
|
||||
ipcMain.handle(IpcChannel.CodeTools_GetAvailableTerminals, () => codeToolsService.getAvailableTerminalsForPlatform())
|
||||
ipcMain.handle(IpcChannel.CodeTools_SetCustomTerminalPath, (_, terminalId: string, path: string) =>
|
||||
codeToolsService.setCustomTerminalPath(terminalId, path)
|
||||
)
|
||||
ipcMain.handle(IpcChannel.CodeTools_GetCustomTerminalPath, (_, terminalId: string) =>
|
||||
codeToolsService.getCustomTerminalPath(terminalId)
|
||||
)
|
||||
ipcMain.handle(IpcChannel.CodeTools_RemoveCustomTerminalPath, (_, terminalId: string) =>
|
||||
codeToolsService.removeCustomTerminalPath(terminalId)
|
||||
)
|
||||
|
||||
// OCR
|
||||
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
|
||||
ocrService.ocr(file, provider)
|
||||
)
|
||||
|
||||
// CherryIN
|
||||
ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params))
|
||||
// OVMS
|
||||
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
|
||||
ovmsManager.addModel(modelName, modelId, modelSource, task)
|
||||
)
|
||||
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
|
||||
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
|
||||
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
|
||||
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
|
||||
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
|
||||
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
|
||||
|
||||
// CherryAI
|
||||
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* DiDi MCP Server Implementation
|
||||
*
|
||||
* Based on official DiDi MCP API capabilities.
|
||||
* API Documentation: https://mcp.didichuxing.com/api?tap=api
|
||||
*
|
||||
* Provides ride-hailing services including map search, price estimation,
|
||||
* order management, and driver tracking.
|
||||
*
|
||||
* Note: Only available in Mainland China.
|
||||
*/
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
const logger = loggerService.withContext('DiDiMCPServer')
|
||||
|
||||
export class DiDiMcpServer {
|
||||
private _server: Server
|
||||
private readonly baseUrl = 'http://mcp.didichuxing.com/mcp-servers'
|
||||
private apiKey: string
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
this._server = new Server(
|
||||
{
|
||||
name: 'didi-mcp-server',
|
||||
version: '0.1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Get API key from parameter or environment variables
|
||||
this.apiKey = apiKey || process.env.DIDI_API_KEY || ''
|
||||
if (!this.apiKey) {
|
||||
logger.warn('DIDI_API_KEY environment variable is not set')
|
||||
}
|
||||
|
||||
this.setupRequestHandlers()
|
||||
}
|
||||
|
||||
get server(): Server {
|
||||
return this._server
|
||||
}
|
||||
|
||||
private setupRequestHandlers() {
|
||||
// List available tools
|
||||
this._server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'maps_textsearch',
|
||||
description: 'Search for POI locations based on keywords and city',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: {
|
||||
type: 'string',
|
||||
description: 'Query city'
|
||||
},
|
||||
keywords: {
|
||||
type: 'string',
|
||||
description: 'Search keywords'
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'Location coordinates, format: longitude,latitude'
|
||||
}
|
||||
},
|
||||
required: ['keywords', 'city']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_cancel_order',
|
||||
description: 'Cancel a taxi order',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: {
|
||||
type: 'string',
|
||||
description: 'Order ID from order creation or query results'
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: 'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter'
|
||||
}
|
||||
},
|
||||
required: ['order_id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_create_order',
|
||||
description: 'Create taxi order directly via API without opening any app interface',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
caller_car_phone: {
|
||||
type: 'string',
|
||||
description: 'Caller phone number (optional)'
|
||||
},
|
||||
estimate_trace_id: {
|
||||
type: 'string',
|
||||
description: 'Estimation trace ID from estimation results'
|
||||
},
|
||||
product_category: {
|
||||
type: 'string',
|
||||
description: 'Vehicle category ID from estimation results, comma-separated for multiple types'
|
||||
}
|
||||
},
|
||||
required: ['product_category', 'estimate_trace_id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_estimate',
|
||||
description: 'Get available ride-hailing vehicle types and fare estimates',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from_lat: {
|
||||
type: 'string',
|
||||
description: 'Departure latitude, must be from map tools'
|
||||
},
|
||||
from_lng: {
|
||||
type: 'string',
|
||||
description: 'Departure longitude, must be from map tools'
|
||||
},
|
||||
from_name: {
|
||||
type: 'string',
|
||||
description: 'Departure location name'
|
||||
},
|
||||
to_lat: {
|
||||
type: 'string',
|
||||
description: 'Destination latitude, must be from map tools'
|
||||
},
|
||||
to_lng: {
|
||||
type: 'string',
|
||||
description: 'Destination longitude, must be from map tools'
|
||||
},
|
||||
to_name: {
|
||||
type: 'string',
|
||||
description: 'Destination name'
|
||||
}
|
||||
},
|
||||
required: ['from_lng', 'from_lat', 'from_name', 'to_lng', 'to_lat', 'to_name']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_generate_ride_app_link',
|
||||
description: 'Generate deep links to open ride-hailing apps based on origin, destination and vehicle type',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from_lat: {
|
||||
type: 'string',
|
||||
description: 'Departure latitude, must be from map tools'
|
||||
},
|
||||
from_lng: {
|
||||
type: 'string',
|
||||
description: 'Departure longitude, must be from map tools'
|
||||
},
|
||||
product_category: {
|
||||
type: 'string',
|
||||
description: 'Vehicle category IDs from estimation results, comma-separated for multiple types'
|
||||
},
|
||||
to_lat: {
|
||||
type: 'string',
|
||||
description: 'Destination latitude, must be from map tools'
|
||||
},
|
||||
to_lng: {
|
||||
type: 'string',
|
||||
description: 'Destination longitude, must be from map tools'
|
||||
}
|
||||
},
|
||||
required: ['from_lng', 'from_lat', 'to_lng', 'to_lat']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_get_driver_location',
|
||||
description: 'Get real-time driver location for a taxi order',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: {
|
||||
type: 'string',
|
||||
description: 'Taxi order ID'
|
||||
}
|
||||
},
|
||||
required: ['order_id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_query_order',
|
||||
description: 'Query taxi order status and information such as driver contact, license plate, ETA',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: {
|
||||
type: 'string',
|
||||
description: 'Order ID from order creation results, if available; otherwise queries incomplete orders'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Handle tool calls
|
||||
this._server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'maps_textsearch':
|
||||
return await this.handleMapsTextSearch(args)
|
||||
case 'taxi_cancel_order':
|
||||
return await this.handleTaxiCancelOrder(args)
|
||||
case 'taxi_create_order':
|
||||
return await this.handleTaxiCreateOrder(args)
|
||||
case 'taxi_estimate':
|
||||
return await this.handleTaxiEstimate(args)
|
||||
case 'taxi_generate_ride_app_link':
|
||||
return await this.handleTaxiGenerateRideAppLink(args)
|
||||
case 'taxi_get_driver_location':
|
||||
return await this.handleTaxiGetDriverLocation(args)
|
||||
case 'taxi_query_order':
|
||||
return await this.handleTaxiQueryOrder(args)
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error calling tool ${name}:`, error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async handleMapsTextSearch(args: any) {
|
||||
const { city, keywords, location } = args
|
||||
|
||||
const params = {
|
||||
name: 'maps_textsearch',
|
||||
arguments: {
|
||||
keywords,
|
||||
city,
|
||||
...(location && { location })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Maps text search error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiCancelOrder(args: any) {
|
||||
const { order_id, reason } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_cancel_order',
|
||||
arguments: {
|
||||
order_id,
|
||||
...(reason && { reason })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi cancel order error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiCreateOrder(args: any) {
|
||||
const { caller_car_phone, estimate_trace_id, product_category } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_create_order',
|
||||
arguments: {
|
||||
product_category,
|
||||
estimate_trace_id,
|
||||
...(caller_car_phone && { caller_car_phone })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi create order error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiEstimate(args: any) {
|
||||
const { from_lng, from_lat, from_name, to_lng, to_lat, to_name } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_estimate',
|
||||
arguments: {
|
||||
from_lng,
|
||||
from_lat,
|
||||
from_name,
|
||||
to_lng,
|
||||
to_lat,
|
||||
to_name
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi estimate error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiGenerateRideAppLink(args: any) {
|
||||
const { from_lng, from_lat, to_lng, to_lat, product_category } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_generate_ride_app_link',
|
||||
arguments: {
|
||||
from_lng,
|
||||
from_lat,
|
||||
to_lng,
|
||||
to_lat,
|
||||
...(product_category && { product_category })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi generate ride app link error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiGetDriverLocation(args: any) {
|
||||
const { order_id } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_get_driver_location',
|
||||
arguments: {
|
||||
order_id
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi get driver location error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiQueryOrder(args: any) {
|
||||
const { order_id } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_query_order',
|
||||
arguments: {
|
||||
...(order_id && { order_id })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi query order error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async makeRequest(method: string, params: any): Promise<any> {
|
||||
const requestData = {
|
||||
jsonrpc: '2.0',
|
||||
method: method,
|
||||
id: Date.now(),
|
||||
...(Object.keys(params).length > 0 && { params })
|
||||
}
|
||||
|
||||
// API key is passed as URL parameter
|
||||
const url = `${this.baseUrl}?key=${this.apiKey}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(`API Error: ${JSON.stringify(data.error)}`)
|
||||
}
|
||||
|
||||
return data.result
|
||||
}
|
||||
}
|
||||
|
||||
export default DiDiMcpServer
|
||||
@@ -3,6 +3,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
|
||||
|
||||
import BraveSearchServer from './brave-search'
|
||||
import DiDiMcpServer from './didi-mcp'
|
||||
import DifyKnowledgeServer from './dify-knowledge'
|
||||
import FetchServer from './fetch'
|
||||
import FileSystemServer from './filesystem'
|
||||
@@ -42,6 +43,10 @@ export function createInMemoryMCPServer(
|
||||
case BuiltinMCPServerNames.python: {
|
||||
return new PythonServer().server
|
||||
}
|
||||
case BuiltinMCPServerNames.didiMCP: {
|
||||
const apiKey = envs.DIDI_API_KEY
|
||||
return new DiDiMcpServer(apiKey).server
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ import { windowService } from './WindowService'
|
||||
|
||||
const logger = loggerService.withContext('AppUpdater')
|
||||
|
||||
// Language markers constants for multi-language release notes
|
||||
const LANG_MARKERS = {
|
||||
EN_START: '<!--LANG:en-->',
|
||||
ZH_CN_START: '<!--LANG:zh-CN-->',
|
||||
END: '<!--LANG:END-->'
|
||||
} as const
|
||||
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
private releaseInfo: UpdateInfo | undefined
|
||||
@@ -30,7 +37,8 @@ export default class AppUpdater {
|
||||
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
|
||||
autoUpdater.requestHeaders = {
|
||||
...autoUpdater.requestHeaders,
|
||||
'User-Agent': generateUserAgent()
|
||||
'User-Agent': generateUserAgent(),
|
||||
'X-Client-Id': configManager.getClientId()
|
||||
}
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
@@ -40,7 +48,8 @@ export default class AppUpdater {
|
||||
|
||||
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
|
||||
logger.info('update available', releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
|
||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, processedReleaseInfo)
|
||||
})
|
||||
|
||||
// 检测到不需要更新时
|
||||
@@ -55,9 +64,10 @@ export default class AppUpdater {
|
||||
|
||||
// 当需要更新的内容下载完成后
|
||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
|
||||
this.releaseInfo = releaseInfo
|
||||
logger.info('update downloaded', releaseInfo)
|
||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
||||
this.releaseInfo = processedReleaseInfo
|
||||
logger.info('update downloaded', processedReleaseInfo)
|
||||
})
|
||||
|
||||
if (isWin) {
|
||||
@@ -270,16 +280,99 @@ export default class AppUpdater {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if release notes contain multi-language markers
|
||||
*/
|
||||
private hasMultiLanguageMarkers(releaseNotes: string): boolean {
|
||||
return releaseNotes.includes(LANG_MARKERS.EN_START)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multi-language release notes and return the appropriate language version
|
||||
* @param releaseNotes - Release notes string with language markers
|
||||
* @returns Parsed release notes for the user's language
|
||||
*
|
||||
* Expected format:
|
||||
* <!--LANG:en-->English content<!--LANG:zh-CN-->Chinese content<!--LANG:END-->
|
||||
*/
|
||||
private parseMultiLangReleaseNotes(releaseNotes: string): string {
|
||||
try {
|
||||
const language = configManager.getLanguage()
|
||||
const isChineseUser = language === 'zh-CN' || language === 'zh-TW'
|
||||
|
||||
// Create regex patterns using constants
|
||||
const enPattern = new RegExp(
|
||||
`${LANG_MARKERS.EN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
|
||||
)
|
||||
const zhPattern = new RegExp(
|
||||
`${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
|
||||
)
|
||||
|
||||
// Extract language sections
|
||||
const enMatch = releaseNotes.match(enPattern)
|
||||
const zhMatch = releaseNotes.match(zhPattern)
|
||||
|
||||
// Return appropriate language version with proper fallback
|
||||
if (isChineseUser && zhMatch) {
|
||||
return zhMatch[1].trim()
|
||||
} else if (enMatch) {
|
||||
return enMatch[1].trim()
|
||||
} else {
|
||||
// Clean fallback: remove all language markers
|
||||
logger.warn('Failed to extract language-specific release notes, using cleaned fallback')
|
||||
return releaseNotes
|
||||
.replace(new RegExp(`${LANG_MARKERS.EN_START}|${LANG_MARKERS.ZH_CN_START}|${LANG_MARKERS.END}`, 'g'), '')
|
||||
.trim()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse multi-language release notes', error as Error)
|
||||
// Return original notes as safe fallback
|
||||
return releaseNotes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process release info to handle multi-language release notes
|
||||
* @param releaseInfo - Original release info from updater
|
||||
* @returns Processed release info with localized release notes
|
||||
*/
|
||||
private processReleaseInfo(releaseInfo: UpdateInfo): UpdateInfo {
|
||||
const processedInfo = { ...releaseInfo }
|
||||
|
||||
// Handle multi-language release notes in string format
|
||||
if (releaseInfo.releaseNotes && typeof releaseInfo.releaseNotes === 'string') {
|
||||
// Check if it contains multi-language markers
|
||||
if (this.hasMultiLanguageMarkers(releaseInfo.releaseNotes)) {
|
||||
processedInfo.releaseNotes = this.parseMultiLangReleaseNotes(releaseInfo.releaseNotes)
|
||||
}
|
||||
}
|
||||
|
||||
return processedInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Format release notes for display
|
||||
* @param releaseNotes - Release notes in various formats
|
||||
* @returns Formatted string for display
|
||||
*/
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof releaseNotes === 'string') {
|
||||
// Check if it contains multi-language markers
|
||||
if (this.hasMultiLanguageMarkers(releaseNotes)) {
|
||||
return this.parseMultiLangReleaseNotes(releaseNotes)
|
||||
}
|
||||
return releaseNotes
|
||||
}
|
||||
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
if (Array.isArray(releaseNotes)) {
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
}
|
||||
interface GithubReleaseInfo {
|
||||
|
||||
@@ -3,11 +3,20 @@ import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
import { removeEnvProxy } from '@main/utils'
|
||||
import { isUserInChina } from '@main/utils/ipService'
|
||||
import { getBinaryName } from '@main/utils/process'
|
||||
import { codeTools } from '@shared/config/constant'
|
||||
import {
|
||||
codeTools,
|
||||
MACOS_TERMINALS,
|
||||
MACOS_TERMINALS_WITH_COMMANDS,
|
||||
terminalApps,
|
||||
TerminalConfig,
|
||||
TerminalConfigWithCommand,
|
||||
WINDOWS_TERMINALS,
|
||||
WINDOWS_TERMINALS_WITH_COMMANDS
|
||||
} from '@shared/config/constant'
|
||||
import { spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
@@ -22,7 +31,13 @@ interface VersionInfo {
|
||||
|
||||
class CodeToolsService {
|
||||
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
|
||||
private terminalsCache: {
|
||||
terminals: TerminalConfig[]
|
||||
timestamp: number
|
||||
} | null = null
|
||||
private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
|
||||
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
|
||||
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
|
||||
|
||||
constructor() {
|
||||
this.getBunPath = this.getBunPath.bind(this)
|
||||
@@ -32,6 +47,23 @@ class CodeToolsService {
|
||||
this.getVersionInfo = this.getVersionInfo.bind(this)
|
||||
this.updatePackage = this.updatePackage.bind(this)
|
||||
this.run = this.run.bind(this)
|
||||
|
||||
if (isMac || isWin) {
|
||||
this.preloadTerminals()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload available terminals in background
|
||||
*/
|
||||
private async preloadTerminals(): Promise<void> {
|
||||
try {
|
||||
logger.info('Preloading available terminals...')
|
||||
await this.getAvailableTerminals()
|
||||
logger.info('Terminal preloading completed')
|
||||
} catch (error) {
|
||||
logger.warn('Terminal preloading failed:', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
public async getBunPath() {
|
||||
@@ -53,6 +85,8 @@ class CodeToolsService {
|
||||
return '@qwen-code/qwen-code'
|
||||
case codeTools.iFlowCli:
|
||||
return '@iflow-ai/iflow-cli'
|
||||
case codeTools.githubCopilotCli:
|
||||
return '@github/copilot'
|
||||
default:
|
||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||
}
|
||||
@@ -70,15 +104,267 @@ class CodeToolsService {
|
||||
return 'qwen'
|
||||
case codeTools.iFlowCli:
|
||||
return 'iflow'
|
||||
case codeTools.githubCopilotCli:
|
||||
return 'copilot'
|
||||
default:
|
||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a single terminal is available
|
||||
*/
|
||||
private async checkTerminalAvailability(terminal: TerminalConfig): Promise<TerminalConfig | null> {
|
||||
try {
|
||||
if (isMac && terminal.bundleId) {
|
||||
// macOS: Check if application is installed via bundle ID with timeout
|
||||
const { stdout } = await execAsync(`mdfind "kMDItemCFBundleIdentifier == '${terminal.bundleId}'"`, {
|
||||
timeout: 3000
|
||||
})
|
||||
if (stdout.trim()) {
|
||||
return terminal
|
||||
}
|
||||
} else if (isWin) {
|
||||
// Windows: Check terminal availability
|
||||
return await this.checkWindowsTerminalAvailability(terminal)
|
||||
} else {
|
||||
// TODO: Check if terminal is available in linux
|
||||
await execAsync(`which ${terminal.id}`, { timeout: 2000 })
|
||||
return terminal
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Terminal ${terminal.id} not available:`, error as Error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Windows terminal availability (simplified - user configured paths)
|
||||
*/
|
||||
private async checkWindowsTerminalAvailability(terminal: TerminalConfig): Promise<TerminalConfig | null> {
|
||||
try {
|
||||
switch (terminal.id) {
|
||||
case terminalApps.cmd:
|
||||
// CMD is always available on Windows
|
||||
return terminal
|
||||
|
||||
case terminalApps.powershell:
|
||||
// Check for PowerShell in PATH
|
||||
try {
|
||||
await execAsync('powershell -Command "Get-Host"', {
|
||||
timeout: 3000
|
||||
})
|
||||
return terminal
|
||||
} catch {
|
||||
try {
|
||||
await execAsync('pwsh -Command "Get-Host"', { timeout: 3000 })
|
||||
return terminal
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
case terminalApps.windowsTerminal:
|
||||
// Check for Windows Terminal via where command (doesn't launch the terminal)
|
||||
try {
|
||||
await execAsync('where wt', { timeout: 3000 })
|
||||
return terminal
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
case terminalApps.wsl:
|
||||
// Check for WSL
|
||||
try {
|
||||
await execAsync('wsl --status', { timeout: 3000 })
|
||||
return terminal
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
default:
|
||||
// For other terminals (Alacritty, WezTerm), check if user has configured custom path
|
||||
return await this.checkCustomTerminalPath(terminal)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Windows terminal ${terminal.id} not available:`, error as Error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has configured custom path for terminal
|
||||
*/
|
||||
private async checkCustomTerminalPath(terminal: TerminalConfig): Promise<TerminalConfig | null> {
|
||||
// Check if user has configured custom path
|
||||
const customPath = this.customTerminalPaths.get(terminal.id)
|
||||
if (customPath && fs.existsSync(customPath)) {
|
||||
try {
|
||||
await execAsync(`"${customPath}" --version`, { timeout: 3000 })
|
||||
return { ...terminal, customPath }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to PATH check
|
||||
try {
|
||||
const command = terminal.id === terminalApps.alacritty ? 'alacritty' : 'wezterm'
|
||||
await execAsync(`${command} --version`, { timeout: 3000 })
|
||||
return terminal
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom path for a terminal (called from settings UI)
|
||||
*/
|
||||
public setCustomTerminalPath(terminalId: string, path: string): void {
|
||||
logger.info(`Setting custom path for terminal ${terminalId}: ${path}`)
|
||||
this.customTerminalPaths.set(terminalId, path)
|
||||
// Clear terminals cache to force refresh
|
||||
this.terminalsCache = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom path for a terminal
|
||||
*/
|
||||
public getCustomTerminalPath(terminalId: string): string | undefined {
|
||||
return this.customTerminalPaths.get(terminalId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove custom path for a terminal
|
||||
*/
|
||||
public removeCustomTerminalPath(terminalId: string): void {
|
||||
logger.info(`Removing custom path for terminal ${terminalId}`)
|
||||
this.customTerminalPaths.delete(terminalId)
|
||||
// Clear terminals cache to force refresh
|
||||
this.terminalsCache = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available terminals (with caching and parallel checking)
|
||||
*/
|
||||
private async getAvailableTerminals(): Promise<TerminalConfig[]> {
|
||||
const now = Date.now()
|
||||
|
||||
// Check cache first
|
||||
if (this.terminalsCache && now - this.terminalsCache.timestamp < this.TERMINALS_CACHE_DURATION) {
|
||||
logger.info(`Using cached terminals list (${this.terminalsCache.terminals.length} terminals)`)
|
||||
return this.terminalsCache.terminals
|
||||
}
|
||||
|
||||
logger.info('Checking available terminals in parallel...')
|
||||
const startTime = Date.now()
|
||||
|
||||
// Get terminal list based on platform
|
||||
const terminalList = isWin ? WINDOWS_TERMINALS : MACOS_TERMINALS
|
||||
|
||||
// Check all terminals in parallel
|
||||
const terminalPromises = terminalList.map((terminal) => this.checkTerminalAvailability(terminal))
|
||||
|
||||
try {
|
||||
// Wait for all checks to complete with a global timeout
|
||||
const results = await Promise.allSettled(
|
||||
terminalPromises.map((p) =>
|
||||
Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))])
|
||||
)
|
||||
)
|
||||
|
||||
const availableTerminals: TerminalConfig[] = []
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
availableTerminals.push(result.value as TerminalConfig)
|
||||
} else if (result.status === 'rejected') {
|
||||
logger.debug(`Terminal check failed for ${MACOS_TERMINALS[index].id}:`, result.reason)
|
||||
}
|
||||
})
|
||||
|
||||
const endTime = Date.now()
|
||||
logger.info(
|
||||
`Terminal availability check completed in ${endTime - startTime}ms, found ${availableTerminals.length} terminals`
|
||||
)
|
||||
|
||||
// Cache the results
|
||||
this.terminalsCache = {
|
||||
terminals: availableTerminals,
|
||||
timestamp: now
|
||||
}
|
||||
|
||||
return availableTerminals
|
||||
} catch (error) {
|
||||
logger.error('Error checking terminal availability:', error as Error)
|
||||
// Return cached result if available, otherwise empty array
|
||||
return this.terminalsCache?.terminals || []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get terminal config by ID, fallback to system default
|
||||
*/
|
||||
private async getTerminalConfig(terminalId?: string): Promise<TerminalConfigWithCommand> {
|
||||
const availableTerminals = await this.getAvailableTerminals()
|
||||
const terminalCommands = isWin ? WINDOWS_TERMINALS_WITH_COMMANDS : MACOS_TERMINALS_WITH_COMMANDS
|
||||
const defaultTerminal = isWin ? terminalApps.cmd : terminalApps.systemDefault
|
||||
|
||||
if (terminalId) {
|
||||
let requestedTerminal = terminalCommands.find(
|
||||
(t) => t.id === terminalId && availableTerminals.some((at) => at.id === t.id)
|
||||
)
|
||||
|
||||
if (requestedTerminal) {
|
||||
// Apply custom path if configured
|
||||
const customPath = this.customTerminalPaths.get(terminalId)
|
||||
if (customPath && isWin) {
|
||||
requestedTerminal = this.applyCustomPath(requestedTerminal, customPath)
|
||||
}
|
||||
return requestedTerminal
|
||||
} else {
|
||||
logger.warn(`Requested terminal ${terminalId} not available, falling back to system default`)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to system default Terminal
|
||||
const systemTerminal = terminalCommands.find(
|
||||
(t) => t.id === defaultTerminal && availableTerminals.some((at) => at.id === t.id)
|
||||
)
|
||||
if (systemTerminal) {
|
||||
return systemTerminal
|
||||
}
|
||||
|
||||
// If even system Terminal is not found, return the first available
|
||||
const firstAvailable = terminalCommands.find((t) => availableTerminals.some((at) => at.id === t.id))
|
||||
if (firstAvailable) {
|
||||
return firstAvailable
|
||||
}
|
||||
|
||||
// Last resort fallback
|
||||
return terminalCommands.find((t) => t.id === defaultTerminal)!
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply custom path to terminal configuration
|
||||
*/
|
||||
private applyCustomPath(terminal: TerminalConfigWithCommand, customPath: string): TerminalConfigWithCommand {
|
||||
return {
|
||||
...terminal,
|
||||
customPath,
|
||||
command: (directory: string, fullCommand: string) => {
|
||||
const originalCommand = terminal.command(directory, fullCommand)
|
||||
return {
|
||||
...originalCommand,
|
||||
command: customPath // Replace command with custom path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isPackageInstalled(cliTool: string): Promise<boolean> {
|
||||
const executableName = await this.getCliExecutableName(cliTool)
|
||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
|
||||
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
|
||||
|
||||
// Ensure bin directory exists
|
||||
if (!fs.existsSync(binDir)) {
|
||||
@@ -105,9 +391,11 @@ class CodeToolsService {
|
||||
try {
|
||||
const executableName = await this.getCliExecutableName(cliTool)
|
||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
|
||||
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
|
||||
|
||||
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
|
||||
const { stdout } = await execAsync(`"${executablePath}" --version`, {
|
||||
timeout: 10000
|
||||
})
|
||||
// Extract version number from output (format may vary by tool)
|
||||
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
|
||||
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
|
||||
@@ -148,7 +436,10 @@ class CodeToolsService {
|
||||
logger.info(`${packageName} latest version: ${latestVersion}`)
|
||||
|
||||
// Cache the result
|
||||
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
|
||||
this.versionCache.set(cacheKey, {
|
||||
version: latestVersion!,
|
||||
timestamp: now
|
||||
})
|
||||
logger.debug(`Cached latest version for ${packageName}`)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)
|
||||
@@ -191,6 +482,17 @@ class CodeToolsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available terminals for the current platform
|
||||
*/
|
||||
public async getAvailableTerminalsForPlatform(): Promise<TerminalConfig[]> {
|
||||
if (isMac || isWin) {
|
||||
return this.getAvailableTerminals()
|
||||
}
|
||||
// For other platforms, return empty array for now
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a CLI tool to the latest version
|
||||
*/
|
||||
@@ -202,10 +504,9 @@ class CodeToolsService {
|
||||
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
|
||||
const registryUrl = await this.getNpmRegistryUrl()
|
||||
|
||||
const installEnvPrefix =
|
||||
process.platform === 'win32'
|
||||
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
|
||||
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
|
||||
const installEnvPrefix = isWin
|
||||
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
|
||||
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
|
||||
|
||||
const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
|
||||
logger.info(`Executing update command: ${updateCommand}`)
|
||||
@@ -241,7 +542,7 @@ class CodeToolsService {
|
||||
_model: string,
|
||||
directory: string,
|
||||
env: Record<string, string>,
|
||||
options: { autoUpdateToLatest?: boolean } = {}
|
||||
options: { autoUpdateToLatest?: boolean; terminal?: string } = {}
|
||||
) {
|
||||
logger.info(`Starting CLI tool launch: ${cliTool} in directory: ${directory}`)
|
||||
logger.debug(`Environment variables:`, Object.keys(env))
|
||||
@@ -251,7 +552,7 @@ class CodeToolsService {
|
||||
const bunPath = await this.getBunPath()
|
||||
const executableName = await this.getCliExecutableName(cliTool)
|
||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
|
||||
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
|
||||
|
||||
logger.debug(`Package name: ${packageName}`)
|
||||
logger.debug(`Bun path: ${bunPath}`)
|
||||
@@ -295,7 +596,13 @@ class CodeToolsService {
|
||||
|
||||
// Build environment variable prefix (based on platform)
|
||||
const buildEnvPrefix = (isWindows: boolean) => {
|
||||
if (Object.keys(env).length === 0) return ''
|
||||
if (Object.keys(env).length === 0) {
|
||||
logger.info('No environment variables to set')
|
||||
return ''
|
||||
}
|
||||
|
||||
logger.info('Setting environment variables:', Object.keys(env))
|
||||
logger.info('Environment variable values:', env)
|
||||
|
||||
if (isWindows) {
|
||||
// Windows uses set command
|
||||
@@ -304,13 +611,29 @@ class CodeToolsService {
|
||||
.join(' && ')
|
||||
} else {
|
||||
// Unix-like systems use export command
|
||||
return Object.entries(env)
|
||||
.map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`)
|
||||
const validEntries = Object.entries(env).filter(([key, value]) => {
|
||||
if (!key || key.trim() === '') {
|
||||
return false
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const envCommands = validEntries
|
||||
.map(([key, value]) => {
|
||||
const sanitizedValue = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
const exportCmd = `export ${key}="${sanitizedValue}"`
|
||||
logger.info(`Setting env var: ${key}="${sanitizedValue}"`)
|
||||
logger.info(`Export command: ${exportCmd}`)
|
||||
return exportCmd
|
||||
})
|
||||
.join(' && ')
|
||||
return envCommands
|
||||
}
|
||||
}
|
||||
|
||||
// Build command to execute
|
||||
let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"`
|
||||
|
||||
// Add configuration parameters for OpenAI Codex
|
||||
@@ -351,20 +674,20 @@ class CodeToolsService {
|
||||
|
||||
switch (platform) {
|
||||
case 'darwin': {
|
||||
// macOS - Use osascript to launch terminal and execute command directly, without showing startup command
|
||||
// macOS - Support multiple terminals
|
||||
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"
|
||||
do script "${fullCommand.replace(/"/g, '\\"')}"
|
||||
activate
|
||||
end tell`
|
||||
]
|
||||
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}`
|
||||
|
||||
const terminalConfig = await this.getTerminalConfig(options.terminal)
|
||||
logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`)
|
||||
|
||||
const { command: cmd, args } = terminalConfig.command(directory, fullCommand)
|
||||
terminalCommand = cmd
|
||||
terminalArgs = args
|
||||
break
|
||||
}
|
||||
case 'win32': {
|
||||
@@ -424,9 +747,23 @@ end tell`
|
||||
throw new Error(`Failed to create launch script: ${error}`)
|
||||
}
|
||||
|
||||
// Launch bat file - Use safest start syntax, no title parameter
|
||||
terminalCommand = 'cmd'
|
||||
terminalArgs = ['/c', 'start', batFilePath]
|
||||
// Use selected terminal configuration
|
||||
const terminalConfig = await this.getTerminalConfig(options.terminal)
|
||||
logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`)
|
||||
|
||||
// Get command and args from terminal configuration
|
||||
// Pass the bat file path as the command to execute
|
||||
const fullCommand = batFilePath
|
||||
const { command: cmd, args } = terminalConfig.command(directory, fullCommand)
|
||||
|
||||
// Override if it's a custom terminal with a custom path
|
||||
if (terminalConfig.customPath) {
|
||||
terminalCommand = terminalConfig.customPath
|
||||
terminalArgs = args
|
||||
} else {
|
||||
terminalCommand = cmd
|
||||
terminalArgs = args
|
||||
}
|
||||
|
||||
// Set cleanup task (delete temp file after 5 minutes)
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/
|
||||
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { locales } from '../utils/locales'
|
||||
|
||||
@@ -27,7 +28,8 @@ export enum ConfigKeys {
|
||||
SelectionAssistantFilterList = 'selectionAssistantFilterList',
|
||||
DisableHardwareAcceleration = 'disableHardwareAcceleration',
|
||||
Proxy = 'proxy',
|
||||
EnableDeveloperMode = 'enableDeveloperMode'
|
||||
EnableDeveloperMode = 'enableDeveloperMode',
|
||||
ClientId = 'clientId'
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
@@ -241,6 +243,17 @@ export class ConfigManager {
|
||||
this.set(ConfigKeys.EnableDeveloperMode, value)
|
||||
}
|
||||
|
||||
getClientId(): string {
|
||||
let clientId = this.get<string>(ConfigKeys.ClientId)
|
||||
|
||||
if (!clientId) {
|
||||
clientId = uuidv4()
|
||||
this.set(ConfigKeys.ClientId, clientId)
|
||||
}
|
||||
|
||||
return clientId
|
||||
}
|
||||
|
||||
set(key: string, value: unknown, isNotify: boolean = false) {
|
||||
this.store.set(key, value)
|
||||
isNotify && this.notifySubscribers(key, value)
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
import { exec } from 'node:child_process'
|
||||
import { homedir } from 'node:os'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import * as fs from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
|
||||
const logger = loggerService.withContext('OvmsManager')
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
interface OvmsProcess {
|
||||
pid: number
|
||||
path: string
|
||||
workingDirectory: string
|
||||
}
|
||||
|
||||
interface ModelConfig {
|
||||
name: string
|
||||
base_path: string
|
||||
}
|
||||
|
||||
interface OvmsConfig {
|
||||
mediapipe_config_list: ModelConfig[]
|
||||
}
|
||||
|
||||
class OvmsManager {
|
||||
private ovms: OvmsProcess | null = null
|
||||
|
||||
/**
|
||||
* Recursively terminate a process and all its child processes
|
||||
* @param pid Process ID to terminate
|
||||
* @returns Promise<{ success: boolean; message?: string }>
|
||||
*/
|
||||
private async terminalProcess(pid: number): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
// Check if the process is running
|
||||
const processCheckCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
|
||||
const { stdout: processStdout } = await execAsync(`powershell -Command "${processCheckCommand}"`)
|
||||
|
||||
if (!processStdout.trim()) {
|
||||
logger.info(`Process with PID ${pid} is not running`)
|
||||
return { success: true, message: `Process with PID ${pid} is not running` }
|
||||
}
|
||||
|
||||
// Find child processes
|
||||
const childProcessCommand = `Get-WmiObject -Class Win32_Process | Where-Object { $_.ParentProcessId -eq ${pid} } | Select-Object ProcessId | ConvertTo-Json`
|
||||
const { stdout: childStdout } = await execAsync(`powershell -Command "${childProcessCommand}"`)
|
||||
|
||||
// If there are child processes, terminate them first
|
||||
if (childStdout.trim()) {
|
||||
const childProcesses = JSON.parse(childStdout)
|
||||
const childList = Array.isArray(childProcesses) ? childProcesses : [childProcesses]
|
||||
|
||||
logger.info(`Found ${childList.length} child processes for PID ${pid}`)
|
||||
|
||||
// Recursively terminate each child process
|
||||
for (const childProcess of childList) {
|
||||
const childPid = childProcess.ProcessId
|
||||
logger.info(`Terminating child process PID: ${childPid}`)
|
||||
await this.terminalProcess(childPid)
|
||||
}
|
||||
} else {
|
||||
logger.info(`No child processes found for PID ${pid}`)
|
||||
}
|
||||
|
||||
// Finally, terminate the parent process
|
||||
const killCommand = `Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`
|
||||
await execAsync(`powershell -Command "${killCommand}"`)
|
||||
logger.info(`Terminated process with PID: ${pid}`)
|
||||
|
||||
// Wait for the process to disappear with 5-second timeout
|
||||
const timeout = 5000 // 5 seconds
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const checkCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
|
||||
const { stdout: checkStdout } = await execAsync(`powershell -Command "${checkCommand}"`)
|
||||
|
||||
if (!checkStdout.trim()) {
|
||||
logger.info(`Process with PID ${pid} has disappeared`)
|
||||
return { success: true, message: `Process ${pid} and all child processes terminated successfully` }
|
||||
}
|
||||
|
||||
// Wait 300ms before checking again
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
}
|
||||
|
||||
logger.warn(`Process with PID ${pid} did not disappear within timeout`)
|
||||
return { success: false, message: `Process ${pid} did not disappear within 5 seconds` }
|
||||
} catch (error) {
|
||||
logger.error(`Failed to terminate process ${pid}:`, error as Error)
|
||||
return { success: false, message: `Failed to terminate process ${pid}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop OVMS process if it's running
|
||||
* @returns Promise<{ success: boolean; message?: string }>
|
||||
*/
|
||||
public async stopOvms(): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
// Check if OVMS process is running
|
||||
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
logger.info('OVMS process is not running')
|
||||
return { success: true, message: 'OVMS process is not running' }
|
||||
}
|
||||
|
||||
const processes = JSON.parse(stdout)
|
||||
const processList = Array.isArray(processes) ? processes : [processes]
|
||||
|
||||
if (processList.length === 0) {
|
||||
logger.info('OVMS process is not running')
|
||||
return { success: true, message: 'OVMS process is not running' }
|
||||
}
|
||||
|
||||
// Terminate all OVMS processes using terminalProcess
|
||||
for (const process of processList) {
|
||||
const result = await this.terminalProcess(process.Id)
|
||||
if (!result.success) {
|
||||
logger.error(`Failed to terminate OVMS process with PID: ${process.Id}, ${result.message}`)
|
||||
return { success: false, message: `Failed to terminate OVMS process: ${result.message}` }
|
||||
}
|
||||
logger.info(`Terminated OVMS process with PID: ${process.Id}`)
|
||||
}
|
||||
|
||||
// Reset the ovms instance
|
||||
this.ovms = null
|
||||
|
||||
logger.info('OVMS process stopped successfully')
|
||||
return { success: true, message: 'OVMS process stopped successfully' }
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop OVMS process: ${error}`)
|
||||
return { success: false, message: 'Failed to stop OVMS process' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run OVMS by ensuring config.json exists and executing run.bat
|
||||
* @returns Promise<{ success: boolean; message?: string }>
|
||||
*/
|
||||
public async runOvms(): Promise<{ success: boolean; message?: string }> {
|
||||
const homeDir = homedir()
|
||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||
const runBatPath = path.join(ovmsDir, 'run.bat')
|
||||
|
||||
try {
|
||||
// Check if config.json exists, if not create it with default content
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
logger.info(`Config file does not exist, creating: ${configPath}`)
|
||||
|
||||
// Ensure the models directory exists
|
||||
await fs.ensureDir(path.dirname(configPath))
|
||||
|
||||
// Create config.json with default content
|
||||
const defaultConfig = {
|
||||
mediapipe_config_list: [],
|
||||
model_config_list: []
|
||||
}
|
||||
|
||||
await fs.writeJson(configPath, defaultConfig, { spaces: 2 })
|
||||
logger.info(`Config file created: ${configPath}`)
|
||||
}
|
||||
|
||||
// Check if run.bat exists
|
||||
if (!(await fs.pathExists(runBatPath))) {
|
||||
logger.error(`run.bat not found at: ${runBatPath}`)
|
||||
return { success: false, message: 'run.bat not found' }
|
||||
}
|
||||
|
||||
// Run run.bat without waiting for it to complete
|
||||
logger.info(`Starting OVMS with run.bat: ${runBatPath}`)
|
||||
exec(`"${runBatPath}"`, { cwd: ovmsDir }, (error) => {
|
||||
if (error) {
|
||||
logger.error(`Error running run.bat: ${error}`)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('OVMS started successfully')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`Failed to run OVMS: ${error}`)
|
||||
return { success: false, message: 'Failed to run OVMS' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OVMS status - checks installation and running status
|
||||
* @returns 'not-installed' | 'not-running' | 'running'
|
||||
*/
|
||||
public async getOvmsStatus(): Promise<'not-installed' | 'not-running' | 'running'> {
|
||||
const homeDir = homedir()
|
||||
const ovmsPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'ovms.exe')
|
||||
|
||||
try {
|
||||
// Check if OVMS executable exists
|
||||
if (!(await fs.pathExists(ovmsPath))) {
|
||||
logger.info(`OVMS executable not found at: ${ovmsPath}`)
|
||||
return 'not-installed'
|
||||
}
|
||||
|
||||
// Check if OVMS process is running
|
||||
//const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq "${ovmsPath.replace(/\\/g, '\\\\')}" } | Select-Object Id | ConvertTo-Json`;
|
||||
//const { stdout } = await execAsync(`powershell -Command "${psCommand}"`);
|
||||
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
logger.info('OVMS process not running')
|
||||
return 'not-running'
|
||||
}
|
||||
|
||||
const processes = JSON.parse(stdout)
|
||||
const processList = Array.isArray(processes) ? processes : [processes]
|
||||
|
||||
if (processList.length > 0) {
|
||||
logger.info('OVMS process is running')
|
||||
return 'running'
|
||||
} else {
|
||||
logger.info('OVMS process not running')
|
||||
return 'not-running'
|
||||
}
|
||||
} catch (error) {
|
||||
logger.info(`Failed to check OVMS status: ${error}`)
|
||||
return 'not-running'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize OVMS by finding the executable path and working directory
|
||||
*/
|
||||
public async initializeOvms(): Promise<boolean> {
|
||||
// Use PowerShell to find ovms.exe processes with their paths
|
||||
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
logger.error('Command to find OVMS process returned no output')
|
||||
return false
|
||||
}
|
||||
logger.debug(`OVMS process output: ${stdout}`)
|
||||
|
||||
const processes = JSON.parse(stdout)
|
||||
const processList = Array.isArray(processes) ? processes : [processes]
|
||||
|
||||
// Find the first process with a valid path
|
||||
for (const process of processList) {
|
||||
this.ovms = {
|
||||
pid: process.Id,
|
||||
path: process.Path,
|
||||
workingDirectory: path.dirname(process.Path)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return this.ovms !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Model Name and ID are valid, they are valid only if they are not used in the config.json
|
||||
* @param modelName Name of the model to check
|
||||
* @param modelId ID of the model to check
|
||||
*/
|
||||
public async isNameAndIDAvalid(modelName: string, modelId: string): Promise<boolean> {
|
||||
if (!modelName || !modelId) {
|
||||
logger.error('Model name and ID cannot be empty')
|
||||
return false
|
||||
}
|
||||
|
||||
const homeDir = homedir()
|
||||
const configPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'models', 'config.json')
|
||||
try {
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
logger.warn(`Config file does not exist: ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const config: OvmsConfig = await fs.readJson(configPath)
|
||||
if (!config.mediapipe_config_list) {
|
||||
logger.warn(`No mediapipe_config_list found in config: ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the model name or ID already exists in the config
|
||||
const exists = config.mediapipe_config_list.some(
|
||||
(model) => model.name === modelName || model.base_path === modelId
|
||||
)
|
||||
if (exists) {
|
||||
logger.warn(`Model with name "${modelName}" or ID "${modelId}" already exists in the config`)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check model existence: ${error}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async applyModelPath(modelDirPath: string): Promise<boolean> {
|
||||
const homeDir = homedir()
|
||||
const patchDir = path.join(homeDir, '.cherrystudio', 'ovms', 'patch')
|
||||
if (!(await fs.pathExists(patchDir))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const modelId = path.basename(modelDirPath)
|
||||
|
||||
// get all sub directories in patchDir
|
||||
const patchs = await fs.readdir(patchDir)
|
||||
for (const patch of patchs) {
|
||||
const fullPatchPath = path.join(patchDir, patch)
|
||||
|
||||
if (fs.lstatSync(fullPatchPath).isDirectory()) {
|
||||
if (modelId.toLowerCase().includes(patch.toLowerCase())) {
|
||||
// copy all files from fullPath to modelDirPath
|
||||
try {
|
||||
const files = await fs.readdir(fullPatchPath)
|
||||
for (const file of files) {
|
||||
const srcFile = path.join(fullPatchPath, file)
|
||||
const destFile = path.join(modelDirPath, file)
|
||||
await fs.copyFile(srcFile, destFile)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to copy files from ${fullPatchPath} to ${modelDirPath}: ${error}`)
|
||||
return false
|
||||
}
|
||||
logger.info(`Applied patchs for model ${modelId}`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a model to OVMS by downloading it
|
||||
* @param modelName Name of the model to add
|
||||
* @param modelId ID of the model to download
|
||||
* @param modelSource Model Source: huggingface, hf-mirror and modelscope, default is huggingface
|
||||
* @param task Task type: text_generation, embedding, rerank, image_generation
|
||||
*/
|
||||
public async addModel(
|
||||
modelName: string,
|
||||
modelId: string,
|
||||
modelSource: string,
|
||||
task: string = 'text_generation'
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
logger.info(`Adding model: ${modelName} with ID: ${modelId}, Source: ${modelSource}, Task: ${task}`)
|
||||
|
||||
const homeDir = homedir()
|
||||
const ovdndDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||
const pathModel = path.join(ovdndDir, 'models', modelId)
|
||||
|
||||
try {
|
||||
// check the ovdnDir+'models'+modelId exist or not
|
||||
if (await fs.pathExists(pathModel)) {
|
||||
logger.error(`Model with ID ${modelId} already exists`)
|
||||
return { success: false, message: 'Model ID already exists!' }
|
||||
}
|
||||
|
||||
// remove the model directory if it exists
|
||||
if (await fs.pathExists(pathModel)) {
|
||||
logger.info(`Removing existing model directory: ${pathModel}`)
|
||||
await fs.remove(pathModel)
|
||||
}
|
||||
|
||||
// Use ovdnd.exe for downloading instead of ovms.exe
|
||||
const ovdndPath = path.join(ovdndDir, 'ovdnd.exe')
|
||||
const command =
|
||||
`"${ovdndPath}" --pull ` +
|
||||
`--model_repository_path "${ovdndDir}/models" ` +
|
||||
`--source_model "${modelId}" ` +
|
||||
`--model_name "${modelName}" ` +
|
||||
`--target_device GPU ` +
|
||||
`--task ${task} ` +
|
||||
`--overwrite_models`
|
||||
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
OVMS_DIR: ovdndDir,
|
||||
PYTHONHOME: path.join(ovdndDir, 'python'),
|
||||
PATH: `${process.env.PATH};${ovdndDir};${path.join(ovdndDir, 'python')}`
|
||||
}
|
||||
|
||||
if (modelSource) {
|
||||
env.HF_ENDPOINT = modelSource
|
||||
}
|
||||
|
||||
logger.info(`Running command: ${command} from ${modelSource}`)
|
||||
const { stdout } = await execAsync(command, { env: env, cwd: ovdndDir })
|
||||
|
||||
logger.info('Model download completed')
|
||||
logger.debug(`Command output: ${stdout}`)
|
||||
} catch (error) {
|
||||
// remove ovdnDir+'models'+modelId if it exists
|
||||
if (await fs.pathExists(pathModel)) {
|
||||
logger.info(`Removing failed model directory: ${pathModel}`)
|
||||
await fs.remove(pathModel)
|
||||
}
|
||||
logger.error(`Failed to add model: ${error}`)
|
||||
return {
|
||||
success: false,
|
||||
message: `Download model ${modelId} failed, please check following items and try it again:<p>- the model id</p><p>- network connection and proxy</p>`
|
||||
}
|
||||
}
|
||||
|
||||
// Update config file
|
||||
if (!(await this.updateModelConfig(modelName, modelId))) {
|
||||
logger.error('Failed to update model config')
|
||||
return { success: false, message: 'Failed to update model config' }
|
||||
}
|
||||
|
||||
if (!(await this.applyModelPath(pathModel))) {
|
||||
logger.error('Failed to apply model patchs')
|
||||
return { success: false, message: 'Failed to apply model patchs' }
|
||||
}
|
||||
|
||||
logger.info(`Model ${modelName} added successfully with ID ${modelId}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the model download process if it's running
|
||||
* @returns Promise<{ success: boolean; message?: string }>
|
||||
*/
|
||||
public async stopAddModel(): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
// Check if ovdnd.exe process is running
|
||||
const psCommand = `Get-Process -Name "ovdnd" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
logger.info('ovdnd process is not running')
|
||||
return { success: true, message: 'Model download process is not running' }
|
||||
}
|
||||
|
||||
const processes = JSON.parse(stdout)
|
||||
const processList = Array.isArray(processes) ? processes : [processes]
|
||||
|
||||
if (processList.length === 0) {
|
||||
logger.info('ovdnd process is not running')
|
||||
return { success: true, message: 'Model download process is not running' }
|
||||
}
|
||||
|
||||
// Terminate all ovdnd processes
|
||||
for (const process of processList) {
|
||||
this.terminalProcess(process.Id)
|
||||
}
|
||||
|
||||
logger.info('Model download process stopped successfully')
|
||||
return { success: true, message: 'Model download process stopped successfully' }
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop model download process: ${error}`)
|
||||
return { success: false, message: 'Failed to stop model download process' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* check if the model id exists in the OVMS configuration
|
||||
* @param modelId ID of the model to check
|
||||
*/
|
||||
public async checkModelExists(modelId: string): Promise<boolean> {
|
||||
const homeDir = homedir()
|
||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||
|
||||
try {
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
logger.warn(`Config file does not exist: ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const config: OvmsConfig = await fs.readJson(configPath)
|
||||
if (!config.mediapipe_config_list) {
|
||||
logger.warn('No mediapipe_config_list found in config')
|
||||
return false
|
||||
}
|
||||
|
||||
return config.mediapipe_config_list.some((model) => model.base_path === modelId)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check model existence: ${error}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the model configuration file
|
||||
*/
|
||||
public async updateModelConfig(modelName: string, modelId: string): Promise<boolean> {
|
||||
const homeDir = homedir()
|
||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||
|
||||
try {
|
||||
// Ensure the models directory exists
|
||||
await fs.ensureDir(path.dirname(configPath))
|
||||
let config: OvmsConfig
|
||||
|
||||
// Read existing config or create new one
|
||||
if (await fs.pathExists(configPath)) {
|
||||
config = await fs.readJson(configPath)
|
||||
} else {
|
||||
config = { mediapipe_config_list: [] }
|
||||
}
|
||||
|
||||
// Ensure mediapipe_config_list exists
|
||||
if (!config.mediapipe_config_list) {
|
||||
config.mediapipe_config_list = []
|
||||
}
|
||||
|
||||
// Add new model config
|
||||
const newModelConfig: ModelConfig = {
|
||||
name: modelName,
|
||||
base_path: modelId
|
||||
}
|
||||
|
||||
// Check if model already exists, if so, update it
|
||||
const existingIndex = config.mediapipe_config_list.findIndex((model) => model.base_path === modelId)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
config.mediapipe_config_list[existingIndex] = newModelConfig
|
||||
logger.info(`Updated existing model config: ${modelName}`)
|
||||
} else {
|
||||
config.mediapipe_config_list.push(newModelConfig)
|
||||
logger.info(`Added new model config: ${modelName}`)
|
||||
}
|
||||
|
||||
// Write config back to file
|
||||
await fs.writeJson(configPath, config, { spaces: 2 })
|
||||
logger.info(`Config file updated: ${configPath}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update model config: ${error}`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models from OVMS config, filtered for image generation models
|
||||
* @returns Array of model configurations
|
||||
*/
|
||||
public async getModels(): Promise<ModelConfig[]> {
|
||||
const homeDir = homedir()
|
||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||
|
||||
try {
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
logger.warn(`Config file does not exist: ${configPath}`)
|
||||
return []
|
||||
}
|
||||
|
||||
const config: OvmsConfig = await fs.readJson(configPath)
|
||||
if (!config.mediapipe_config_list) {
|
||||
logger.warn('No mediapipe_config_list found in config')
|
||||
return []
|
||||
}
|
||||
|
||||
// Filter models for image generation (SD, Stable-Diffusion, Stable Diffusion, FLUX)
|
||||
const imageGenerationModels = config.mediapipe_config_list.filter((model) => {
|
||||
const modelName = model.name.toLowerCase()
|
||||
return (
|
||||
modelName.startsWith('sd') ||
|
||||
modelName.startsWith('stable-diffusion') ||
|
||||
modelName.startsWith('stable diffusion') ||
|
||||
modelName.startsWith('flux')
|
||||
)
|
||||
})
|
||||
|
||||
logger.info(`Found ${imageGenerationModels.length} image generation models`)
|
||||
return imageGenerationModels
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get models: ${error}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default OvmsManager
|
||||
@@ -256,7 +256,7 @@ export class WindowService {
|
||||
|
||||
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||
if (url.includes('localhost:5173')) {
|
||||
if (url.includes('localhost:517')) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -275,7 +275,8 @@ export class WindowService {
|
||||
'https://aihubmix.com/topup',
|
||||
'https://aihubmix.com/statistics',
|
||||
'https://dash.302.ai/sso/login',
|
||||
'https://dash.302.ai/charge'
|
||||
'https://dash.302.ai/charge',
|
||||
'https://www.aiionly.com/login'
|
||||
]
|
||||
|
||||
if (oauthProviderUrls.some((link) => url.startsWith(link))) {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../ConfigManager', () => ({
|
||||
configManager: {
|
||||
getLanguage: vi.fn(),
|
||||
getAutoUpdate: vi.fn(() => false),
|
||||
getTestPlan: vi.fn(() => false),
|
||||
getTestChannel: vi.fn(),
|
||||
getClientId: vi.fn(() => 'test-client-id')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../WindowService', () => ({
|
||||
windowService: {
|
||||
getMainWindow: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@main/constant', () => ({
|
||||
isWin: false
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/ipService', () => ({
|
||||
getIpCountry: vi.fn(() => 'US')
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/locales', () => ({
|
||||
locales: {
|
||||
en: { translation: { update: {} } },
|
||||
'zh-CN': { translation: { update: {} } }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/systemInfo', () => ({
|
||||
generateUserAgent: vi.fn(() => 'test-user-agent')
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: true,
|
||||
getVersion: vi.fn(() => '1.0.0'),
|
||||
getPath: vi.fn(() => '/test/path')
|
||||
},
|
||||
dialog: {
|
||||
showMessageBox: vi.fn()
|
||||
},
|
||||
BrowserWindow: vi.fn(),
|
||||
net: {
|
||||
fetch: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron-updater', () => ({
|
||||
autoUpdater: {
|
||||
logger: null,
|
||||
forceDevUpdateConfig: false,
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
requestHeaders: {},
|
||||
on: vi.fn(),
|
||||
setFeedURL: vi.fn(),
|
||||
checkForUpdates: vi.fn(),
|
||||
downloadUpdate: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
channel: '',
|
||||
allowDowngrade: false,
|
||||
disableDifferentialDownload: false,
|
||||
currentVersion: '1.0.0'
|
||||
},
|
||||
Logger: vi.fn(),
|
||||
NsisUpdater: vi.fn(),
|
||||
AppUpdater: vi.fn()
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import AppUpdater from '../AppUpdater'
|
||||
import { configManager } from '../ConfigManager'
|
||||
|
||||
describe('AppUpdater', () => {
|
||||
let appUpdater: AppUpdater
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appUpdater = new AppUpdater()
|
||||
})
|
||||
|
||||
describe('parseMultiLangReleaseNotes', () => {
|
||||
const sampleReleaseNotes = `<!--LANG:en-->
|
||||
🚀 New Features:
|
||||
- Feature A
|
||||
- Feature B
|
||||
|
||||
🎨 UI Improvements:
|
||||
- Improvement A
|
||||
<!--LANG:zh-CN-->
|
||||
🚀 新功能:
|
||||
- 功能 A
|
||||
- 功能 B
|
||||
|
||||
🎨 界面改进:
|
||||
- 改进 A
|
||||
<!--LANG:END-->`
|
||||
|
||||
it('should return Chinese notes for zh-CN users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('新功能')
|
||||
expect(result).toContain('功能 A')
|
||||
expect(result).not.toContain('New Features')
|
||||
})
|
||||
|
||||
it('should return Chinese notes for zh-TW users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-TW')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('新功能')
|
||||
expect(result).toContain('功能 A')
|
||||
expect(result).not.toContain('New Features')
|
||||
})
|
||||
|
||||
it('should return English notes for non-Chinese users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('New Features')
|
||||
expect(result).toContain('Feature A')
|
||||
expect(result).not.toContain('新功能')
|
||||
})
|
||||
|
||||
it('should return English notes for other language users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('ru-RU')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('New Features')
|
||||
expect(result).not.toContain('新功能')
|
||||
})
|
||||
|
||||
it('should handle missing language sections gracefully', () => {
|
||||
const malformedNotes = 'Simple release notes without markers'
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
|
||||
|
||||
expect(result).toBe('Simple release notes without markers')
|
||||
})
|
||||
|
||||
it('should handle malformed markers', () => {
|
||||
const malformedNotes = `<!--LANG:en-->English only`
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
|
||||
|
||||
// Should clean up markers and return cleaned content
|
||||
expect(result).toContain('English only')
|
||||
expect(result).not.toContain('<!--LANG:')
|
||||
})
|
||||
|
||||
it('should handle empty release notes', () => {
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes('')
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
// Force an error by mocking configManager to throw
|
||||
vi.mocked(configManager.getLanguage).mockImplementation(() => {
|
||||
throw new Error('Test error')
|
||||
})
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
// Should return original notes as fallback
|
||||
expect(result).toBe(sampleReleaseNotes)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasMultiLanguageMarkers', () => {
|
||||
it('should return true when markers are present', () => {
|
||||
const notes = '<!--LANG:en-->Test'
|
||||
|
||||
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when no markers are present', () => {
|
||||
const notes = 'Simple text without markers'
|
||||
|
||||
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('processReleaseInfo', () => {
|
||||
it('should process multi-language release notes in string format', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: `<!--LANG:en-->English notes<!--LANG:zh-CN-->中文说明<!--LANG:END-->`
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toBe('中文说明')
|
||||
})
|
||||
|
||||
it('should not process release notes without markers', () => {
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: 'Simple release notes'
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toBe('Simple release notes')
|
||||
})
|
||||
|
||||
it('should handle array format release notes', () => {
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: [
|
||||
{ version: '1.0.0', note: 'Note 1' },
|
||||
{ version: '1.0.1', note: 'Note 2' }
|
||||
]
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toEqual(releaseInfo.releaseNotes)
|
||||
})
|
||||
|
||||
it('should handle null release notes', () => {
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: null
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatReleaseNotes', () => {
|
||||
it('should format string release notes with markers', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
||||
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('English')
|
||||
})
|
||||
|
||||
it('should format string release notes without markers', () => {
|
||||
const notes = 'Simple notes'
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Simple notes')
|
||||
})
|
||||
|
||||
it('should format array release notes', () => {
|
||||
const notes = [
|
||||
{ version: '1.0.0', note: 'Note 1' },
|
||||
{ version: '1.0.1', note: 'Note 2' }
|
||||
]
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Note 1\nNote 2')
|
||||
})
|
||||
|
||||
it('should handle null release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(null)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle undefined release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(undefined)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import * as fs from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
@@ -264,11 +265,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
||||
|
||||
if (entry.isDirectory() && options.includeDirectories) {
|
||||
const stats = await fs.promises.stat(entryPath)
|
||||
const externalDirPath = entryPath.replace(/\\/g, '/')
|
||||
const dirTreeNode: NotesTreeNode = {
|
||||
id: uuidv4(),
|
||||
id: createHash('sha1').update(externalDirPath).digest('hex'),
|
||||
name: entry.name,
|
||||
treePath: treePath,
|
||||
externalPath: entryPath,
|
||||
externalPath: externalDirPath,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
type: 'folder',
|
||||
@@ -299,11 +301,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
||||
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
|
||||
: `/${nameWithoutExt}`
|
||||
|
||||
const externalFilePath = entryPath.replace(/\\/g, '/')
|
||||
const fileTreeNode: NotesTreeNode = {
|
||||
id: uuidv4(),
|
||||
id: createHash('sha1').update(externalFilePath).digest('hex'),
|
||||
name: name,
|
||||
treePath: fileTreePath,
|
||||
externalPath: entryPath,
|
||||
externalPath: externalFilePath,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
type: 'file'
|
||||
|
||||
+27
-6
@@ -1,7 +1,7 @@
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import { SpanContext } from '@opentelemetry/api'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
|
||||
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
|
||||
import type { FileChangeEvent } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
@@ -47,6 +47,7 @@ const api = {
|
||||
getDiskInfo: (directoryPath: string): Promise<{ free: number; size: number } | null> =>
|
||||
ipcRenderer.invoke(IpcChannel.App_GetDiskInfo, directoryPath),
|
||||
reload: () => ipcRenderer.invoke(IpcChannel.App_Reload),
|
||||
quit: () => ipcRenderer.invoke(IpcChannel.App_Quit),
|
||||
setProxy: (proxy: string | undefined, bypassRules?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
|
||||
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
||||
@@ -94,7 +95,8 @@ const api = {
|
||||
},
|
||||
system: {
|
||||
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
|
||||
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
|
||||
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname),
|
||||
getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName)
|
||||
},
|
||||
devTools: {
|
||||
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
|
||||
@@ -284,6 +286,16 @@ const api = {
|
||||
clearAuthCache: (projectId: string, clientEmail?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
|
||||
},
|
||||
ovms: {
|
||||
addModel: (modelName: string, modelId: string, modelSource: string, task: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Ovms_AddModel, modelName, modelId, modelSource, task),
|
||||
stopAddModel: () => ipcRenderer.invoke(IpcChannel.Ovms_StopAddModel),
|
||||
getModels: () => ipcRenderer.invoke(IpcChannel.Ovms_GetModels),
|
||||
isRunning: () => ipcRenderer.invoke(IpcChannel.Ovms_IsRunning),
|
||||
getStatus: () => ipcRenderer.invoke(IpcChannel.Ovms_GetStatus),
|
||||
runOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_RunOVMS),
|
||||
stopOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_StopOVMS)
|
||||
},
|
||||
config: {
|
||||
set: (key: string, value: any, isNotify: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
|
||||
@@ -349,6 +361,7 @@ const api = {
|
||||
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
|
||||
installUVBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallUvBinary),
|
||||
installBunBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallBunBinary),
|
||||
installOvmsBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallOvmsBinary),
|
||||
protocol: {
|
||||
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
|
||||
@@ -439,16 +452,24 @@ const api = {
|
||||
model: string,
|
||||
directory: string,
|
||||
env: Record<string, string>,
|
||||
options?: { autoUpdateToLatest?: boolean }
|
||||
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options)
|
||||
options?: { autoUpdateToLatest?: boolean; terminal?: string }
|
||||
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options),
|
||||
getAvailableTerminals: (): Promise<TerminalConfig[]> =>
|
||||
ipcRenderer.invoke(IpcChannel.CodeTools_GetAvailableTerminals),
|
||||
setCustomTerminalPath: (terminalId: string, path: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannel.CodeTools_SetCustomTerminalPath, terminalId, path),
|
||||
getCustomTerminalPath: (terminalId: string): Promise<string | undefined> =>
|
||||
ipcRenderer.invoke(IpcChannel.CodeTools_GetCustomTerminalPath, terminalId),
|
||||
removeCustomTerminalPath: (terminalId: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannel.CodeTools_RemoveCustomTerminalPath, terminalId)
|
||||
},
|
||||
ocr: {
|
||||
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
|
||||
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
|
||||
},
|
||||
cherryin: {
|
||||
cherryai: {
|
||||
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Cherryin_GetSignature, params)
|
||||
ipcRenderer.invoke(IpcChannel.Cherryai_GetSignature, params)
|
||||
},
|
||||
windowControls: {
|
||||
minimize: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Windows_Minimize),
|
||||
|
||||
@@ -13,16 +13,6 @@ import { ToolCallChunkHandler } from './handleToolCallChunk'
|
||||
|
||||
const logger = loggerService.withContext('AiSdkToChunkAdapter')
|
||||
|
||||
export interface CherryStudioChunk {
|
||||
type: 'text-delta' | 'text-complete' | 'tool-call' | 'tool-result' | 'finish' | 'error'
|
||||
text?: string
|
||||
toolCall?: any
|
||||
toolResult?: any
|
||||
finishReason?: string
|
||||
usage?: any
|
||||
error?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* AI SDK 到 Cherry Studio Chunk 适配器类
|
||||
* 处理 fullStream 到 Cherry Studio chunk 的转换
|
||||
@@ -173,15 +163,13 @@ export class AiSdkToChunkAdapter {
|
||||
final.reasoningContent += chunk.text || ''
|
||||
this.onChunk({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: final.reasoningContent || '',
|
||||
thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0
|
||||
text: final.reasoningContent || ''
|
||||
})
|
||||
break
|
||||
case 'reasoning-end':
|
||||
this.onChunk({
|
||||
type: ChunkType.THINKING_COMPLETE,
|
||||
text: (chunk.providerMetadata?.metadata?.thinking_content as string) || '',
|
||||
thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0
|
||||
text: final.reasoningContent || ''
|
||||
})
|
||||
final.reasoningContent = ''
|
||||
break
|
||||
|
||||
@@ -298,8 +298,29 @@ export class ToolCallChunkHandler {
|
||||
type: ChunkType.MCP_TOOL_COMPLETE,
|
||||
responses: [toolResponse]
|
||||
})
|
||||
|
||||
const images: string[] = []
|
||||
for (const content of toolResponse.response?.content || []) {
|
||||
if (content.type === 'image' && content.data) {
|
||||
images.push(`data:${content.mimeType};base64,${content.data}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (images.length) {
|
||||
this.onChunk({
|
||||
type: ChunkType.IMAGE_CREATED
|
||||
})
|
||||
this.onChunk({
|
||||
type: ChunkType.IMAGE_COMPLETE,
|
||||
image: {
|
||||
type: 'base64',
|
||||
images: images
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleToolError(
|
||||
chunk: {
|
||||
type: 'tool-error'
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isNewApiProvider } from '@renderer/config/providers'
|
||||
import { Provider } from '@renderer/types'
|
||||
|
||||
import { AihubmixAPIClient } from './aihubmix/AihubmixAPIClient'
|
||||
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
|
||||
import { AwsBedrockAPIClient } from './aws/AwsBedrockAPIClient'
|
||||
import { BaseApiClient } from './BaseApiClient'
|
||||
import { CherryinAPIClient } from './cherryin/CherryinAPIClient'
|
||||
import { CherryAiAPIClient } from './cherryai/CherryAiAPIClient'
|
||||
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
|
||||
import { VertexAPIClient } from './gemini/VertexAPIClient'
|
||||
import { NewAPIClient } from './newapi/NewAPIClient'
|
||||
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
|
||||
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
|
||||
import { OVMSClient } from './ovms/OVMSClient'
|
||||
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
|
||||
import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient'
|
||||
|
||||
@@ -34,8 +36,8 @@ export class ApiClientFactory {
|
||||
let instance: BaseApiClient
|
||||
|
||||
// 首先检查特殊的 Provider ID
|
||||
if (provider.id === 'cherryin') {
|
||||
instance = new CherryinAPIClient(provider) as BaseApiClient
|
||||
if (provider.id === 'cherryai') {
|
||||
instance = new CherryAiAPIClient(provider) as BaseApiClient
|
||||
return instance
|
||||
}
|
||||
|
||||
@@ -45,7 +47,7 @@ export class ApiClientFactory {
|
||||
return instance
|
||||
}
|
||||
|
||||
if (provider.id === 'new-api') {
|
||||
if (isNewApiProvider(provider)) {
|
||||
logger.debug(`Creating NewAPIClient for provider: ${provider.id}`)
|
||||
instance = new NewAPIClient(provider) as BaseApiClient
|
||||
return instance
|
||||
@@ -62,6 +64,12 @@ export class ApiClientFactory {
|
||||
return instance
|
||||
}
|
||||
|
||||
if (provider.id === 'ovms') {
|
||||
logger.debug(`Creating OVMSClient for provider: ${provider.id}`)
|
||||
instance = new OVMSClient(provider) as BaseApiClient
|
||||
return instance
|
||||
}
|
||||
|
||||
// 然后检查标准的 Provider Type
|
||||
switch (provider.type) {
|
||||
case 'openai':
|
||||
|
||||
@@ -67,7 +67,9 @@ vi.mock('@renderer/config/models', () => ({
|
||||
silicon: [],
|
||||
defaultModel: []
|
||||
},
|
||||
isOpenAIModel: vi.fn(() => false)
|
||||
isOpenAIModel: vi.fn(() => false),
|
||||
glm45FlashModel: {},
|
||||
qwen38bModel: {}
|
||||
}))
|
||||
|
||||
describe('ApiClientFactory', () => {
|
||||
|
||||
+2
-6
@@ -35,12 +35,8 @@ vi.mock('@renderer/config/models', () => ({
|
||||
findTokenLimit: vi.fn().mockReturnValue(4096),
|
||||
isFunctionCallingModel: vi.fn().mockReturnValue(false),
|
||||
DEFAULT_MAX_TOKENS: 4096,
|
||||
glm45FlashModel: {
|
||||
id: 'glm-4.5-flash',
|
||||
name: 'GLM-4.5-Flash',
|
||||
provider: 'cherryin',
|
||||
group: 'GLM-4.5'
|
||||
}
|
||||
qwen38bModel: {},
|
||||
glm45FlashModel: {}
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/services/AssistantService', () => ({
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ import OpenAI from 'openai'
|
||||
|
||||
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
|
||||
|
||||
export class CherryinAPIClient extends OpenAIAPIClient {
|
||||
export class CherryAiAPIClient extends OpenAIAPIClient {
|
||||
constructor(provider: Provider) {
|
||||
super(provider)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export class CherryinAPIClient extends OpenAIAPIClient {
|
||||
options = options || {}
|
||||
options.headers = options.headers || {}
|
||||
|
||||
const signature = await window.api.cherryin.generateSignature({
|
||||
const signature = await window.api.cherryai.generateSignature({
|
||||
method: 'POST',
|
||||
path: '/chat/completions',
|
||||
query: '',
|
||||
@@ -34,7 +34,7 @@ export class CherryinAPIClient extends OpenAIAPIClient {
|
||||
}
|
||||
|
||||
override getClientCompatibilityType(): string[] {
|
||||
return ['CherryinAPIClient']
|
||||
return ['CherryAiAPIClient']
|
||||
}
|
||||
|
||||
public async listModels(): Promise<OpenAI.Models.Model[]> {
|
||||
@@ -43,7 +43,7 @@ export class CherryinAPIClient extends OpenAIAPIClient {
|
||||
const created = Date.now()
|
||||
return models.map((id) => ({
|
||||
id,
|
||||
owned_by: 'cherryin',
|
||||
owned_by: 'cherryai',
|
||||
object: 'model' as const,
|
||||
created
|
||||
}))
|
||||
@@ -0,0 +1,56 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isSupportedModel } from '@renderer/config/models'
|
||||
import { objectKeys, Provider } from '@renderer/types'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
|
||||
|
||||
const logger = loggerService.withContext('OVMSClient')
|
||||
|
||||
export class OVMSClient extends OpenAIAPIClient {
|
||||
constructor(provider: Provider) {
|
||||
super(provider)
|
||||
}
|
||||
|
||||
override async listModels(): Promise<OpenAI.Models.Model[]> {
|
||||
try {
|
||||
const sdk = await this.getSdkInstance()
|
||||
|
||||
const chatModelsResponse = await sdk.request({
|
||||
method: 'get',
|
||||
path: '../v1/config'
|
||||
})
|
||||
logger.debug(`Chat models response: ${JSON.stringify(chatModelsResponse)}`)
|
||||
|
||||
// Parse the config response to extract model information
|
||||
const config = chatModelsResponse as Record<string, any>
|
||||
const models = objectKeys(config)
|
||||
.map((modelName) => {
|
||||
const modelInfo = config[modelName]
|
||||
|
||||
// Check if model has at least one version with "AVAILABLE" state
|
||||
const hasAvailableVersion = modelInfo?.model_version_status?.some(
|
||||
(versionStatus: any) => versionStatus?.state === 'AVAILABLE'
|
||||
)
|
||||
|
||||
if (hasAvailableVersion) {
|
||||
return {
|
||||
id: modelName,
|
||||
object: 'model' as const,
|
||||
owned_by: 'ovms',
|
||||
created: Date.now()
|
||||
}
|
||||
}
|
||||
return null // Skip models without available versions
|
||||
})
|
||||
.filter(Boolean) // Remove null entries
|
||||
logger.debug(`Processed models: ${JSON.stringify(models)}`)
|
||||
|
||||
// Filter out unsupported models
|
||||
return models.filter((model): model is OpenAI.Models.Model => model !== null && isSupportedModel(model))
|
||||
} catch (error) {
|
||||
logger.error(`Error listing OVMS models: ${error}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isZhipuModel } from '@renderer/config/models'
|
||||
import store from '@renderer/store'
|
||||
import { getStoreProviders } from '@renderer/hooks/useStore'
|
||||
import { Chunk } from '@renderer/types/chunk'
|
||||
|
||||
import { CompletionsParams, CompletionsResult } from '../schemas'
|
||||
@@ -87,7 +87,7 @@ function handleError(error: any, params: CompletionsParams): any {
|
||||
* 2. 绘画功能(enableGenerateImage为true)使用通用错误处理
|
||||
*/
|
||||
function handleZhipuError(error: any): any {
|
||||
const provider = store.getState().llm.providers.find((p) => p.id === 'zhipu')
|
||||
const provider = getStoreProviders().find((p) => p.id === 'zhipu')
|
||||
const logger = loggerService.withContext('handleZhipuError')
|
||||
|
||||
// 定义错误模式映射
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
|
||||
import { loggerService } from '@logger'
|
||||
import type { MCPTool, Message, Model, Provider } from '@renderer/types'
|
||||
import type { Chunk } from '@renderer/types/chunk'
|
||||
@@ -26,6 +27,8 @@ export interface AiSdkMiddlewareConfig {
|
||||
enableUrlContext: boolean
|
||||
mcpTools?: MCPTool[]
|
||||
uiMessages?: Message[]
|
||||
// 内置搜索配置
|
||||
webSearchPluginConfig?: WebSearchPluginConfig
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,7 +140,19 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
const tagNameArray = ['think', 'thought']
|
||||
const tagName = {
|
||||
reasoning: 'reasoning',
|
||||
think: 'think',
|
||||
thought: 'thought',
|
||||
seedThink: 'seed:think'
|
||||
}
|
||||
|
||||
function getReasoningTagName(modelId: string | undefined): string {
|
||||
if (modelId?.includes('gpt-oss')) return tagName.reasoning
|
||||
if (modelId?.includes('gemini')) return tagName.thought
|
||||
if (modelId?.includes('seed-oss-36b')) return tagName.seedThink
|
||||
return tagName.think
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加provider特定的中间件
|
||||
@@ -153,7 +168,7 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
|
||||
case 'openai':
|
||||
case 'azure-openai': {
|
||||
if (config.enableReasoning) {
|
||||
const tagName = config.model?.id.includes('gemini') ? tagNameArray[1] : tagNameArray[0]
|
||||
const tagName = getReasoningTagName(config.model?.id.toLowerCase())
|
||||
builder.add({
|
||||
name: 'thinking-tag-extraction',
|
||||
middleware: extractReasoningMiddleware({ tagName })
|
||||
@@ -164,6 +179,9 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
|
||||
case 'gemini':
|
||||
// Gemini特定中间件
|
||||
break
|
||||
case 'aws-bedrock': {
|
||||
break
|
||||
}
|
||||
default:
|
||||
// 其他provider的通用处理
|
||||
break
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
|
||||
import { Assistant } from '@renderer/types'
|
||||
|
||||
import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder'
|
||||
import reasoningTimePlugin from './reasoningTimePlugin'
|
||||
import { searchOrchestrationPlugin } from './searchOrchestrationPlugin'
|
||||
import { createTelemetryPlugin } from './telemetryPlugin'
|
||||
|
||||
@@ -30,9 +29,8 @@ export function buildPlugins(
|
||||
}
|
||||
|
||||
// 1. 模型内置搜索
|
||||
if (middlewareConfig.enableWebSearch) {
|
||||
// 内置了默认搜索参数,如果改的话可以传config进去
|
||||
plugins.push(webSearchPlugin())
|
||||
if (middlewareConfig.enableWebSearch && middlewareConfig.webSearchPluginConfig) {
|
||||
plugins.push(webSearchPlugin(middlewareConfig.webSearchPluginConfig))
|
||||
}
|
||||
// 2. 支持工具调用时添加搜索插件
|
||||
if (middlewareConfig.isSupportedToolUse || middlewareConfig.isPromptToolUse) {
|
||||
@@ -40,9 +38,9 @@ export function buildPlugins(
|
||||
}
|
||||
|
||||
// 3. 推理模型时添加推理插件
|
||||
if (middlewareConfig.enableReasoning) {
|
||||
plugins.push(reasoningTimePlugin)
|
||||
}
|
||||
// if (middlewareConfig.enableReasoning) {
|
||||
// plugins.push(reasoningTimePlugin)
|
||||
// }
|
||||
|
||||
// 4. 启用Prompt工具调用时添加工具插件
|
||||
if (middlewareConfig.isPromptToolUse) {
|
||||
|
||||
@@ -7,18 +7,14 @@ export default definePlugin({
|
||||
transformStream: () => () => {
|
||||
// === 时间跟踪状态 ===
|
||||
let thinkingStartTime = 0
|
||||
let hasStartedThinking = false
|
||||
let accumulatedThinkingContent = ''
|
||||
let reasoningBlockId = ''
|
||||
|
||||
return new TransformStream<TextStreamPart<ToolSet>, TextStreamPart<ToolSet>>({
|
||||
transform(chunk: TextStreamPart<ToolSet>, controller: TransformStreamDefaultController<TextStreamPart<ToolSet>>) {
|
||||
// === 处理 reasoning 类型 ===
|
||||
if (chunk.type === 'reasoning-start') {
|
||||
controller.enqueue(chunk)
|
||||
hasStartedThinking = true
|
||||
thinkingStartTime = performance.now()
|
||||
reasoningBlockId = chunk.id
|
||||
} else if (chunk.type === 'reasoning-delta') {
|
||||
accumulatedThinkingContent += chunk.text
|
||||
controller.enqueue({
|
||||
@@ -32,21 +28,6 @@ export default definePlugin({
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (chunk.type === 'reasoning-end' && hasStartedThinking) {
|
||||
controller.enqueue({
|
||||
type: 'reasoning-end',
|
||||
id: reasoningBlockId,
|
||||
providerMetadata: {
|
||||
metadata: {
|
||||
thinking_millsec: performance.now() - thinkingStartTime,
|
||||
thinking_content: accumulatedThinkingContent
|
||||
}
|
||||
}
|
||||
})
|
||||
accumulatedThinkingContent = ''
|
||||
hasStartedThinking = false
|
||||
thinkingStartTime = 0
|
||||
reasoningBlockId = ''
|
||||
} else {
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge'
|
||||
import { vertex } from '@ai-sdk/google-vertex/edge'
|
||||
import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
|
||||
import { isBaseProvider } from '@cherrystudio/ai-core/core/providers/schemas'
|
||||
import { loggerService } from '@logger'
|
||||
import {
|
||||
isGenerateImageModel,
|
||||
@@ -16,8 +18,11 @@ import {
|
||||
isWebSearchModel
|
||||
} from '@renderer/config/models'
|
||||
import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import store from '@renderer/store'
|
||||
import { CherryWebSearchConfig } from '@renderer/store/websearch'
|
||||
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
|
||||
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
||||
import type { ModelMessage, Tool } from 'ai'
|
||||
import { stepCountIs } from 'ai'
|
||||
|
||||
@@ -25,6 +30,7 @@ import { getAiSdkProviderId } from '../provider/factory'
|
||||
import { setupToolsConfig } from '../utils/mcp'
|
||||
import { buildProviderOptions } from '../utils/options'
|
||||
import { getAnthropicThinkingBudget } from '../utils/reasoning'
|
||||
import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch'
|
||||
import { getTemperature, getTopP } from './modelParameters'
|
||||
|
||||
const logger = loggerService.withContext('parameterBuilder')
|
||||
@@ -42,6 +48,7 @@ export async function buildStreamTextParams(
|
||||
options: {
|
||||
mcpTools?: MCPTool[]
|
||||
webSearchProviderId?: string
|
||||
webSearchConfig?: CherryWebSearchConfig
|
||||
requestOptions?: {
|
||||
signal?: AbortSignal
|
||||
timeout?: number
|
||||
@@ -57,6 +64,7 @@ export async function buildStreamTextParams(
|
||||
enableGenerateImage: boolean
|
||||
enableUrlContext: boolean
|
||||
}
|
||||
webSearchPluginConfig?: WebSearchPluginConfig
|
||||
}> {
|
||||
const { mcpTools } = options
|
||||
|
||||
@@ -93,6 +101,12 @@ export async function buildStreamTextParams(
|
||||
// }
|
||||
|
||||
// 构建真正的 providerOptions
|
||||
const webSearchConfig: CherryWebSearchConfig = {
|
||||
maxResults: store.getState().websearch.maxResults,
|
||||
excludeDomains: store.getState().websearch.excludeDomains,
|
||||
searchWithTime: store.getState().websearch.searchWithTime
|
||||
}
|
||||
|
||||
const providerOptions = buildProviderOptions(assistant, model, provider, {
|
||||
enableReasoning,
|
||||
enableWebSearch,
|
||||
@@ -109,15 +123,22 @@ export async function buildStreamTextParams(
|
||||
maxTokens -= getAnthropicThinkingBudget(assistant, model)
|
||||
}
|
||||
|
||||
// google-vertex | google-vertex-anthropic
|
||||
let webSearchPluginConfig: WebSearchPluginConfig | undefined = undefined
|
||||
if (enableWebSearch) {
|
||||
if (isBaseProvider(aiSdkProviderId)) {
|
||||
webSearchPluginConfig = buildProviderBuiltinWebSearchConfig(aiSdkProviderId, webSearchConfig)
|
||||
}
|
||||
if (!tools) {
|
||||
tools = {}
|
||||
}
|
||||
if (aiSdkProviderId === 'google-vertex') {
|
||||
tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool
|
||||
} else if (aiSdkProviderId === 'google-vertex-anthropic') {
|
||||
tools.web_search = vertexAnthropic.tools.webSearch_20250305({}) as ProviderDefinedTool
|
||||
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
tools.web_search = vertexAnthropic.tools.webSearch_20250305({
|
||||
maxUses: webSearchConfig.maxResults,
|
||||
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
|
||||
}) as ProviderDefinedTool
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +172,8 @@ export async function buildStreamTextParams(
|
||||
return {
|
||||
params,
|
||||
modelId: model.id,
|
||||
capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext }
|
||||
capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext },
|
||||
webSearchPluginConfig
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ export function getAiSdkProviderId(provider: Provider): ProviderId | 'openai-com
|
||||
return resolvedFromType
|
||||
}
|
||||
}
|
||||
if (provider.apiHost.includes('api.openai.com')) {
|
||||
return 'openai-chat'
|
||||
}
|
||||
// 3. 最后的fallback(通常会成为openai-compatible)
|
||||
return provider.id as ProviderId
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ProviderSettingsMap
|
||||
} from '@cherrystudio/ai-core/provider'
|
||||
import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models'
|
||||
import { isNewApiProvider } from '@renderer/config/providers'
|
||||
import {
|
||||
getAwsBedrockAccessKeyId,
|
||||
getAwsBedrockRegion,
|
||||
@@ -17,7 +18,7 @@ import { loggerService } from '@renderer/services/LoggerService'
|
||||
import store from '@renderer/store'
|
||||
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
|
||||
import { formatApiHost } from '@renderer/utils/api'
|
||||
import { cloneDeep, isEmpty } from 'lodash'
|
||||
import { cloneDeep, trim } from 'lodash'
|
||||
|
||||
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
|
||||
import { getAiSdkProviderId } from './factory'
|
||||
@@ -65,7 +66,7 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
||||
if (provider.id === 'aihubmix') {
|
||||
return aihubmixProviderCreator(model, provider)
|
||||
}
|
||||
if (provider.id === 'new-api') {
|
||||
if (isNewApiProvider(provider)) {
|
||||
return newApiResolverCreator(model, provider)
|
||||
}
|
||||
if (provider.id === 'vertexai') {
|
||||
@@ -119,7 +120,7 @@ export function providerToAiSdkConfig(
|
||||
|
||||
// 构建基础配置
|
||||
const baseConfig = {
|
||||
baseURL: actualProvider.apiHost,
|
||||
baseURL: trim(actualProvider.apiHost),
|
||||
apiKey: getRotatedApiKey(actualProvider)
|
||||
}
|
||||
// 处理OpenAI模式
|
||||
@@ -194,7 +195,10 @@ export function providerToAiSdkConfig(
|
||||
} else if (baseConfig.baseURL.endsWith('/v1')) {
|
||||
baseConfig.baseURL = baseConfig.baseURL.slice(0, -3)
|
||||
}
|
||||
baseConfig.baseURL = isEmpty(baseConfig.baseURL) ? '' : baseConfig.baseURL
|
||||
|
||||
if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) {
|
||||
baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google`
|
||||
}
|
||||
}
|
||||
|
||||
// 如果AI SDK支持该provider,使用原生配置
|
||||
@@ -213,7 +217,8 @@ export function providerToAiSdkConfig(
|
||||
options: {
|
||||
...options,
|
||||
name: actualProvider.id,
|
||||
...extraOptions
|
||||
...extraOptions,
|
||||
includeUsage: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,10 +254,10 @@ export async function prepareSpecialProviderConfig(
|
||||
config.options.apiKey = token
|
||||
break
|
||||
}
|
||||
case 'cherryin': {
|
||||
case 'cherryai': {
|
||||
config.options.fetch = async (url, options) => {
|
||||
// 在这里对最终参数进行签名
|
||||
const signature = await window.api.cherryin.generateSignature({
|
||||
const signature = await window.api.cherryai.generateSignature({
|
||||
method: 'POST',
|
||||
path: '/chat/completions',
|
||||
query: '',
|
||||
|
||||
@@ -18,12 +18,13 @@ export const knowledgeSearchTool = (
|
||||
) => {
|
||||
return tool({
|
||||
name: 'builtin_knowledge_search',
|
||||
description: `Search the knowledge base for relevant information using pre-analyzed search intent.
|
||||
description: `Knowledge base search tool for retrieving information from user's private knowledge base. This searches your local collection of documents, web content, notes, and other materials you have stored.
|
||||
|
||||
Pre-extracted search queries: "${extractedKeywords.question.join(', ')}"
|
||||
Rewritten query: "${extractedKeywords.rewrite}"
|
||||
This tool has been configured with search parameters based on the conversation context:
|
||||
- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')}
|
||||
- Query rewrite: "${extractedKeywords.rewrite}"
|
||||
|
||||
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
|
||||
You can use this tool as-is, or provide additionalContext to refine the search focus within the knowledge base.`,
|
||||
|
||||
inputSchema: z.object({
|
||||
additionalContext: z
|
||||
|
||||
@@ -21,16 +21,17 @@ export const webSearchToolWithPreExtractedKeywords = (
|
||||
|
||||
return tool({
|
||||
name: 'builtin_web_search',
|
||||
description: `Search the web and return citable sources using pre-analyzed search intent.
|
||||
description: `Web search tool for finding current information, news, and real-time data from the internet.
|
||||
|
||||
Pre-extracted search keywords: "${extractedKeywords.question.join(', ')}"${
|
||||
extractedKeywords.links
|
||||
This tool has been configured with search parameters based on the conversation context:
|
||||
- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')}${
|
||||
extractedKeywords.links?.length
|
||||
? `
|
||||
Relevant links: ${extractedKeywords.links.join(', ')}`
|
||||
- Relevant URLs: ${extractedKeywords.links.join(', ')}`
|
||||
: ''
|
||||
}
|
||||
|
||||
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
|
||||
You can use this tool as-is to search with the prepared queries, or provide additionalContext to refine or replace the search terms.`,
|
||||
|
||||
inputSchema: z.object({
|
||||
additionalContext: z
|
||||
|
||||
@@ -82,7 +82,9 @@ export function buildProviderOptions(
|
||||
// 应该覆盖所有类型
|
||||
switch (baseProviderId) {
|
||||
case 'openai':
|
||||
case 'openai-chat':
|
||||
case 'azure':
|
||||
case 'azure-responses':
|
||||
providerSpecificOptions = {
|
||||
...buildOpenAIProviderOptions(assistant, model, capabilities),
|
||||
serviceTier: serviceTierSetting
|
||||
@@ -101,13 +103,15 @@ export function buildProviderOptions(
|
||||
providerSpecificOptions = buildXAIProviderOptions(assistant, model, capabilities)
|
||||
break
|
||||
case 'deepseek':
|
||||
case 'openai-compatible':
|
||||
case 'openrouter':
|
||||
case 'openai-compatible': {
|
||||
// 对于其他 provider,使用通用的构建逻辑
|
||||
providerSpecificOptions = {
|
||||
...buildGenericProviderOptions(assistant, model, capabilities),
|
||||
serviceTier: serviceTierSetting
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported base provider ${baseProviderId}`)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
return {}
|
||||
}
|
||||
// Don't disable reasoning for models that require it
|
||||
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) {
|
||||
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
|
||||
return {}
|
||||
}
|
||||
return { reasoning: { enabled: false, exclude: true } }
|
||||
@@ -112,6 +112,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
return {
|
||||
enable_thinking: true
|
||||
}
|
||||
case SystemProviderIds.hunyuan:
|
||||
case SystemProviderIds['tencent-cloud-ti']:
|
||||
case SystemProviderIds.doubao:
|
||||
return {
|
||||
thinking: {
|
||||
@@ -312,7 +314,7 @@ export function getOpenAIReasoningParams(assistant: Assistant, model: Model): Re
|
||||
|
||||
export function getAnthropicThinkingBudget(assistant: Assistant, model: Model): number {
|
||||
const { maxTokens, reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
|
||||
if (maxTokens === undefined || reasoningEffort === undefined) {
|
||||
if (reasoningEffort === undefined) {
|
||||
return 0
|
||||
}
|
||||
const effortRatio = EFFORT_RATIO[reasoningEffort]
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import {
|
||||
AnthropicSearchConfig,
|
||||
OpenAISearchConfig,
|
||||
WebSearchPluginConfig
|
||||
} from '@cherrystudio/ai-core/core/plugins/built-in/webSearchPlugin/helper'
|
||||
import { BaseProviderId } from '@cherrystudio/ai-core/provider'
|
||||
import { isOpenAIWebSearchChatCompletionOnlyModel } from '@renderer/config/models'
|
||||
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '@renderer/config/prompts'
|
||||
import { CherryWebSearchConfig } from '@renderer/store/websearch'
|
||||
import { Model } from '@renderer/types'
|
||||
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
||||
|
||||
export function getWebSearchParams(model: Model): Record<string, any> {
|
||||
if (model.provider === 'hunyuan') {
|
||||
@@ -21,11 +28,79 @@ export function getWebSearchParams(model: Model): Record<string, any> {
|
||||
web_search_options: {}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.provider === 'openrouter') {
|
||||
return {
|
||||
plugins: [{ id: 'web', search_prompts: WEB_SEARCH_PROMPT_FOR_OPENROUTER }]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* range in [0, 100]
|
||||
* @param maxResults
|
||||
*/
|
||||
function mapMaxResultToOpenAIContextSize(maxResults: number): OpenAISearchConfig['searchContextSize'] {
|
||||
if (maxResults <= 33) return 'low'
|
||||
if (maxResults <= 66) return 'medium'
|
||||
return 'high'
|
||||
}
|
||||
|
||||
export function buildProviderBuiltinWebSearchConfig(
|
||||
providerId: BaseProviderId,
|
||||
webSearchConfig: CherryWebSearchConfig
|
||||
): WebSearchPluginConfig | undefined {
|
||||
switch (providerId) {
|
||||
case 'openai': {
|
||||
return {
|
||||
openai: {
|
||||
searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults)
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'openai-chat': {
|
||||
return {
|
||||
'openai-chat': {
|
||||
searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults)
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'anthropic': {
|
||||
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
const anthropicSearchOptions: AnthropicSearchConfig = {
|
||||
maxUses: webSearchConfig.maxResults,
|
||||
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
|
||||
}
|
||||
return {
|
||||
anthropic: anthropicSearchOptions
|
||||
}
|
||||
}
|
||||
case 'xai': {
|
||||
return {
|
||||
xai: {
|
||||
maxSearchResults: webSearchConfig.maxResults,
|
||||
returnCitations: true,
|
||||
sources: [
|
||||
{
|
||||
type: 'web',
|
||||
excludedWebsites: mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
},
|
||||
{ type: 'news' },
|
||||
{ type: 'x' }
|
||||
],
|
||||
mode: 'on'
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'openrouter': {
|
||||
return {
|
||||
openrouter: {
|
||||
plugins: [
|
||||
{
|
||||
id: 'web',
|
||||
max_results: webSearchConfig.maxResults
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -76,6 +76,10 @@
|
||||
list-style: initial;
|
||||
}
|
||||
|
||||
.markdown ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.markdown ul,
|
||||
.markdown ol {
|
||||
padding-left: 1.5em;
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const FreeTrialModelTag: FC<Props> = ({ model, showLabel = true }) => {
|
||||
if (model.provider !== 'cherryin') {
|
||||
if (model.provider !== 'cherryai') {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
app: MinAppType
|
||||
@@ -11,31 +10,52 @@ interface Props {
|
||||
}
|
||||
|
||||
const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => {
|
||||
// First try to find in DEFAULT_MIN_APPS for predefined styling
|
||||
const _app = DEFAULT_MIN_APPS.find((item) => item.id === app.id)
|
||||
|
||||
if (!_app) {
|
||||
return null
|
||||
// If found in DEFAULT_MIN_APPS, use predefined styling
|
||||
if (_app) {
|
||||
return (
|
||||
<img
|
||||
src={_app.logo}
|
||||
className="select-none rounded-2xl"
|
||||
style={{
|
||||
border: _app.bodered ? '0.5px solid var(--color-border)' : 'none',
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: _app.background,
|
||||
userSelect: 'none',
|
||||
...(sidebar ? {} : app.style),
|
||||
...style
|
||||
}}
|
||||
draggable={false}
|
||||
alt={app.name || 'MinApp Icon'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
src={_app.logo}
|
||||
style={{
|
||||
border: _app.bodered ? '0.5px solid var(--color-border)' : 'none',
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: _app.background,
|
||||
...(sidebar ? {} : app.style),
|
||||
...style
|
||||
}}
|
||||
/>
|
||||
)
|
||||
// If not found in DEFAULT_MIN_APPS but app has logo, use it (for temporary apps)
|
||||
if (app.logo) {
|
||||
return (
|
||||
<img
|
||||
src={app.logo}
|
||||
className="select-none rounded-2xl"
|
||||
style={{
|
||||
border: 'none',
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: 'transparent',
|
||||
userSelect: 'none',
|
||||
...(sidebar ? {} : app.style),
|
||||
...style
|
||||
}}
|
||||
draggable={false}
|
||||
alt={app.name || 'MinApp Icon'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const Container = styled.img`
|
||||
border-radius: 16px;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
`
|
||||
|
||||
export default MinAppIcon
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`MinAppIcon > should render correctly with various props 1`] = `
|
||||
.c0 {
|
||||
border-radius: 16px;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
<img
|
||||
class="c0"
|
||||
alt="Test App"
|
||||
class="select-none rounded-2xl"
|
||||
draggable="false"
|
||||
src="/test-logo-1.png"
|
||||
style="border: 0.5px solid var(--color-border); width: 64px; height: 64px; background-color: rgb(240, 240, 240); opacity: 0.8; transform: scale(1.1); margin-top: 10px;"
|
||||
style="border: 0.5px solid var(--color-border); width: 64px; height: 64px; background-color: rgb(240, 240, 240); user-select: none; opacity: 0.8; transform: scale(1.1); margin-top: 10px;"
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Provider } from '@renderer/types'
|
||||
import {
|
||||
oauthWith302AI,
|
||||
oauthWithAihubmix,
|
||||
oauthWithAiOnly,
|
||||
oauthWithPPIO,
|
||||
oauthWithSiliconFlow,
|
||||
oauthWithTokenFlux
|
||||
@@ -46,6 +47,10 @@ const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
|
||||
if (provider.id === '302ai') {
|
||||
oauthWith302AI(handleSuccess)
|
||||
}
|
||||
|
||||
if (provider.id === 'aionly') {
|
||||
oauthWithAiOnly(handleSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { Button, Modal } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const WebViewContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
|
||||
webview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
`
|
||||
|
||||
interface ShowParams {
|
||||
title?: string
|
||||
showDeclineButton?: boolean
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ title, showDeclineButton = true, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [privacyUrl, setPrivacyUrl] = useState<string>('')
|
||||
const { theme } = useTheme()
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
const getTitle = () => {
|
||||
if (title) return title
|
||||
const isChinese = i18n.language.startsWith('zh')
|
||||
return isChinese ? '隐私协议' : 'Privacy Policy'
|
||||
}
|
||||
|
||||
const handleAccept = () => {
|
||||
setOpen(false)
|
||||
localStorage.setItem('privacy-popup-accepted', 'true')
|
||||
resolve({ accepted: true })
|
||||
}
|
||||
|
||||
const handleDecline = () => {
|
||||
setOpen(false)
|
||||
window.api.quit()
|
||||
resolve({ accepted: false })
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
if (!showDeclineButton) {
|
||||
handleAccept()
|
||||
} else {
|
||||
handleDecline()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
runAsyncFunction(async () => {
|
||||
const { appPath } = await window.api.getAppInfo()
|
||||
const isChinese = i18n.language.startsWith('zh')
|
||||
const htmlFile = isChinese ? 'privacy-zh.html' : 'privacy-en.html'
|
||||
const url = `file://${appPath}/resources/cherry-studio/${htmlFile}?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}`
|
||||
setPrivacyUrl(url)
|
||||
})
|
||||
}, [theme, i18n.language])
|
||||
|
||||
PrivacyPopup.hide = () => setOpen(false)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={getTitle()}
|
||||
open={open}
|
||||
onCancel={showDeclineButton ? handleDecline : undefined}
|
||||
afterClose={onClose}
|
||||
transitionName=""
|
||||
maskTransitionName=""
|
||||
centered
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
styles={{
|
||||
mask: { backgroundColor: 'var(--color-background)' },
|
||||
header: { paddingLeft: 20 },
|
||||
body: { paddingLeft: 20 }
|
||||
}}
|
||||
width={900}
|
||||
footer={[
|
||||
showDeclineButton && (
|
||||
<Button key="decline" onClick={handleDecline}>
|
||||
{i18n.language.startsWith('zh') ? '拒绝' : 'Decline'}
|
||||
</Button>
|
||||
),
|
||||
<Button key="accept" type="primary" onClick={handleAccept}>
|
||||
{i18n.language.startsWith('zh') ? '同意并继续' : 'Accept and Continue'}
|
||||
</Button>
|
||||
].filter(Boolean)}>
|
||||
<WebViewContainer>
|
||||
{privacyUrl && <webview src={privacyUrl} style={{ width: '100%', height: '100%' }} />}
|
||||
</WebViewContainer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'PrivacyPopup'
|
||||
|
||||
export default class PrivacyPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static async show(props?: ShowParams) {
|
||||
const accepted = localStorage.getItem('privacy-popup-accepted')
|
||||
|
||||
if (accepted) {
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise<{ accepted: boolean }>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...(props || {})}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -253,10 +253,39 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
||||
let savedCount = 0
|
||||
|
||||
try {
|
||||
// Validate knowledge base configuration before proceeding
|
||||
if (!selectedBaseId) {
|
||||
throw new Error('No knowledge base selected')
|
||||
}
|
||||
|
||||
const selectedBase = bases.find((base) => base.id === selectedBaseId)
|
||||
if (!selectedBase) {
|
||||
throw new Error('Selected knowledge base not found')
|
||||
}
|
||||
|
||||
if (!selectedBase.version) {
|
||||
throw new Error('Knowledge base is not properly configured. Please check the knowledge base settings.')
|
||||
}
|
||||
|
||||
if (isNoteMode) {
|
||||
const note = source.data as NotesTreeNode
|
||||
const content = await window.api.file.read(note.id + '.md')
|
||||
logger.debug('Note content:', content)
|
||||
if (!note.externalPath) {
|
||||
throw new Error('Note external path is required for export')
|
||||
}
|
||||
|
||||
let content = ''
|
||||
try {
|
||||
content = await window.api.file.readExternal(note.externalPath)
|
||||
} catch (error) {
|
||||
logger.error('Failed to read note file:', error as Error)
|
||||
throw new Error('Failed to read note content. Please ensure the file exists and is accessible.')
|
||||
}
|
||||
|
||||
if (!content || content.trim() === '') {
|
||||
throw new Error('Note content is empty. Cannot export empty notes to knowledge base.')
|
||||
}
|
||||
|
||||
logger.debug('Note content loaded', { contentLength: content.length })
|
||||
await addNote(content)
|
||||
savedCount = 1
|
||||
} else {
|
||||
@@ -281,9 +310,23 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
||||
resolve({ success: true, savedCount })
|
||||
} catch (error) {
|
||||
logger.error('save failed:', error as Error)
|
||||
window.toast.error(
|
||||
t(isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed')
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = t(
|
||||
isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed'
|
||||
)
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('not properly configured')) {
|
||||
errorMessage = error.message
|
||||
} else if (error.message.includes('empty')) {
|
||||
errorMessage = error.message
|
||||
} else if (error.message.includes('read note content')) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
}
|
||||
|
||||
window.toast.error(errorMessage)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PushpinOutlined } from '@ant-design/icons'
|
||||
import { FreeTrialModelTag } from '@renderer/components/FreeTrialModelTag'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
|
||||
@@ -102,16 +103,18 @@ const PopupContainer: React.FC<Props> = ({ model, filter: baseFilter, showTagFil
|
||||
(model: Model, provider: Provider, isPinned: boolean): FlatListModel => {
|
||||
const modelId = getModelUniqId(model)
|
||||
const groupName = getFancyProviderName(provider)
|
||||
const isCherryin = provider.id === 'cherryin'
|
||||
const isCherryAi = provider.id === 'cherryai'
|
||||
|
||||
return {
|
||||
key: isPinned ? `${modelId}_pinned` : modelId,
|
||||
type: 'model',
|
||||
name: (
|
||||
<ModelName>
|
||||
{model.name}
|
||||
{isPinned && <span style={{ color: 'var(--color-text-3)' }}> | {groupName}</span>}
|
||||
{isCherryin && <FreeTrialModelTag model={model} showLabel={false} />}
|
||||
<HStack alignItems="center">
|
||||
{model.name}
|
||||
{isPinned && <span style={{ color: 'var(--color-text-3)' }}> | {groupName}</span>}
|
||||
</HStack>
|
||||
{isCherryAi && <FreeTrialModelTag model={model} showLabel={false} />}
|
||||
</ModelName>
|
||||
),
|
||||
tags: (
|
||||
@@ -177,7 +180,7 @@ const PopupContainer: React.FC<Props> = ({ model, filter: baseFilter, showTagFil
|
||||
key: `provider-${p.id}`,
|
||||
type: 'group',
|
||||
name: getFancyProviderName(p),
|
||||
actions: (
|
||||
actions: p.id !== 'cherryai' && (
|
||||
<Tooltip title={t('navigate.provider_settings')} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||
<Settings2
|
||||
size={12}
|
||||
@@ -542,6 +545,7 @@ const ModelItemLeft = styled.div`
|
||||
const ModelName = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -18,7 +18,8 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
// Sanitize the SVG content
|
||||
const sanitizedContent = DOMPurify.sanitize(svgContent, {
|
||||
ADD_TAGS: ['animate', 'foreignObject', 'use'],
|
||||
ADD_ATTR: ['from', 'to']
|
||||
ADD_ATTR: ['from', 'to'],
|
||||
HTML_INTEGRATION_POINTS: { foreignobject: true }
|
||||
})
|
||||
|
||||
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
|
||||
@@ -36,6 +37,7 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
border-radius: var(--shadow-host-border-radius);
|
||||
padding: 1em;
|
||||
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
|
||||
white-space: normal;
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -158,15 +158,22 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const cursorPosition = textArea.selectionStart ?? 0
|
||||
const textBeforeCursor = textArea.value.slice(0, cursorPosition)
|
||||
|
||||
// 查找最后一个 @ 或 / 符号的位置
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
|
||||
const lastSymbolIndex = Math.max(lastAtIndex, lastSlashIndex)
|
||||
// 查找末尾最近的触发符号(@ 或 /),允许位于文本起始或空格后
|
||||
const match = textBeforeCursor.match(/(^| )([@/][^\s]*)$/)
|
||||
if (!match) return
|
||||
|
||||
if (lastSymbolIndex === -1) return
|
||||
const matchIndex = match.index ?? -1
|
||||
if (matchIndex === -1) return
|
||||
|
||||
const boundarySegment = match[1] ?? ''
|
||||
const symbolSegment = match[2] ?? ''
|
||||
if (!symbolSegment) return
|
||||
|
||||
const boundaryStart = matchIndex
|
||||
const symbolStart = boundaryStart + boundarySegment.length
|
||||
|
||||
// 根据 includeSymbol 决定是否删除符号
|
||||
const deleteStart = includeSymbol ? lastSymbolIndex : lastSymbolIndex + 1
|
||||
const deleteStart = includeSymbol ? boundaryStart : symbolStart + 1
|
||||
const deleteEnd = cursorPosition
|
||||
|
||||
if (deleteStart >= deleteEnd) return
|
||||
@@ -203,7 +210,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
if (textArea) {
|
||||
setInputText(textArea.value)
|
||||
}
|
||||
} else if (action && !['outsideclick', 'esc', 'enter_empty'].includes(action)) {
|
||||
} else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) {
|
||||
clearSearchText(true)
|
||||
}
|
||||
},
|
||||
@@ -450,7 +457,13 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
// 面板可见且未折叠时:拦截所有 Enter 变体;
|
||||
// 纯 Enter 选择项,带修饰键仅拦截不处理
|
||||
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||
if (e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Don't prevent default or stop propagation - let it create a newline
|
||||
setIsMouseOver(false)
|
||||
break
|
||||
}
|
||||
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
@@ -533,6 +546,18 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list])
|
||||
const collapsed = hasSearchText && visibleNonPinnedCount === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) return
|
||||
if (!collapsed) return
|
||||
if (ctx.triggerInfo?.type !== 'input') return
|
||||
if (ctx.multiple) return
|
||||
|
||||
const trimmedSearch = searchText.replace(/^[/@]/, '').trim()
|
||||
if (!trimmedSearch) return
|
||||
|
||||
handleClose('no_result')
|
||||
}, [collapsed, ctx.isVisible, ctx.triggerInfo, ctx.multiple, handleClose, searchText])
|
||||
|
||||
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
|
||||
|
||||
const rowRenderer = useCallback(
|
||||
|
||||
@@ -87,6 +87,9 @@ const CommandListPopover = ({
|
||||
return true
|
||||
|
||||
case 'Enter':
|
||||
if (event.shiftKey) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
if (items[internalSelectedIndex]) {
|
||||
selectItem(internalSelectedIndex)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { commandSuggestion } from '../command'
|
||||
|
||||
describe('commandSuggestion render', () => {
|
||||
it('has render function', () => {
|
||||
expect(commandSuggestion.render).toBeDefined()
|
||||
expect(typeof commandSuggestion.render).toBe('function')
|
||||
})
|
||||
|
||||
it('render function returns object with onKeyDown', () => {
|
||||
const renderResult = commandSuggestion.render?.()
|
||||
expect(renderResult).toBeDefined()
|
||||
expect(renderResult?.onKeyDown).toBeDefined()
|
||||
expect(typeof renderResult?.onKeyDown).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -628,13 +628,34 @@ export const commandSuggestion: Omit<SuggestionOptions<Command, MentionNodeAttrs
|
||||
},
|
||||
|
||||
onKeyDown: (props) => {
|
||||
// Let CommandListPopover handle events first
|
||||
const popoverHandled = component.ref?.onKeyDown?.(props.event)
|
||||
if (popoverHandled) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle Shift+Enter for newline when popover doesn't handle it
|
||||
if (props.event.key === 'Enter' && props.event.shiftKey) {
|
||||
props.event.preventDefault()
|
||||
// Close the suggestion menu
|
||||
if (cleanup) cleanup()
|
||||
component.destroy()
|
||||
// Use the view from SuggestionKeyDownProps to insert newline
|
||||
const { view } = props
|
||||
const { state, dispatch } = view
|
||||
const { tr } = state
|
||||
tr.insertText('\n')
|
||||
dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
if (props.event.key === 'Escape') {
|
||||
if (cleanup) cleanup()
|
||||
component.destroy()
|
||||
return true
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event)
|
||||
return false
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
||||
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
@@ -12,9 +13,10 @@ import tabsService from '@renderer/services/TabsService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import type { Tab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { MinAppType, ThemeMode } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Tooltip } from 'antd'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import {
|
||||
FileSearch,
|
||||
Folder,
|
||||
@@ -45,14 +47,40 @@ interface TabsContainerProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined => {
|
||||
const logger = loggerService.withContext('TabContainer')
|
||||
|
||||
const getTabIcon = (
|
||||
tabId: string,
|
||||
minapps: MinAppType[],
|
||||
minAppsCache?: LRUCache<string, MinAppType>
|
||||
): React.ReactNode | undefined => {
|
||||
// Check if it's a minapp tab (format: apps:appId)
|
||||
if (tabId.startsWith('apps:')) {
|
||||
const appId = tabId.replace('apps:', '')
|
||||
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
|
||||
// If not found in permanent apps, search in temporary apps cache
|
||||
// The cache stores apps opened via openSmartMinapp() for top navbar mode
|
||||
// These are temporary MinApps that were opened but not yet saved to user's config
|
||||
// The cache is LRU (Least Recently Used) with max size from settings
|
||||
// Cache validity: Apps in cache are currently active/recently used, not outdated
|
||||
if (!app && minAppsCache) {
|
||||
app = minAppsCache.get(appId)
|
||||
|
||||
// Defensive programming: If app not found in cache but tab exists,
|
||||
// the cache entry may have been evicted due to LRU policy
|
||||
// Log warning for debugging potential sync issues
|
||||
if (!app) {
|
||||
logger.warn(`MinApp ${appId} not found in cache, using fallback icon`)
|
||||
}
|
||||
}
|
||||
|
||||
if (app) {
|
||||
return <MinAppIcon size={14} app={app} />
|
||||
}
|
||||
|
||||
// Fallback: If no app found (cache evicted), show default icon
|
||||
return <LayoutGrid size={14} />
|
||||
}
|
||||
|
||||
switch (tabId) {
|
||||
@@ -94,7 +122,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
|
||||
const isFullscreen = useFullscreen()
|
||||
const { settedTheme, toggleTheme } = useTheme()
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { hideMinappPopup, minAppsCache } = useMinappPopup()
|
||||
const { minapps } = useMinapps()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -112,8 +140,23 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
// Check if it's a minapp tab
|
||||
if (tabId.startsWith('apps:')) {
|
||||
const appId = tabId.replace('apps:', '')
|
||||
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
return app ? app.name : 'MinApp'
|
||||
let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
|
||||
// If not found in permanent apps, search in temporary apps cache
|
||||
// This ensures temporary MinApps display proper titles while being used
|
||||
// The LRU cache automatically manages app lifecycle and prevents memory leaks
|
||||
if (!app && minAppsCache) {
|
||||
app = minAppsCache.get(appId)
|
||||
|
||||
// Defensive programming: If app not found in cache but tab exists,
|
||||
// the cache entry may have been evicted due to LRU policy
|
||||
if (!app) {
|
||||
logger.warn(`MinApp ${appId} not found in cache, using fallback title`)
|
||||
}
|
||||
}
|
||||
|
||||
// Return app name if found, otherwise use fallback with appId
|
||||
return app ? app.name : `MinApp-${appId}`
|
||||
}
|
||||
return getTitleLabel(tabId)
|
||||
}
|
||||
@@ -196,7 +239,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
renderItem={(tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
|
||||
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
@@ -259,7 +302,7 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'env(titlebar-area-x)' : '15px')};
|
||||
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'calc(env(titlebar-area-x) + 4px)' : '15px')};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : '0')};
|
||||
height: var(--navbar-height);
|
||||
min-height: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'env(titlebar-area-height)' : '')};
|
||||
|
||||
@@ -88,6 +88,7 @@ const NavbarCenterContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
padding-left: 10px;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
position: relative;
|
||||
@@ -108,7 +109,8 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
padding-right: ${isMac ? '20px' : 0};
|
||||
padding-left: 10px;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ItemRendererProps<T> {
|
||||
transform?: Transform | null
|
||||
transition?: string | null
|
||||
listeners?: DraggableSyntheticListeners
|
||||
itemStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function ItemRenderer<T>({
|
||||
@@ -30,6 +31,7 @@ export function ItemRenderer<T>({
|
||||
transform,
|
||||
transition,
|
||||
listeners,
|
||||
itemStyle,
|
||||
...props
|
||||
}: ItemRendererProps<T>) {
|
||||
useEffect(() => {
|
||||
@@ -44,7 +46,7 @@ export function ItemRenderer<T>({
|
||||
}
|
||||
}, [dragOverlay])
|
||||
|
||||
const wrapperStyle = {
|
||||
const style = {
|
||||
transition,
|
||||
transform: CSS.Transform.toString(transform ?? null)
|
||||
} as React.CSSProperties
|
||||
@@ -54,7 +56,7 @@ export function ItemRenderer<T>({
|
||||
ref={ref}
|
||||
data-index={index}
|
||||
className={classNames({ dragOverlay: dragOverlay })}
|
||||
style={{ ...wrapperStyle }}>
|
||||
style={{ ...style, ...itemStyle }}>
|
||||
<DraggableItem
|
||||
className={classNames({ dragging: dragging, dragOverlay: dragOverlay, ghost: ghost })}
|
||||
{...listeners}
|
||||
|
||||
@@ -61,6 +61,8 @@ interface SortableProps<T> {
|
||||
className?: string
|
||||
/** Item list style */
|
||||
listStyle?: React.CSSProperties
|
||||
/** Item style */
|
||||
itemStyle?: React.CSSProperties
|
||||
/** Item gap */
|
||||
gap?: number | string
|
||||
/** Restrictions, shortcuts for some modifiers */
|
||||
@@ -87,6 +89,7 @@ function Sortable<T>({
|
||||
showGhost = false,
|
||||
className,
|
||||
listStyle,
|
||||
itemStyle,
|
||||
gap,
|
||||
restrictions,
|
||||
modifiers: customModifiers
|
||||
@@ -195,19 +198,19 @@ function Sortable<T>({
|
||||
renderItem={renderItem}
|
||||
useDragOverlay={useDragOverlay}
|
||||
showGhost={showGhost}
|
||||
itemStyle={itemStyle}
|
||||
/>
|
||||
))}
|
||||
</ListWrapper>
|
||||
</SortableContext>
|
||||
|
||||
{useDragOverlay
|
||||
? createPortal(
|
||||
<DragOverlay adjustScale dropAnimation={dropAnimation}>
|
||||
{activeItem ? <ItemRenderer item={activeItem} renderItem={renderItem} dragOverlay /> : null}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
{useDragOverlay &&
|
||||
createPortal(
|
||||
<DragOverlay adjustScale dropAnimation={dropAnimation}>
|
||||
{activeItem && <ItemRenderer item={activeItem} renderItem={renderItem} itemStyle={itemStyle} dragOverlay />}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SortableItemProps<T> {
|
||||
renderItem: RenderItemType<T>
|
||||
useDragOverlay?: boolean
|
||||
showGhost?: boolean
|
||||
itemStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function SortableItem<T>({
|
||||
@@ -18,7 +19,8 @@ export function SortableItem<T>({
|
||||
index,
|
||||
renderItem,
|
||||
useDragOverlay = true,
|
||||
showGhost = true
|
||||
showGhost = true,
|
||||
itemStyle
|
||||
}: SortableItemProps<T>) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id
|
||||
@@ -36,6 +38,7 @@ export function SortableItem<T>({
|
||||
transform={transform}
|
||||
transition={transition}
|
||||
listeners={listeners}
|
||||
itemStyle={itemStyle}
|
||||
{...attributes}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('Qwen Model Detection', () => {
|
||||
initialState: {}
|
||||
}))
|
||||
vi.mock('@renderer/services/AssistantService', () => ({
|
||||
getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryin' })
|
||||
getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' })
|
||||
}))
|
||||
})
|
||||
test('isQwenReasoningModel', () => {
|
||||
@@ -52,7 +52,7 @@ describe('Vision Model Detection', () => {
|
||||
initialState: {}
|
||||
}))
|
||||
vi.mock('@renderer/services/AssistantService', () => ({
|
||||
getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryin' })
|
||||
getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' })
|
||||
}))
|
||||
})
|
||||
test('isVisionModel', () => {
|
||||
@@ -81,7 +81,7 @@ describe('Web Search Model Detection', () => {
|
||||
initialState: {}
|
||||
}))
|
||||
vi.mock('@renderer/services/AssistantService', () => ({
|
||||
getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryin' })
|
||||
getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' })
|
||||
}))
|
||||
})
|
||||
test('isWebSearchModel', () => {
|
||||
|
||||
@@ -3,14 +3,14 @@ import { Model, SystemProviderId } from '@renderer/types'
|
||||
export const glm45FlashModel: Model = {
|
||||
id: 'glm-4.5-flash',
|
||||
name: 'GLM-4.5-Flash',
|
||||
provider: 'cherryin',
|
||||
provider: 'cherryai',
|
||||
group: 'GLM-4.5'
|
||||
}
|
||||
|
||||
export const qwen38bModel: Model = {
|
||||
id: 'Qwen/Qwen3-8B',
|
||||
name: 'Qwen3-8B',
|
||||
provider: 'cherryin',
|
||||
provider: 'cherryai',
|
||||
group: 'Qwen'
|
||||
}
|
||||
|
||||
@@ -25,20 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
// Default quick assistant model
|
||||
glm45FlashModel
|
||||
],
|
||||
cherryin: [
|
||||
{
|
||||
id: 'glm-4.5-flash',
|
||||
name: 'GLM-4.5-Flash',
|
||||
provider: 'cherryin',
|
||||
group: 'GLM-4.5'
|
||||
},
|
||||
{
|
||||
id: 'Qwen/Qwen3-8B',
|
||||
name: 'Qwen3-8B',
|
||||
provider: 'cherryin',
|
||||
group: 'Qwen'
|
||||
}
|
||||
],
|
||||
// cherryin: [],
|
||||
vertexai: [],
|
||||
'302ai': [
|
||||
{
|
||||
@@ -273,6 +260,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
{ id: 'deepseek-r1', name: 'DeepSeek-R1', provider: 'burncloud', group: 'deepseek-ai' },
|
||||
{ id: 'deepseek-v3', name: 'DeepSeek-V3', provider: 'burncloud', group: 'deepseek-ai' }
|
||||
],
|
||||
ovms: [],
|
||||
ollama: [],
|
||||
lmstudio: [],
|
||||
silicon: [
|
||||
@@ -1785,5 +1773,51 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
provider: 'poe',
|
||||
group: 'poe'
|
||||
}
|
||||
],
|
||||
aionly: [
|
||||
{
|
||||
id: 'claude-opus-4.1',
|
||||
name: 'claude-opus-4.1',
|
||||
provider: 'aionly',
|
||||
group: 'claude'
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet4',
|
||||
name: 'claude-sonnet4',
|
||||
provider: 'aionly',
|
||||
group: 'claude'
|
||||
},
|
||||
{
|
||||
id: 'claude-3.5-sonnet-v2',
|
||||
name: 'claude-3.5-sonnet-v2',
|
||||
provider: 'aionly',
|
||||
group: 'claude'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4.1',
|
||||
name: 'gpt-4.1',
|
||||
provider: 'aionly',
|
||||
group: 'gpt'
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-flash',
|
||||
name: 'gemini-2.5-flash',
|
||||
provider: 'aionly',
|
||||
group: 'gemini'
|
||||
}
|
||||
],
|
||||
longcat: [
|
||||
{
|
||||
id: 'LongCat-Flash-Chat',
|
||||
name: 'LongCat Flash Chat',
|
||||
provider: 'longcat',
|
||||
group: 'LongCat'
|
||||
},
|
||||
{
|
||||
id: 'LongCat-Flash-Thinking',
|
||||
name: 'LongCat Flash Thinking',
|
||||
provider: 'longcat',
|
||||
group: 'LongCat'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import ChatGPTImageModelLogo from '@renderer/assets/images/models/gpt_image_1.pn
|
||||
import ChatGPTo1ModelLogo from '@renderer/assets/images/models/gpt_o1.png'
|
||||
import GPT5ModelLogo from '@renderer/assets/images/models/gpt-5.png'
|
||||
import GPT5ChatModelLogo from '@renderer/assets/images/models/gpt-5-chat.png'
|
||||
import GPT5CodexModelLogo from '@renderer/assets/images/models/gpt-5-codex.png'
|
||||
import GPT5MiniModelLogo from '@renderer/assets/images/models/gpt-5-mini.png'
|
||||
import GPT5NanoModelLogo from '@renderer/assets/images/models/gpt-5-nano.png'
|
||||
import GrokModelLogo from '@renderer/assets/images/models/grok.png'
|
||||
@@ -162,6 +163,7 @@ export function getModelLogo(modelId: string) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// key is regex
|
||||
const logoMap = {
|
||||
pixtral: isLight ? PixtralModelLogo : PixtralModelLogoDark,
|
||||
jina: isLight ? JinaModelLogo : JinaModelLogoDark,
|
||||
@@ -177,6 +179,7 @@ export function getModelLogo(modelId: string) {
|
||||
'gpt-5-mini': GPT5MiniModelLogo,
|
||||
'gpt-5-nano': GPT5NanoModelLogo,
|
||||
'gpt-5-chat': GPT5ChatModelLogo,
|
||||
'gpt-5-codex': GPT5CodexModelLogo,
|
||||
'gpt-5': GPT5ModelLogo,
|
||||
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
|
||||
'gpt-oss(?:-[\\w-]+)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||
@@ -286,7 +289,7 @@ export function getModelLogo(modelId: string) {
|
||||
longcat: LongCatAppLogo,
|
||||
bytedance: BytedanceModelLogo,
|
||||
'(V_1|V_1_TURBO|V_2|V_2A|V_2_TURBO|DESCRIBE|UPSCALE)': IdeogramModelLogo
|
||||
}
|
||||
} as const
|
||||
|
||||
for (const key in logoMap) {
|
||||
const regex = new RegExp(key, 'i')
|
||||
|
||||
@@ -22,6 +22,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
||||
default: ['low', 'medium', 'high'] as const,
|
||||
o: ['low', 'medium', 'high'] as const,
|
||||
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
|
||||
gpt5_codex: ['low', 'medium', 'high'] as const,
|
||||
grok: ['low', 'high'] as const,
|
||||
gemini: ['low', 'medium', 'high', 'auto'] as const,
|
||||
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
|
||||
@@ -40,6 +41,7 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
|
||||
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
|
||||
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
|
||||
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
|
||||
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
|
||||
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
||||
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
|
||||
@@ -55,8 +57,13 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||
|
||||
export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
let thinkingModelType: ThinkingModelType = 'default'
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
if (isGPT5SeriesModel(model)) {
|
||||
thinkingModelType = 'gpt5'
|
||||
if (modelId.includes('codex')) {
|
||||
thinkingModelType = 'gpt5_codex'
|
||||
} else {
|
||||
thinkingModelType = 'gpt5'
|
||||
}
|
||||
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||
thinkingModelType = 'o'
|
||||
} else if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||
@@ -93,7 +100,17 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean {
|
||||
// Specifically for DeepSeek V3.1. White list for now
|
||||
if (isDeepSeekHybridInferenceModel(model)) {
|
||||
return (
|
||||
['openrouter', 'dashscope', 'modelscope', 'doubao', 'silicon', 'nvidia', 'ppio'] satisfies SystemProviderId[]
|
||||
[
|
||||
'openrouter',
|
||||
'dashscope',
|
||||
'modelscope',
|
||||
'doubao',
|
||||
'silicon',
|
||||
'nvidia',
|
||||
'ppio',
|
||||
'hunyuan',
|
||||
'tencent-cloud-ti'
|
||||
] satisfies SystemProviderId[]
|
||||
).some((id) => id === model.provider)
|
||||
}
|
||||
|
||||
@@ -225,6 +242,8 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
|
||||
'qwen-plus-2025-04-28',
|
||||
'qwen-plus-0714',
|
||||
'qwen-plus-2025-07-14',
|
||||
'qwen-plus-2025-07-28',
|
||||
'qwen-plus-2025-09-11',
|
||||
'qwen-turbo',
|
||||
'qwen-turbo-latest',
|
||||
'qwen-turbo-0428',
|
||||
@@ -323,7 +342,7 @@ export const isDeepSeekHybridInferenceModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
|
||||
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险
|
||||
return /deepseek-v3(?:\.1|-1-\d+)?/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
|
||||
return /deepseek-v3(?:\.1|-1-\d+)/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
|
||||
}
|
||||
|
||||
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
|
||||
@@ -379,7 +398,8 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
isDeepSeekHybridInferenceModel(model) ||
|
||||
modelId.includes('magistral') ||
|
||||
modelId.includes('minimax-m1') ||
|
||||
modelId.includes('pangu-pro-moe')
|
||||
modelId.includes('pangu-pro-moe') ||
|
||||
modelId.includes('seed-oss')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
@@ -410,13 +430,14 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
|
||||
'gemini-.*-pro.*$': { min: 128, max: 32768 },
|
||||
|
||||
// Qwen models
|
||||
// qwen-plus-x 系列自 qwen-plus-2025-07-28 后模型最长思维链变为 81_920, qwen-plus 模型于 2025.9.16 同步变更
|
||||
'qwen3-235b-a22b-thinking-2507$': { min: 0, max: 81_920 },
|
||||
'qwen3-30b-a3b-thinking-2507$': { min: 0, max: 81_920 },
|
||||
'qwen-plus-2025-07-28$': { min: 0, max: 81_920 },
|
||||
'qwen-plus-latest$': { min: 0, max: 81_920 },
|
||||
'qwen-plus-2025-07-14$': { min: 0, max: 38_912 },
|
||||
'qwen-plus-2025-04-28$': { min: 0, max: 38_912 },
|
||||
'qwen3-1\\.7b$': { min: 0, max: 30_720 },
|
||||
'qwen3-0\\.6b$': { min: 0, max: 30_720 },
|
||||
'qwen-plus.*$': { min: 0, max: 38_912 },
|
||||
'qwen-plus.*$': { min: 0, max: 81_920 },
|
||||
'qwen-turbo.*$': { min: 0, max: 38_912 },
|
||||
'qwen-flash.*$': { min: 0, max: 81_920 },
|
||||
'qwen3-(?!max).*$': { min: 1024, max: 38_912 },
|
||||
|
||||
@@ -229,6 +229,11 @@ export const isGPT5SeriesModel = (model: Model) => {
|
||||
return modelId.includes('gpt-5')
|
||||
}
|
||||
|
||||
export const isGPT5SeriesReasoningModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return modelId.includes('gpt-5') && !modelId.includes('chat')
|
||||
}
|
||||
|
||||
export const isGeminiModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return modelId.includes('gemini')
|
||||
|
||||
@@ -3,6 +3,7 @@ import HunyuanProviderLogo from '@renderer/assets/images/models/hunyuan.png'
|
||||
import AzureProviderLogo from '@renderer/assets/images/models/microsoft.png'
|
||||
import Ai302ProviderLogo from '@renderer/assets/images/providers/302ai.webp'
|
||||
import AiHubMixProviderLogo from '@renderer/assets/images/providers/aihubmix.webp'
|
||||
import AiOnlyProviderLogo from '@renderer/assets/images/providers/aiOnly.webp'
|
||||
import AlayaNewProviderLogo from '@renderer/assets/images/providers/alayanew.webp'
|
||||
import AnthropicProviderLogo from '@renderer/assets/images/providers/anthropic.png'
|
||||
import AwsProviderLogo from '@renderer/assets/images/providers/aws-bedrock.webp'
|
||||
@@ -23,9 +24,11 @@ import GrokProviderLogo from '@renderer/assets/images/providers/grok.png'
|
||||
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
|
||||
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
|
||||
import InfiniProviderLogo from '@renderer/assets/images/providers/infini.png'
|
||||
import IntelOvmsLogo from '@renderer/assets/images/providers/intel.png'
|
||||
import JinaProviderLogo from '@renderer/assets/images/providers/jina.png'
|
||||
import LanyunProviderLogo from '@renderer/assets/images/providers/lanyun.png'
|
||||
import LMStudioProviderLogo from '@renderer/assets/images/providers/lmstudio.png'
|
||||
import LongCatProviderLogo from '@renderer/assets/images/providers/longcat.png'
|
||||
import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png'
|
||||
import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png'
|
||||
import ModelScopeProviderLogo from '@renderer/assets/images/providers/modelscope.png'
|
||||
@@ -63,19 +66,30 @@ import {
|
||||
} from '@renderer/types'
|
||||
|
||||
import { TOKENFLUX_HOST } from './constant'
|
||||
import { SYSTEM_MODELS } from './models'
|
||||
import { glm45FlashModel, qwen38bModel, SYSTEM_MODELS } from './models'
|
||||
|
||||
export const CHERRYAI_PROVIDER: SystemProvider = {
|
||||
id: 'cherryai' as SystemProviderId,
|
||||
name: 'CherryAI',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.cherry-ai.com/',
|
||||
models: [glm45FlashModel, qwen38bModel],
|
||||
isSystem: true,
|
||||
enabled: true
|
||||
}
|
||||
|
||||
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
|
||||
cherryin: {
|
||||
id: 'cherryin',
|
||||
name: 'CherryIN',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.cherry-ai.com/',
|
||||
models: SYSTEM_MODELS.cherryin,
|
||||
isSystem: true,
|
||||
enabled: true
|
||||
},
|
||||
// cherryin: {
|
||||
// id: 'cherryin',
|
||||
// name: 'CherryIN',
|
||||
// type: 'openai',
|
||||
// apiKey: '',
|
||||
// apiHost: 'https://open.cherryin.ai',
|
||||
// models: [],
|
||||
// isSystem: true,
|
||||
// enabled: true
|
||||
// },
|
||||
silicon: {
|
||||
id: 'silicon',
|
||||
name: 'Silicon',
|
||||
@@ -96,6 +110,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
ovms: {
|
||||
id: 'ovms',
|
||||
name: 'OpenVINO Model Server',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'http://localhost:8000/v3/',
|
||||
models: SYSTEM_MODELS.ovms,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
ocoolai: {
|
||||
id: 'ocoolai',
|
||||
name: 'ocoolAI',
|
||||
@@ -126,16 +150,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
ppio: {
|
||||
id: 'ppio',
|
||||
name: 'PPIO',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.ppinfra.com/v3/openai/',
|
||||
models: SYSTEM_MODELS.ppio,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
alayanew: {
|
||||
id: 'alayanew',
|
||||
name: 'AlayaNew',
|
||||
@@ -146,16 +160,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
qiniu: {
|
||||
id: 'qiniu',
|
||||
name: 'Qiniu',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.qnaigc.com',
|
||||
models: SYSTEM_MODELS.qiniu,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
dmxapi: {
|
||||
id: 'dmxapi',
|
||||
name: 'DMXAPI',
|
||||
@@ -166,6 +170,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
aionly: {
|
||||
id: 'aionly',
|
||||
name: 'AIOnly',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.aiionly.com',
|
||||
models: SYSTEM_MODELS.aionly,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
burncloud: {
|
||||
id: 'burncloud',
|
||||
name: 'BurnCloud',
|
||||
@@ -226,6 +240,26 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
ppio: {
|
||||
id: 'ppio',
|
||||
name: 'PPIO',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.ppinfra.com/v3/openai/',
|
||||
models: SYSTEM_MODELS.ppio,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
qiniu: {
|
||||
id: 'qiniu',
|
||||
name: 'Qiniu',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.qnaigc.com',
|
||||
models: SYSTEM_MODELS.qiniu,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
openrouter: {
|
||||
id: 'openrouter',
|
||||
name: 'OpenRouter',
|
||||
@@ -600,6 +634,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
models: SYSTEM_MODELS['poe'],
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
longcat: {
|
||||
id: 'longcat',
|
||||
name: 'LongCat',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.longcat.chat/openai',
|
||||
models: SYSTEM_MODELS.longcat,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -616,6 +660,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
yi: ZeroOneProviderLogo,
|
||||
groq: GroqProviderLogo,
|
||||
zhipu: ZhipuProviderLogo,
|
||||
ovms: IntelOvmsLogo,
|
||||
ollama: OllamaProviderLogo,
|
||||
lmstudio: LMStudioProviderLogo,
|
||||
moonshot: MoonshotProviderLogo,
|
||||
@@ -661,7 +706,9 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
vertexai: VertexAIProviderLogo,
|
||||
'new-api': NewAPIProviderLogo,
|
||||
'aws-bedrock': AwsProviderLogo,
|
||||
poe: 'poe' // use svg icon component
|
||||
poe: 'poe', // use svg icon component
|
||||
aionly: AiOnlyProviderLogo,
|
||||
longcat: LongCatProviderLogo
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@@ -685,16 +732,17 @@ type ProviderUrls = {
|
||||
}
|
||||
|
||||
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
cherryin: {
|
||||
api: {
|
||||
url: 'https://api.cherry-ai.com'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://cherry-ai.com',
|
||||
docs: 'https://docs.cherry-ai.com',
|
||||
models: 'https://docs.cherry-ai.com/pre-basic/providers/cherryin'
|
||||
}
|
||||
},
|
||||
// cherryin: {
|
||||
// api: {
|
||||
// url: 'https://open.cherryin.ai'
|
||||
// },
|
||||
// websites: {
|
||||
// official: 'https://open.cherryin.ai',
|
||||
// apiKey: 'https://open.cherryin.ai/console/token',
|
||||
// docs: 'https://open.cherryin.ai',
|
||||
// models: 'https://open.cherryin.ai/pricing'
|
||||
// }
|
||||
// },
|
||||
ph8: {
|
||||
api: {
|
||||
url: 'https://ph8.co'
|
||||
@@ -998,6 +1046,16 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
models: 'https://console.groq.com/docs/models'
|
||||
}
|
||||
},
|
||||
ovms: {
|
||||
api: {
|
||||
url: 'http://localhost:8000/v3/'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://www.intel.com/content/www/us/en/developer/tools/openvino-toolkit/overview.html',
|
||||
docs: 'https://docs.openvino.ai/2025/model-server/ovms_what_is_openvino_model_server.html',
|
||||
models: 'https://www.modelscope.cn/organization/OpenVINO'
|
||||
}
|
||||
},
|
||||
ollama: {
|
||||
api: {
|
||||
url: 'http://localhost:11434'
|
||||
@@ -1020,7 +1078,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
},
|
||||
anthropic: {
|
||||
api: {
|
||||
url: 'https://api.anthropic.com/'
|
||||
url: 'https://api.anthropic.com'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://anthropic.com/',
|
||||
@@ -1255,6 +1313,28 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
docs: 'https://creator.poe.com/docs/external-applications/openai-compatible-api',
|
||||
models: 'https://poe.com/'
|
||||
}
|
||||
},
|
||||
aionly: {
|
||||
api: {
|
||||
url: 'https://api.aiionly.com'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://www.aiionly.com',
|
||||
apiKey: 'https://www.aiionly.com/keyApi',
|
||||
docs: 'https://www.aiionly.com/document',
|
||||
models: 'https://www.aiionly.com'
|
||||
}
|
||||
},
|
||||
longcat: {
|
||||
api: {
|
||||
url: 'https://api.longcat.chat/openai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://longcat.chat',
|
||||
apiKey: 'https://longcat.chat/platform/api_keys',
|
||||
docs: 'https://longcat.chat/platform/docs/zh/',
|
||||
models: 'https://longcat.chat/platform/docs/zh/APIDocs.html'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1340,3 +1420,7 @@ const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as con
|
||||
export const isGeminiWebSearchProvider = (provider: Provider) => {
|
||||
return SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS.some((id) => id === provider.id)
|
||||
}
|
||||
|
||||
export const isNewApiProvider = (provider: Provider) => {
|
||||
return ['new-api', 'cherryin'].includes(provider.id)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@renderer/types'
|
||||
// Import necessary types for blocks and new message structure
|
||||
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { NotesTreeNode } from '@renderer/types/note'
|
||||
import { Dexie, type EntityTable } from 'dexie'
|
||||
|
||||
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
|
||||
@@ -24,7 +23,6 @@ export const db = new Dexie('CherryStudio', {
|
||||
quick_phrases: EntityTable<QuickPhrase, 'id'>
|
||||
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
|
||||
translate_languages: EntityTable<CustomTranslateLanguage, 'id'>
|
||||
notes_tree: EntityTable<{ id: string; tree: NotesTreeNode[] }, 'id'>
|
||||
}
|
||||
|
||||
db.version(1).stores({
|
||||
@@ -118,8 +116,7 @@ db.version(10).stores({
|
||||
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
|
||||
translate_languages: '&id, langCode',
|
||||
quick_phrases: 'id',
|
||||
message_blocks: 'id, messageId, file.id',
|
||||
notes_tree: '&id'
|
||||
message_blocks: 'id, messageId, file.id'
|
||||
})
|
||||
|
||||
export default db
|
||||
|
||||
@@ -172,7 +172,10 @@ export function useAssistant(id: string) {
|
||||
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
|
||||
[assistant, dispatch]
|
||||
),
|
||||
updateAssistant: useCallback((assistant: Partial<Assistant>) => dispatch(updateAssistant(assistant)), [dispatch]),
|
||||
updateAssistant: useCallback(
|
||||
(update: Partial<Omit<Assistant, 'id'>>) => dispatch(updateAssistant({ id, ...update })),
|
||||
[dispatch, id]
|
||||
),
|
||||
updateAssistantSettings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
setCurrentDirectory,
|
||||
setEnvironmentVariables,
|
||||
setSelectedCliTool,
|
||||
setSelectedModel
|
||||
setSelectedModel,
|
||||
setSelectedTerminal
|
||||
} from '@renderer/store/codeTools'
|
||||
import { Model } from '@renderer/types'
|
||||
import { codeTools } from '@shared/config/constant'
|
||||
@@ -35,6 +36,14 @@ export const useCodeTools = () => {
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
// 设置选择的终端
|
||||
const setTerminal = useCallback(
|
||||
(terminal: string) => {
|
||||
dispatch(setSelectedTerminal(terminal))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
// 设置环境变量
|
||||
const setEnvVars = useCallback(
|
||||
(envVars: string) => {
|
||||
@@ -99,12 +108,17 @@ export const useCodeTools = () => {
|
||||
const environmentVariables = codeToolsState?.environmentVariables?.[codeToolsState.selectedCliTool] || ''
|
||||
|
||||
// 检查是否可以启动(所有必需字段都已填写)
|
||||
const canLaunch = Boolean(codeToolsState.selectedCliTool && selectedModel && codeToolsState.currentDirectory)
|
||||
const canLaunch = Boolean(
|
||||
codeToolsState.selectedCliTool &&
|
||||
codeToolsState.currentDirectory &&
|
||||
(codeToolsState.selectedCliTool === codeTools.githubCopilotCli || selectedModel)
|
||||
)
|
||||
|
||||
return {
|
||||
// 状态
|
||||
selectedCliTool: codeToolsState.selectedCliTool,
|
||||
selectedModel: selectedModel,
|
||||
selectedTerminal: codeToolsState.selectedTerminal,
|
||||
environmentVariables: environmentVariables,
|
||||
directories: codeToolsState.directories,
|
||||
currentDirectory: codeToolsState.currentDirectory,
|
||||
@@ -113,6 +127,7 @@ export const useCodeTools = () => {
|
||||
// 操作函数
|
||||
setCliTool,
|
||||
setModel,
|
||||
setTerminal,
|
||||
setEnvVars,
|
||||
addDir,
|
||||
removeDir,
|
||||
|
||||
@@ -67,7 +67,8 @@ export const useKnowledge = (baseId: string) => {
|
||||
// 添加笔记
|
||||
const addNote = async (content: string) => {
|
||||
await dispatch(addNoteThunk(baseId, content))
|
||||
checkAllBases()
|
||||
// 确保数据库写入完成后再触发队列检查
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 100)
|
||||
}
|
||||
|
||||
// 添加URL
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import TabsService from '@renderer/services/TabsService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
@@ -14,6 +15,8 @@ import { clearWebviewState } from '@renderer/utils/webviewStateManager'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useNavbarPosition } from './useSettings'
|
||||
|
||||
let minAppsCache: LRUCache<string, MinAppType>
|
||||
|
||||
/**
|
||||
@@ -34,6 +37,7 @@ export const useMinappPopup = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
|
||||
const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
|
||||
const createLRUCache = useCallback(() => {
|
||||
return new LRUCache<string, MinAppType>({
|
||||
@@ -165,6 +169,33 @@ export const useMinappPopup = () => {
|
||||
dispatch(setMinappShow(false))
|
||||
}, [dispatch, minappShow, openedOneOffMinapp])
|
||||
|
||||
/** Smart open minapp that adapts to navbar position */
|
||||
const openSmartMinapp = useCallback(
|
||||
(config: MinAppType, keepAlive: boolean = false) => {
|
||||
if (isTopNavbar) {
|
||||
// For top navbar mode, need to add to cache first for temporary apps
|
||||
const cacheApp = minAppsCache.get(config.id)
|
||||
if (!cacheApp) {
|
||||
// Add temporary app to cache so MinAppPage can find it
|
||||
minAppsCache.set(config.id, config)
|
||||
}
|
||||
|
||||
// Set current minapp and show state
|
||||
dispatch(setCurrentMinappId(config.id))
|
||||
dispatch(setMinappShow(true))
|
||||
|
||||
// Then navigate to the app tab using NavigationService
|
||||
if (NavigationService.navigate) {
|
||||
NavigationService.navigate(`/apps/${config.id}`)
|
||||
}
|
||||
} else {
|
||||
// For side navbar, use the traditional popup system
|
||||
openMinapp(config, keepAlive)
|
||||
}
|
||||
},
|
||||
[isTopNavbar, openMinapp, dispatch]
|
||||
)
|
||||
|
||||
return {
|
||||
openMinapp,
|
||||
openMinappKeepAlive,
|
||||
@@ -172,6 +203,7 @@ export const useMinappPopup = () => {
|
||||
closeMinapp,
|
||||
hideMinappPopup,
|
||||
closeAllMinapps,
|
||||
openSmartMinapp,
|
||||
// Expose cache instance for TabsService integration
|
||||
minAppsCache
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import store from '@renderer/store'
|
||||
|
||||
import { useProviders } from './useProvider'
|
||||
import { getStoreProviders } from './useStore'
|
||||
|
||||
export function useModel(id?: string, providerId?: string) {
|
||||
const { providers } = useProviders()
|
||||
@@ -15,7 +14,7 @@ export function useModel(id?: string, providerId?: string) {
|
||||
}
|
||||
|
||||
export function getModel(id?: string, providerId?: string) {
|
||||
const providers = store.getState().llm.providers
|
||||
const providers = getStoreProviders()
|
||||
const allModels = providers.map((p) => p.models).flat()
|
||||
return allModels.find((m) => {
|
||||
if (providerId) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { CHERRYAI_PROVIDER } from '@renderer/config/providers'
|
||||
import { getDefaultProvider } from '@renderer/services/AssistantService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ import { useDefaultModel } from './useAssistant'
|
||||
|
||||
const selectEnabledProviders = createSelector(
|
||||
(state) => state.llm.providers,
|
||||
(providers) => providers.filter((p) => p.enabled)
|
||||
(providers) => providers.filter((p) => p.enabled).concat(CHERRYAI_PROVIDER)
|
||||
)
|
||||
|
||||
export function useProviders() {
|
||||
@@ -24,7 +25,7 @@ export function useProviders() {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
providers: providers || {},
|
||||
providers: providers || [],
|
||||
addProvider: (provider: Provider) => dispatch(addProvider(provider)),
|
||||
removeProvider: (provider: Provider) => dispatch(removeProvider(provider)),
|
||||
updateProvider: (updates: Partial<Provider> & { id: string }) => dispatch(updateProvider(updates)),
|
||||
@@ -45,7 +46,9 @@ export function useAllProviders() {
|
||||
}
|
||||
|
||||
export function useProvider(id: string) {
|
||||
const provider = useAppSelector((state) => state.llm.providers.find((p) => p.id === id)) || getDefaultProvider()
|
||||
const provider =
|
||||
useAppSelector((state) => state.llm.providers.concat([CHERRYAI_PROVIDER]).find((p) => p.id === id)) ||
|
||||
getDefaultProvider()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user