Compare commits
82 Commits
feat/backu
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86b7eecd09 | ||
|
|
acb88bbb5b | ||
|
|
d07ed1576d | ||
|
|
c265577330 | ||
|
|
521051877d | ||
|
|
16252a6263 | ||
|
|
8e51f6c598 | ||
|
|
1981248b42 | ||
|
|
e5f000733f | ||
|
|
cafd40bc1c | ||
|
|
e46a45f409 | ||
|
|
790bb923e8 | ||
|
|
2529662a29 | ||
|
|
cd26190fe9 | ||
|
|
c2a78b4129 | ||
|
|
1c3e1b5954 | ||
|
|
261cfab2f0 | ||
|
|
36b67c6917 | ||
|
|
ac319b3574 | ||
|
|
91050899c4 | ||
|
|
156ceca4a7 | ||
|
|
c3b0beb37f | ||
|
|
f71ce7fe3d | ||
|
|
7b10ff5010 | ||
|
|
e4036b6991 | ||
|
|
701903d1e0 | ||
|
|
a2004082af | ||
|
|
b8c435138b | ||
|
|
d869c5750a | ||
|
|
3f4d34f6ae | ||
|
|
1da1f10462 | ||
|
|
9e65eae81a | ||
|
|
a9a16ceb3e | ||
|
|
a91c35de32 | ||
|
|
9f5355b455 | ||
|
|
1ee2a98e5f | ||
|
|
ac2a2b2e3a | ||
|
|
a03c1346a4 | ||
|
|
b310527210 | ||
|
|
ab78ef71d9 | ||
|
|
0d86e16e28 | ||
|
|
a8600a5426 | ||
|
|
e208d60c10 | ||
|
|
6968302961 | ||
|
|
dd65fa2f71 | ||
|
|
679043f26b | ||
|
|
6a1fb9bc7e | ||
|
|
07619c11e5 | ||
|
|
b71ea99738 | ||
|
|
5a29e45b88 | ||
|
|
45195bb57d | ||
|
|
bb003c071c | ||
|
|
80c0fca963 | ||
|
|
c5629da46d | ||
|
|
8b16170b16 | ||
|
|
182321a1bd | ||
|
|
cacabfa56d | ||
|
|
aaef458010 | ||
|
|
43dff80211 | ||
|
|
1dfae45a12 | ||
|
|
26c7dd9976 | ||
|
|
5ce9209334 | ||
|
|
0502ff48f1 | ||
|
|
726b2570e2 | ||
|
|
a33e3971d9 | ||
|
|
98faa80835 | ||
|
|
49deece835 | ||
|
|
eccdd7643e | ||
|
|
7c8894616c | ||
|
|
170632a199 | ||
|
|
cd5841cdd4 | ||
|
|
763afc5ca2 | ||
|
|
45f033ff4e | ||
|
|
f8fadcc73f | ||
|
|
a94e5dad5f | ||
|
|
632fd4c567 | ||
|
|
401e17eb0e | ||
|
|
80fc118465 | ||
|
|
9a8d7640f5 | ||
|
|
2b3f6d5640 | ||
|
|
a2d81e6204 | ||
|
|
b6107c5fb1 |
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,5 @@
|
||||
/src/renderer/src/store/ @0xfullex
|
||||
/src/renderer/src/databases/ @0xfullex
|
||||
/src/main/services/ConfigManager.ts @0xfullex
|
||||
/packages/shared/IpcChannel.ts @0xfullex
|
||||
/src/main/ipc.ts @0xfullex
|
||||
/src/main/ipc.ts @0xfullex
|
||||
|
||||
252
.github/issue-checker.yml
vendored
@@ -1,252 +0,0 @@
|
||||
default-mode:
|
||||
add:
|
||||
remove: [pull_request_target, issues]
|
||||
|
||||
labels:
|
||||
# <!-- [Ss]kip `LABEL` --> 跳过一个 label
|
||||
# <!-- [Rr]emove `LABEL` --> 去掉一个 label
|
||||
|
||||
# skips and removes
|
||||
- name: skip all
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Aa]ll |)[Ll]abels?'
|
||||
- name: remove all
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Aa]ll |)[Ll]abels?'
|
||||
|
||||
- name: skip kind/bug
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
|
||||
- name: remove kind/bug
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
|
||||
|
||||
- name: skip kind/enhancement
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
|
||||
- name: remove kind/enhancement
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
|
||||
|
||||
- name: skip kind/question
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
|
||||
- name: remove kind/question
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
|
||||
|
||||
- name: skip area/Connectivity
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
|
||||
- name: remove area/Connectivity
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
|
||||
|
||||
- name: skip area/UI/UX
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
|
||||
- name: remove area/UI/UX
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
|
||||
|
||||
- name: skip kind/documentation
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
|
||||
- name: remove kind/documentation
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
|
||||
|
||||
- name: skip client:linux
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
|
||||
- name: remove client:linux
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
|
||||
|
||||
- name: skip client:mac
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
|
||||
- name: remove client:mac
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
|
||||
|
||||
- name: skip client:win
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
|
||||
- name: remove client:win
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
|
||||
|
||||
- name: skip sig/Assistant
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
|
||||
- name: remove sig/Assistant
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
|
||||
|
||||
- name: skip sig/Data
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
|
||||
- name: remove sig/Data
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
|
||||
|
||||
- name: skip sig/MCP
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
|
||||
- name: remove sig/MCP
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
|
||||
|
||||
- name: skip sig/RAG
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
|
||||
- name: remove sig/RAG
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
|
||||
|
||||
- name: skip lgtm
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
|
||||
- name: remove lgtm
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
|
||||
|
||||
- name: skip License
|
||||
content:
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)License(?:`|)'
|
||||
- name: remove License
|
||||
content:
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)License(?:`|)'
|
||||
|
||||
# `Dev Team`
|
||||
- name: Dev Team
|
||||
mode:
|
||||
add: [pull_request_target, issues]
|
||||
author_association:
|
||||
- COLLABORATOR
|
||||
|
||||
# Area labels
|
||||
- name: area/Connectivity
|
||||
content: area/Connectivity
|
||||
regexes: '代理|[Pp]roxy'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip area/Connectivity
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove area/Connectivity
|
||||
|
||||
- name: area/UI/UX
|
||||
content: area/UI/UX
|
||||
regexes: '界面|[Uu][Ii]|重叠|按钮|图标|组件|渲染|菜单|栏目|头像|主题|样式|[Cc][Ss][Ss]'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip area/UI/UX
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove area/UI/UX
|
||||
|
||||
# Kind labels
|
||||
- name: kind/documentation
|
||||
content: kind/documentation
|
||||
regexes: '文档|教程|[Dd]oc(s|umentation)|[Rr]eadme'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip kind/documentation
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove kind/documentation
|
||||
|
||||
# Client labels
|
||||
- name: client:linux
|
||||
content: client:linux
|
||||
regexes: '(?:[Ll]inux|[Uu]buntu|[Dd]ebian)'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip client:linux
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove client:linux
|
||||
|
||||
- name: client:mac
|
||||
content: client:mac
|
||||
regexes: '(?:[Mm]ac|[Mm]acOS|[Oo]SX)'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip client:mac
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove client:mac
|
||||
|
||||
- name: client:win
|
||||
content: client:win
|
||||
regexes: '(?:[Ww]in|[Ww]indows)'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip client:win
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove client:win
|
||||
|
||||
# SIG labels
|
||||
- name: sig/Assistant
|
||||
content: sig/Assistant
|
||||
regexes: '快捷助手|[Aa]ssistant'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip sig/Assistant
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove sig/Assistant
|
||||
|
||||
- name: sig/Data
|
||||
content: sig/Data
|
||||
regexes: '[Ww]ebdav|坚果云|备份|同步|数据|Obsidian|Notion|Joplin|思源'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip sig/Data
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove sig/Data
|
||||
|
||||
- name: sig/MCP
|
||||
content: sig/MCP
|
||||
regexes: '[Mm][Cc][Pp]'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip sig/MCP
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove sig/MCP
|
||||
|
||||
- name: sig/RAG
|
||||
content: sig/RAG
|
||||
regexes: '知识库|[Rr][Aa][Gg]'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip sig/RAG
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove sig/RAG
|
||||
|
||||
# Other labels
|
||||
- name: lgtm
|
||||
content: lgtm
|
||||
regexes: '(?:[Ll][Gg][Tt][Mm]|[Ll]ooks [Gg]ood [Tt]o [Mm]e)'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip lgtm
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove lgtm
|
||||
|
||||
- name: License
|
||||
content: License
|
||||
regexes: '(?:[Ll]icense|[Cc]opyright|[Mm][Ii][Tt]|[Aa]pache)'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip License
|
||||
remove-if:
|
||||
- remove all
|
||||
- remove License
|
||||
12
.github/pull_request_template.md
vendored
@@ -3,6 +3,18 @@
|
||||
1. Consider creating this PR as draft: https://github.com/CherryHQ/cherry-studio/blob/main/CONTRIBUTING.md
|
||||
-->
|
||||
|
||||
<!--
|
||||
|
||||
⚠️ Important: Redux/IndexedDB Data-Changing Feature PRs Temporarily On Hold ⚠️
|
||||
|
||||
Please note: For our current development cycle, we are not accepting feature Pull Requests that introduce changes to Redux data models or IndexedDB schemas.
|
||||
|
||||
While we value your contributions, PRs of this nature will be blocked without merge. We welcome all other contributions (bug fixes, perf enhancements, docs, etc.). Thank you!
|
||||
|
||||
Once version 2.0.0 is released, we will resume reviewing feature PRs.
|
||||
|
||||
-->
|
||||
|
||||
### What this PR does
|
||||
|
||||
Before this PR:
|
||||
|
||||
5
.github/workflows/auto-i18n.yml
vendored
@@ -13,7 +13,7 @@ on:
|
||||
jobs:
|
||||
auto-i18n:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.head.repo.full_name == 'CherryHQ/cherry-studio'
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == 'CherryHQ/cherry-studio'
|
||||
name: Auto I18N
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -29,13 +29,14 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
package-manager-cache: false
|
||||
|
||||
- name: 📦 Install dependencies in isolated directory
|
||||
run: |
|
||||
# 在临时目录安装依赖
|
||||
mkdir -p /tmp/translation-deps
|
||||
cd /tmp/translation-deps
|
||||
echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "@biomejs/biome": "2.2.4"}}' > package.json
|
||||
echo '{"dependencies": {"@cherrystudio/openai": "^6.5.0", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "@biomejs/biome": "2.2.4"}}' > package.json
|
||||
npm install --no-package-lock
|
||||
|
||||
# 设置 NODE_PATH 让项目能找到这些依赖
|
||||
|
||||
1
.github/workflows/delete-branch.yml
vendored
@@ -13,6 +13,7 @@ jobs:
|
||||
steps:
|
||||
- name: Delete merged branch
|
||||
uses: actions/github-script@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
script: |
|
||||
github.rest.git.deleteRef({
|
||||
|
||||
187
.github/workflows/github-issue-tracker.yml
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
name: GitHub Issue Tracker with Feishu Notification
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
schedule:
|
||||
# Run every day at 8:30 Beijing Time (00:30 UTC)
|
||||
- cron: '30 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
process-new-issue:
|
||||
if: github.event_name == 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check Beijing Time
|
||||
id: check_time
|
||||
run: |
|
||||
# Get current time in Beijing timezone (UTC+8)
|
||||
BEIJING_HOUR=$(TZ='Asia/Shanghai' date +%H)
|
||||
BEIJING_MINUTE=$(TZ='Asia/Shanghai' date +%M)
|
||||
|
||||
echo "Beijing Time: ${BEIJING_HOUR}:${BEIJING_MINUTE}"
|
||||
|
||||
# Check if time is between 00:00 and 08:30
|
||||
if [ $BEIJING_HOUR -lt 8 ] || ([ $BEIJING_HOUR -eq 8 ] && [ $BEIJING_MINUTE -le 30 ]); then
|
||||
echo "should_delay=true" >> $GITHUB_OUTPUT
|
||||
echo "⏰ Issue created during quiet hours (00:00-08:30 Beijing Time)"
|
||||
echo "Will schedule notification for 08:30"
|
||||
else
|
||||
echo "should_delay=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ Issue created during active hours, will notify immediately"
|
||||
fi
|
||||
|
||||
- name: Add pending label if in quiet hours
|
||||
if: steps.check_time.outputs.should_delay == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['pending-feishu-notification']
|
||||
});
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.check_time.outputs.should_delay == 'false'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Process issue with Claude
|
||||
if: steps.check_time.outputs.should_delay == 'false'
|
||||
uses: anthropics/claude-code-action@main
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
|
||||
claude_args: "--allowed-tools Bash(gh issue:*),Bash(node scripts/feishu-notify.js)"
|
||||
prompt: |
|
||||
你是一个GitHub Issue自动化处理助手。请完成以下任务:
|
||||
|
||||
## 当前Issue信息
|
||||
- Issue编号:#${{ github.event.issue.number }}
|
||||
- 标题:${{ github.event.issue.title }}
|
||||
- 作者:${{ github.event.issue.user.login }}
|
||||
- URL:${{ github.event.issue.html_url }}
|
||||
- 内容:${{ github.event.issue.body }}
|
||||
- 标签:${{ join(github.event.issue.labels.*.name, ', ') }}
|
||||
|
||||
## 任务步骤
|
||||
|
||||
1. **分析并总结issue**
|
||||
用中文(简体)提供简洁的总结(2-3句话),包括:
|
||||
- 问题的主要内容
|
||||
- 核心诉求
|
||||
- 重要的技术细节
|
||||
|
||||
2. **发送飞书通知**
|
||||
使用以下命令发送飞书通知(注意:ISSUE_SUMMARY需要用引号包裹):
|
||||
```bash
|
||||
ISSUE_URL="${{ github.event.issue.html_url }}" \
|
||||
ISSUE_NUMBER="${{ github.event.issue.number }}" \
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}" \
|
||||
ISSUE_AUTHOR="${{ github.event.issue.user.login }}" \
|
||||
ISSUE_LABELS="${{ join(github.event.issue.labels.*.name, ',') }}" \
|
||||
ISSUE_SUMMARY="<你生成的中文总结>" \
|
||||
node scripts/feishu-notify.js
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
- 总结必须使用简体中文
|
||||
- ISSUE_SUMMARY 在传递给 node 命令时需要正确转义特殊字符
|
||||
- 如果issue内容为空,也要提供一个简短的说明
|
||||
|
||||
请开始执行任务!
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}
|
||||
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
|
||||
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
|
||||
|
||||
process-pending-issues:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Process pending issues with Claude
|
||||
uses: anthropics/claude-code-action@main
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
|
||||
allowed_non_write_users: "*"
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:*),Bash(node scripts/feishu-notify.js)"
|
||||
prompt: |
|
||||
你是一个GitHub Issue自动化处理助手。请完成以下任务:
|
||||
|
||||
## 任务说明
|
||||
处理所有待发送飞书通知的GitHub Issues(标记为 `pending-feishu-notification` 的issues)
|
||||
|
||||
## 步骤
|
||||
|
||||
1. **获取待处理的issues**
|
||||
使用以下命令获取所有带 `pending-feishu-notification` 标签的issues:
|
||||
```bash
|
||||
gh api repos/${{ github.repository }}/issues?labels=pending-feishu-notification&state=open
|
||||
```
|
||||
|
||||
2. **总结每个issue**
|
||||
对于每个找到的issue,用中文提供简洁的总结(2-3句话),包括:
|
||||
- 问题的主要内容
|
||||
- 核心诉求
|
||||
- 重要的技术细节
|
||||
|
||||
3. **发送飞书通知**
|
||||
对于每个issue,使用以下命令发送飞书通知:
|
||||
```bash
|
||||
ISSUE_URL="<issue的html_url>" \
|
||||
ISSUE_NUMBER="<issue编号>" \
|
||||
ISSUE_TITLE="<issue标题>" \
|
||||
ISSUE_AUTHOR="<issue作者>" \
|
||||
ISSUE_LABELS="<逗号分隔的标签列表,排除pending-feishu-notification>" \
|
||||
ISSUE_SUMMARY="<你生成的中文总结>" \
|
||||
node scripts/feishu-notify.js
|
||||
```
|
||||
|
||||
4. **移除标签**
|
||||
成功发送后,使用以下命令移除 `pending-feishu-notification` 标签:
|
||||
```bash
|
||||
gh api -X DELETE repos/${{ github.repository }}/issues/<issue编号>/labels/pending-feishu-notification
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
- Repository: ${{ github.repository }}
|
||||
- Feishu webhook URL和密钥已在环境变量中配置好
|
||||
|
||||
## 注意事项
|
||||
- 如果没有待处理的issues,输出提示信息后直接结束
|
||||
- 处理多个issues时,每个issue之间等待2-3秒,避免API限流
|
||||
- 如果某个issue处理失败,继续处理下一个,不要中断整个流程
|
||||
- 所有总结必须使用中文(简体中文)
|
||||
|
||||
请开始执行任务!
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}
|
||||
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
|
||||
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
|
||||
25
.github/workflows/issue-checker.yml
vendored
@@ -1,25 +0,0 @@
|
||||
name: 'Issue Checker'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: MaaAssistantArknights/issue-checker@v1.14
|
||||
with:
|
||||
repo-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
configuration-path: .github/issue-checker.yml
|
||||
not-before: 2022-08-05T00:00:00Z
|
||||
include-title: 1
|
||||
6
.github/workflows/issue-management.yml
vendored
@@ -29,8 +29,10 @@ jobs:
|
||||
days-before-close: 0 # Close immediately after stale
|
||||
stale-issue-label: 'inactive'
|
||||
close-issue-label: 'closed:no-response'
|
||||
exempt-all-milestones: true
|
||||
exempt-all-assignees: true
|
||||
stale-issue-message: |
|
||||
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
|
||||
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
|
||||
It will be closed now due to lack of additional information.
|
||||
|
||||
该问题被标记为"需要更多信息"且已经 ${{ env.daysBeforeStale }} 天没有任何活动,将立即关闭。
|
||||
@@ -46,6 +48,8 @@ jobs:
|
||||
days-before-stale: ${{ env.daysBeforeStale }}
|
||||
days-before-close: ${{ env.daysBeforeClose }}
|
||||
stale-issue-label: 'inactive'
|
||||
exempt-all-milestones: true
|
||||
exempt-all-assignees: true
|
||||
stale-issue-message: |
|
||||
This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days.
|
||||
该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
|
||||
index b957cb824faa79cf01ba3a504f221870bd8e306a..4d71d30f655775d61537d9d8b73f6e17d41fa67e 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
@@ -452,7 +452,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
|
||||
// src/get-model-path.ts
|
||||
function getModelPath(modelId) {
|
||||
@@ -65,7 +65,28 @@ The Test Plan aims to provide users with a more stable application experience an
|
||||
### Other Suggestions
|
||||
|
||||
- **Contact Developers**: Before submitting a PR, you can contact the developers first to discuss or get help.
|
||||
- **Become a Core Developer**: If you contribute to the project consistently, congratulations, you can become a core developer and gain project membership status. Please check our [Membership Guide](https://github.com/CherryHQ/community/blob/main/docs/membership.en.md).
|
||||
|
||||
## Important Contribution Guidelines & Focus Areas
|
||||
|
||||
Please review the following critical information before submitting your Pull Request:
|
||||
|
||||
### Temporary Restriction on Data-Changing Feature PRs 🚫
|
||||
|
||||
**Currently, we are NOT accepting feature Pull Requests that introduce changes to our Redux data models or IndexedDB schemas.**
|
||||
|
||||
Our core team is currently focused on significant architectural updates that involve these data structures. To ensure stability and focus during this period, contributions of this nature will be temporarily managed internally.
|
||||
|
||||
* **PRs that require changes to Redux state shape or IndexedDB schemas will be closed.**
|
||||
* **This restriction is temporary and will be lifted with the release of `v2.0.0`.** You can track the progress of `v2.0.0` and its related discussions on issue [#10162](https://github.com/CherryHQ/cherry-studio/pull/10162).
|
||||
|
||||
We highly encourage contributions for:
|
||||
* Bug fixes 🐞
|
||||
* Performance improvements 🚀
|
||||
* Documentation updates 📚
|
||||
* Features that **do not** alter Redux data models or IndexedDB schemas (e.g., UI enhancements, new components, minor refactors). ✨
|
||||
|
||||
We appreciate your understanding and continued support during this important development phase. Thank you!
|
||||
|
||||
|
||||
## Contact Us
|
||||
|
||||
|
||||
677
LICENSE
@@ -1,42 +1,661 @@
|
||||
**Licensing**
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
This project employs a **User-Segmented Dual Licensing** model.
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
**Core Principle:**
|
||||
Preamble
|
||||
|
||||
* **Individual Users and Organizations with 10 or Fewer Individuals:** Governed by default under the **GNU Affero General Public License v3.0 (AGPLv3)**.
|
||||
* **Organizations with More Than 10 Individuals:** **Must** obtain a **Commercial License**.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
Definition: "10 or Fewer Individuals"
|
||||
Refers to any organization (including companies, non-profits, government agencies, educational institutions, etc.) where the total number of individuals who can access, use, or in any way directly or indirectly benefit from the functionality of this software (Cherry Studio) does not exceed 10. This includes, but is not limited to, developers, testers, operations staff, end-users, and indirect users via integrated systems.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
---
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
**1. Open Source License: AGPLv3 - For Individuals and Organizations of 10 or Fewer**
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
* If you are an individual user, or if your organization meets the "10 or Fewer Individuals" definition above, you are free to use, modify, and distribute Cherry Studio under the terms of the **AGPLv3**. The full text of the AGPLv3 can be found in the LICENSE file at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
* **Core Obligation:** A key requirement of the AGPLv3 is that if you modify Cherry Studio and make it available over a network, or distribute the modified version, you must provide the **complete corresponding source code** under the AGPLv3 license to the recipients. Even if you qualify under the "10 or Fewer Individuals" rule, if you wish to avoid this source code disclosure obligation, you will need to obtain a Commercial License (see below).
|
||||
* Please read and understand the full terms of the AGPLv3 carefully before use.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
**2. Commercial License - For Organizations with More Than 10 Individuals, or Users Needing to Avoid AGPLv3 Obligations**
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
* **Mandatory Requirement:** If your organization does **not** meet the "10 or Fewer Individuals" definition above (i.e., 11 or more individuals can access, use, or benefit from the software), you **must** contact us to obtain and execute a Commercial License to use Cherry Studio.
|
||||
* **Voluntary Option:** Even if your organization meets the "10 or Fewer Individuals" condition, if your intended use case **cannot comply with the terms of the AGPLv3** (particularly the obligations regarding **source code disclosure**), or if you require specific commercial terms **not offered** by the AGPLv3 (such as warranties, indemnities, or freedom from copyleft restrictions), you also **must** contact us to obtain and execute a Commercial License.
|
||||
* **Common scenarios requiring a Commercial License include (but are not limited to):**
|
||||
* Your organization has more than 10 individuals who can access, use, or benefit from the software.
|
||||
* (Regardless of organization size) You wish to distribute a modified version of Cherry Studio but **do not want** to disclose the source code of your modifications under AGPLv3.
|
||||
* (Regardless of organization size) You wish to provide a network service (SaaS) based on a modified version of Cherry Studio but **do not want** to provide the modified source code to users of the service under AGPLv3.
|
||||
* (Regardless of organization size) Your corporate policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software or mandate closed-source distribution and confidentiality.
|
||||
* The Commercial License grants you rights exempting you from AGPLv3 obligations (like source code disclosure) and may include additional commercial assurances.
|
||||
* **Obtaining a Commercial License:** Please contact the Cherry Studio development team via email at **bd@cherry-ai.com** to discuss commercial licensing options.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
**3. Contributions**
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
* We welcome community contributions to Cherry Studio. All contributions submitted to this project are considered to be offered under the **AGPLv3** license.
|
||||
* By submitting a contribution to this project (e.g., via a Pull Request), you agree to license your code under the AGPLv3 to the project and all its downstream users (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License).
|
||||
* You also understand and agree that your contribution may be included in distributions of Cherry Studio offered under our commercial license.
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
**4. Other Terms**
|
||||
0. Definitions.
|
||||
|
||||
* The specific terms and conditions of the Commercial License are governed by the formal commercial license agreement signed by both parties.
|
||||
* The project maintainers reserve the right to update this licensing policy (including the definition and threshold for user count) as needed. Updates will be communicated through official project channels (e.g., code repository, official website).
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
17
README.md
@@ -37,7 +37,7 @@
|
||||
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us">Documents</a> | <a href="./docs/dev.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
[![][deepwiki-shield]][deepwiki-link]
|
||||
[![][twitter-shield]][twitter-link]
|
||||
[![][discord-shield]][discord-link]
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
</div>
|
||||
<div align="center">
|
||||
|
||||
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-nightly-shield]][github-nightly-link]
|
||||
[![][github-contributors-shield]][github-contributors-link]
|
||||
@@ -141,6 +141,7 @@ We're actively working on the following features and improvements:
|
||||
- iOS App (Phase 1)
|
||||
- Multi-Window support
|
||||
- Window Pinning functionality
|
||||
- Intel AI PC (Core Ultra) Support
|
||||
|
||||
4. 🔌 **Advanced Features**
|
||||
|
||||
@@ -247,10 +248,10 @@ The Enterprise Edition addresses core challenges in team collaboration by centra
|
||||
|
||||
| Feature | Community Edition | Enterprise Edition |
|
||||
| :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Open Source** | ✅ Yes | ⭕️ Partially released to customers |
|
||||
| **Open Source** | ✅ Yes | ⭕️ Partially released to customers |
|
||||
| **Cost** | Free for Personal Use / Commercial License | Buyout / Subscription Fee |
|
||||
| **Admin Backend** | — | ● Centralized **Model** Access<br>● **Employee** Management<br>● Shared **Knowledge Base**<br>● **Access** Control<br>● **Data** Backup |
|
||||
| **Server** | — | ✅ Dedicated Private Deployment |
|
||||
| **Server** | — | ✅ Dedicated Private Deployment |
|
||||
|
||||
## Get the Enterprise Edition
|
||||
|
||||
@@ -286,6 +287,14 @@ We believe the Enterprise Edition will become your team's AI productivity engine
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
# 📜 License
|
||||
|
||||
The Cherry Studio Community Edition is governed by the standard GNU Affero General Public License v3.0 (AGPL-3.0), available at https://www.gnu.org/licenses/agpl-3.0.html.
|
||||
|
||||
Use of the Cherry Studio Community Edition for commercial purposes is permitted, subject to full compliance with the terms and conditions of the AGPL-3.0 license.
|
||||
|
||||
Should you require a commercial license that provides an exemption from the AGPL-3.0 requirements, please contact us at bd@cherry-ai.com.
|
||||
|
||||
<!-- Links & Images -->
|
||||
|
||||
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNy45MyAzMiI+PHBhdGggZD0iTTE5LjMzIDE0LjEyYy42Ny0uMzkgMS41LS4zOSAyLjE4IDBsMS43NCAxYy4wNi4wMy4xMS4wNi4xOC4wN2guMDRjLjA2LjAzLjEyLjAzLjE4LjAzaC4wMmMuMDYgMCAuMTEgMCAuMTctLjAyaC4wM2MuMDYtLjAyLjEyLS4wNS4xNy0uMDhoLjAybDMuNDgtMi4wMWMuMjUtLjE0LjQtLjQxLjQtLjdWOC40YS44MS44MSAwIDAgMC0uNC0uN2wtMy40OC0yLjAxYS44My44MyAwIDAgMC0uODEgMEwxOS43NyA3LjdoLS4wMWwtLjE1LjEyLS4wMi4wMnMtLjA3LjA5LS4xLjE0VjhhLjQuNCAwIDAgMC0uMDguMTd2LjA0Yy0uMDMuMDYtLjAzLjEyLS4wMy4xOXYyLjAxYzAgLjc4LS40MSAxLjQ5LTEuMDkgMS44OC0uNjcuMzktMS41LjM5LTIuMTggMGwtMS43NC0xYS42LjYgMCAwIDAtLjIxLS4wOGMtLjA2LS4wMS0uMTItLjAyLS4xOC0uMDJoLS4wM2MtLjA2IDAtLjExLjAxLS4xNy4wMmgtLjAzYy0uMDYuMDItLjEyLjA0LS4xNy4wN2gtLjAybC0zLjQ3IDIuMDFjLS4yNS4xNC0uNC40MS0uNC43VjE4YzAgLjI5LjE1LjU1LjQuN2wzLjQ4IDIuMDFoLjAyYy4wNi4wNC4xMS4wNi4xNy4wOGguMDNjLjA1LjAyLjExLjAzLjE3LjAzaC4wMmMuMDYgMCAuMTIgMCAuMTgtLjAyaC4wNGMuMDYtLjAzLjEyLS4wNS4xOC0uMDhsMS43NC0xYy42Ny0uMzkgMS41LS4zOSAyLjE3IDBzMS4wOSAxLjExIDEuMDkgMS44OHYyLjAxYzAgLjA3IDAgLjEzLjAyLjE5di4wNGMuMDMuMDYuMDUuMTIuMDguMTd2LjAycy4wOC4wOS4xMi4xM2wuMDIuMDJzLjA5LjA4LjE1LjExYzAgMCAuMDEgMCAuMDEuMDFsMy40OCAyLjAxYy4yNS4xNC41Ni4xNC44MSAwbDMuNDgtMi4wMWMuMjUtLjE0LjQtLjQxLjQtLjd2LTQuMDFhLjgxLjgxIDAgMCAwLS40LS43bC0zLjQ4LTIuMDFoLS4wMmMtLjA1LS4wNC0uMTEtLjA2LS4xNy0uMDhoLS4wM2EuNS41IDAgMCAwLS4xNy0uMDNoLS4wM2MtLjA2IDAtLjEyIDAtLjE4LjAyLS4wNy4wMi0uMTUuMDUtLjIxLjA4bC0xLjc0IDFjLS42Ny4zOS0xLjUuMzktMi4xNyAwYTIuMTkgMi4xOSAwIDAgMS0xLjA5LTEuODhjMC0uNzguNDItMS40OSAxLjA5LTEuODhaIiBzdHlsZT0iZmlsbDojNWRiZjlkIi8+PHBhdGggZD0ibS40IDEzLjExIDMuNDcgMi4wMWMuMjUuMTQuNTYuMTQuOCAwbDMuNDctMi4wMWguMDFsLjE1LS4xMi4wMi0uMDJzLjA3LS4wOS4xLS4xNGwuMDItLjAyYy4wMy0uMDUuMDUtLjExLjA3LS4xN3YtLjA0Yy4wMy0uMDYuMDMtLjEyLjAzLS4xOVYxMC40YzAtLjc4LjQyLTEuNDkgMS4wOS0xLjg4czEuNS0uMzkgMi4xOCAwbDEuNzQgMWMuMDcuMDQuMTQuMDcuMjEuMDguMDYuMDEuMTIuMDIuMTguMDJoLjAzYy4wNiAwIC4xMS0uMDEuMTctLjAyaC4wM2MuMDYtLjAyLjEyLS4wNC4xNy0uMDdoLjAybDMuNDctMi4wMmMuMjUtLjE0LjQtLjQxLjQtLjd2LTRhLjgxLjgxIDAgMCAwLS40LS43bC0zLjQ2LTJhLjgzLjgzIDAgMCAwLS44MSAwbC0zLjQ4IDIuMDFoLS4wMWwtLjE1LjEyLS4wMi4wMi0uMS4xMy0uMDIuMDJjLS4wMy4wNS0uMDUuMTEtLjA3LjE3di4wNGMtLjAzLjA2LS4wMy4xMi0uMDMuMTl2Mi4wMWMwIC43OC0uNDIgMS40OS0xLjA5IDEuODhzLTEuNS4zOS0yLjE4IDBsLTEuNzQtMWEuNi42IDAgMCAwLS4yMS0uMDhjLS4wNi0uMDEtLjEyLS4wMi0uMTgtLjAyaC0uMDNjLS4wNiAwLS4xMS4wMS0uMTcuMDJoLS4wM2MtLjA2LjAyLS4xMi4wNS0uMTcuMDhoLS4wMkwuNCA3LjcxYy0uMjUuMTQtLjQuNDEtLjQuNjl2NC4wMWMwIC4yOS4xNS41Ni40LjciIHN0eWxlPSJmaWxsOiM0NDY4YzQiLz48cGF0aCBkPSJtMTcuODQgMjQuNDgtMy40OC0yLjAxaC0uMDJjLS4wNS0uMDQtLjExLS4wNi0uMTctLjA4aC0uMDNhLjUuNSAwIDAgMC0uMTctLjAzaC0uMDNjLS4wNiAwLS4xMiAwLS4xOC4wMmgtLjA0Yy0uMDYuMDMtLjEyLjA1LS4xOC4wOGwtMS43NCAxYy0uNjcuMzktMS41LjM5LTIuMTggMGEyLjE5IDIuMTkgMCAwIDEtMS4wOS0xLjg4di0yLjAxYzAtLjA2IDAtLjEzLS4wMi0uMTl2LS4wNGMtLjAzLS4wNi0uMDUtLjExLS4wOC0uMTdsLS4wMi0uMDJzLS4wNi0uMDktLjEtLjEzTDguMjkgMTlzLS4wOS0uMDgtLjE1LS4xMWgtLjAxbC0zLjQ3LTIuMDJhLjgzLjgzIDAgMCAwLS44MSAwTC4zNyAxOC44OGEuODcuODcgMCAwIDAtLjM3LjcxdjQuMDFjMCAuMjkuMTUuNTUuNC43bDMuNDcgMi4wMWguMDJjLjA1LjA0LjExLjA2LjE3LjA4aC4wM2MuMDUuMDIuMTEuMDMuMTYuMDNoLjAzYy4wNiAwIC4xMiAwIC4xOC0uMDJoLjA0Yy4wNi0uMDMuMTItLjA1LjE4LS4wOGwxLjc0LTFjLjY3LS4zOSAxLjUtLjM5IDIuMTcgMHMxLjA5IDEuMTEgMS4wOSAxLjg4djIuMDFjMCAuMDcgMCAuMTMuMDIuMTl2LjA0Yy4wMy4wNi4wNS4xMS4wOC4xN2wuMDIuMDJzLjA2LjA5LjEuMTRsLjAyLjAycy4wOS4wOC4xNS4xMWguMDFsMy40OCAyLjAyYy4yNS4xNC41Ni4xNC44MSAwbDMuNDgtMi4wMWMuMjUtLjE0LjQtLjQxLjQtLjdWMjUuMmEuODEuODEgMCAwIDAtLjQtLjdaIiBzdHlsZT0iZmlsbDojNDI5M2Q5Ii8+PC9zdmc+
|
||||
|
||||
@@ -69,7 +69,28 @@ git commit --signoff -m "Your commit message"
|
||||
### 其他建议
|
||||
|
||||
- **联系开发者**:在提交 PR 之前,您可以先和开发者进行联系,共同探讨或者获取帮助。
|
||||
- **成为核心开发者**:如果您能够稳定为项目贡献,恭喜您可以成为项目核心开发者,获取到项目成员身份。请查看我们的[成员指南](https://github.com/CherryHQ/community/blob/main/membership.md)
|
||||
|
||||
## 重要贡献指南与关注点
|
||||
|
||||
在提交 Pull Request 之前,请务必阅读以下关键信息:
|
||||
|
||||
### 🚫 暂时限制涉及数据更改的功能性 PR
|
||||
|
||||
**目前,我们不接受涉及 Redux 数据模型或 IndexedDB schema 变更的功能性 Pull Request。**
|
||||
|
||||
我们的核心团队目前正专注于涉及这些数据结构的关键架构更新和基础工作。为确保在此期间的稳定性与专注,此类贡献将暂时由内部进行管理。
|
||||
|
||||
* **需要更改 Redux 状态结构或 IndexedDB schema 的 PR 将会被关闭。**
|
||||
* **此限制是临时性的,并将在 `v2.0.0` 版本发布后解除。** 您可以通过 Issue [#10162](https://github.com/CherryHQ/cherry-studio/pull/10162) 跟踪 `v2.0.0` 的进展及相关讨论。
|
||||
|
||||
我们非常鼓励以下类型的贡献:
|
||||
* 错误修复 🐞
|
||||
* 性能改进 🚀
|
||||
* 文档更新 📚
|
||||
* 不改变 Redux 数据模型或 IndexedDB schema 的功能(例如,UI 增强、新组件、小型重构)。✨
|
||||
|
||||
感谢您在此重要开发阶段的理解与持续支持。谢谢!
|
||||
|
||||
|
||||
## 联系我们
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ electronLanguages:
|
||||
- zh_CN # for macOS
|
||||
- zh_TW # for macOS
|
||||
- en # for macOS
|
||||
- de
|
||||
directories:
|
||||
buildResources: build
|
||||
|
||||
@@ -125,21 +126,71 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
What's New in v1.6.3
|
||||
<!--LANG:en-->
|
||||
What's New in v1.6.6
|
||||
|
||||
Features:
|
||||
- Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming
|
||||
- UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow
|
||||
- Editor: Add read-only extension support, make TextFilePreview read-only but copyable
|
||||
- Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models
|
||||
- Code Tools: Add GitHub Copilot CLI integration
|
||||
- Add automatic update checks with interval support
|
||||
- Add confirmation modal for activating protocol-installed MCP servers
|
||||
- Add mobile app data restore functionality
|
||||
- Add doubao_after_251015 reasoning model support
|
||||
- Add cherryin provider type option
|
||||
- Add German language support
|
||||
- Enhance proxy bypass rules with comprehensive matching
|
||||
- Enhance model capabilities with endpoint type validation for Gemini provider
|
||||
|
||||
Bug Fixes:
|
||||
- Fix migration for missing providers
|
||||
- Fix forked topic retaining old name after rename
|
||||
- Restore first token latency reporting in metrics
|
||||
- Fix UI scrollbar and overflow issues
|
||||
- Fix knowledge base AISDK error handling
|
||||
- Fix toolchoice support for knowledge features
|
||||
- Fix Claude 4.5 reasoning model getTopP logic
|
||||
- Fix up-down button visibility issues
|
||||
- Fix in-place editing save behavior
|
||||
- Fix system prompt variables in quick assistant
|
||||
- Fix URL context support for Gemini endpoint models
|
||||
- Fix Silicon reasoning model handling
|
||||
- Fix deep research model context and reasoning effort settings
|
||||
- Fix file content paste via right-click
|
||||
- Fix reranker API error response handling
|
||||
- Fix UI layout for backup managers and navbar
|
||||
- Fix aihubmix model routing rules
|
||||
|
||||
Improvements:
|
||||
- Update LICENSE file with full GNU AGPL-3.0 text
|
||||
- Improve GitHub workflows and CI/CD processes
|
||||
- Update dependencies including Playwright testing framework
|
||||
|
||||
<!--LANG:zh-CN-->
|
||||
v1.6.6 版本更新
|
||||
|
||||
新增功能:
|
||||
- 新增自动更新检查和间隔支持
|
||||
- 新增协议安装 MCP 服务器激活确认弹窗
|
||||
- 新增移动应用数据恢复功能
|
||||
- 新增 doubao_after_251015 推理模型支持
|
||||
- 新增 cherryin 提供商类型选项
|
||||
- 新增德语语言支持
|
||||
- 增强代理绕过规则的全面匹配
|
||||
- 增强 Gemini 提供商的端点类型验证和模型能力
|
||||
|
||||
问题修复:
|
||||
- 修复知识库 AISDK 错误处理
|
||||
- 修复知识功能的工具选择支持
|
||||
- 修复 Claude 4.5 推理模型的 getTopP 逻辑
|
||||
- 修复上下按钮可见性问题
|
||||
- 修复就地编辑保存行为
|
||||
- 修复快速助手中的系统提示变量
|
||||
- 修复 Gemini 端点模型的 URL 上下文支持
|
||||
- 修复 Silicon 推理模型处理
|
||||
- 修复深度研究模型的上下文和推理努力设置
|
||||
- 修复右键粘贴文件内容功能
|
||||
- 修复重排序器 API 错误响应处理
|
||||
- 修复备份管理器和导航栏的 UI 布局
|
||||
- 修复 aihubmix 模型路由规则
|
||||
|
||||
改进优化:
|
||||
- 更新 LICENSE 文件为完整 GNU AGPL-3.0 文本
|
||||
- 改进 GitHub 工作流和 CI/CD 流程
|
||||
- 更新依赖项包括 Playwright 测试框架
|
||||
|
||||
<!--LANG:END-->
|
||||
|
||||
Technical Updates:
|
||||
- Upgrade to Electron 37.6.0
|
||||
- Update dependencies across packages
|
||||
|
||||
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.6",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -86,8 +86,10 @@
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.2.0",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"selection-hook": "^1.0.12",
|
||||
"sharp": "^0.34.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
@@ -268,6 +270,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^23.11.5",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"isbinaryfile": "5.0.4",
|
||||
"jaison": "^2.0.2",
|
||||
"jest-styled-components": "^7.2.0",
|
||||
@@ -290,7 +293,7 @@
|
||||
"p-queue": "^8.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"playwright": "^1.52.0",
|
||||
"playwright": "^1.55.1",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -325,6 +328,7 @@
|
||||
"string-width": "^7.2.0",
|
||||
"striptags": "^3.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"swr": "^2.3.6",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tar": "^7.4.3",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
@@ -369,7 +373,7 @@
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
|
||||
"@ai-sdk/google@npm:2.0.17": "patch:@ai-sdk/google@npm%3A2.0.17#~/.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -5,8 +5,8 @@ export enum IpcChannel {
|
||||
App_SetLanguage = 'app:set-language',
|
||||
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
|
||||
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
|
||||
App_ShowUpdateDialog = 'app:show-update-dialog',
|
||||
App_CheckForUpdate = 'app:check-for-update',
|
||||
App_QuitAndInstall = 'app:quit-and-install',
|
||||
App_Reload = 'app:reload',
|
||||
App_Quit = 'app:quit',
|
||||
App_Info = 'app:info',
|
||||
@@ -53,6 +53,7 @@ export enum IpcChannel {
|
||||
|
||||
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
||||
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
|
||||
Webview_SearchHotkey = 'webview:search-hotkey',
|
||||
|
||||
// Open
|
||||
Open_Path = 'open:path',
|
||||
@@ -229,7 +230,6 @@ export enum IpcChannel {
|
||||
// events
|
||||
BackupProgress = 'backup-progress',
|
||||
ThemeUpdated = 'theme:updated',
|
||||
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
||||
RestoreProgress = 'restore-progress',
|
||||
UpdateError = 'update-error',
|
||||
UpdateAvailable = 'update-available',
|
||||
@@ -331,6 +331,7 @@ export enum IpcChannel {
|
||||
|
||||
// OCR
|
||||
OCR_ocr = 'ocr:ocr',
|
||||
OCR_ListProviders = 'ocr:list-providers',
|
||||
|
||||
// OVMS
|
||||
Ovms_AddModel = 'ovms:add-model',
|
||||
@@ -342,5 +343,12 @@ export enum IpcChannel {
|
||||
Ovms_StopOVMS = 'ovms:stop-ovms',
|
||||
|
||||
// CherryAI
|
||||
Cherryai_GetSignature = 'cherryai:get-signature'
|
||||
Cherryai_GetSignature = 'cherryai:get-signature',
|
||||
|
||||
// WebSocket
|
||||
WebSocket_Start = 'webSocket:start',
|
||||
WebSocket_Stop = 'webSocket:stop',
|
||||
WebSocket_Status = 'webSocket:status',
|
||||
WebSocket_SendFile = 'webSocket:send-file',
|
||||
WebSocket_GetAllCandidates = 'webSocket:get-all-candidates'
|
||||
}
|
||||
|
||||
@@ -22,3 +22,25 @@ export type MCPProgressEvent = {
|
||||
callId: string
|
||||
progress: number // 0-1 range
|
||||
}
|
||||
|
||||
export type WebviewKeyEvent = {
|
||||
webviewId: number
|
||||
key: string
|
||||
control: boolean
|
||||
meta: boolean
|
||||
shift: boolean
|
||||
alt: boolean
|
||||
}
|
||||
|
||||
export interface WebSocketStatusResponse {
|
||||
isRunning: boolean
|
||||
port?: number
|
||||
ip?: string
|
||||
clientConnected: boolean
|
||||
}
|
||||
|
||||
export interface WebSocketCandidatesResponse {
|
||||
host: string
|
||||
interface: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
@@ -5,105 +5,171 @@ 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}`]
|
||||
const OVMS_RELEASE_BASE_URL =
|
||||
'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.3.0/ovms_windows_python_on.zip'
|
||||
const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.3_ex.zip'
|
||||
|
||||
/**
|
||||
* Downloads and extracts the OVMS binary for the specified platform
|
||||
* error code:
|
||||
* 101: Unsupported CPU (not Intel Ultra)
|
||||
* 102: Unsupported platform (not Windows)
|
||||
* 103: Download failed
|
||||
* 104: Installation failed
|
||||
* 105: Failed to create ovdnd.exe
|
||||
* 106: Failed to create run.bat
|
||||
* 110: Cleanup of old installation failed
|
||||
*/
|
||||
async function downloadOvmsBinary() {
|
||||
// Create output directory structure - OVMS goes into its own subdirectory
|
||||
|
||||
/**
|
||||
* Clean old OVMS installation if it exists
|
||||
*/
|
||||
function cleanOldOvmsInstallation() {
|
||||
console.log('Cleaning the existing OVMS installation...')
|
||||
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
|
||||
fs.rmSync(csOvmsDir, { recursive: true })
|
||||
} 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...`)
|
||||
}
|
||||
console.warn(`Failed to clean up old OVMS installation: ${error.message}`)
|
||||
return 110
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any download succeeded
|
||||
if (!downloadSuccess) {
|
||||
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Install OVMS Base package
|
||||
*/
|
||||
async function installOvmsBase() {
|
||||
// Download the base package
|
||||
const tempdir = os.tmpdir()
|
||||
const tempFilename = path.join(tempdir, 'ovms.zip')
|
||||
|
||||
try {
|
||||
console.log(`Downloading OVMS Base Package from ${OVMS_RELEASE_BASE_URL} to ${tempFilename}...`)
|
||||
|
||||
// Try PowerShell download first, fallback to Node.js download if it fails
|
||||
await downloadWithPowerShell(OVMS_RELEASE_BASE_URL, tempFilename)
|
||||
console.log(`Successfully downloaded from: ${OVMS_RELEASE_BASE_URL}`)
|
||||
} catch (error) {
|
||||
console.error(`Download OVMS Base failed: ${error.message}`)
|
||||
fs.unlinkSync(tempFilename)
|
||||
return 103
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Extracting to ${csDir}...`)
|
||||
// unzip the base package to the target directory
|
||||
const csDir = path.join(os.homedir(), '.cherrystudio')
|
||||
const csOvmsDir = path.join(csDir, 'ovms')
|
||||
fs.mkdirSync(csOvmsDir, { recursive: true })
|
||||
|
||||
try {
|
||||
// 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}`)
|
||||
console.log(`Extracting OVMS Base to ${csOvmsDir}...`)
|
||||
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
|
||||
console.log(`OVMS extracted to ${csOvmsDir}`)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
fs.unlinkSync(tempFilename)
|
||||
return 104
|
||||
}
|
||||
|
||||
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
|
||||
// copy ovms.exe to ovdnd.exe
|
||||
try {
|
||||
fs.copyFileSync(path.join(csOvmsBinDir, 'ovms.exe'), path.join(csOvmsBinDir, 'ovdnd.exe'))
|
||||
console.log('Copied ovms.exe to ovdnd.exe')
|
||||
} catch (error) {
|
||||
console.error(`Error copying ovms.exe to ovdnd.exe: ${error.message}`)
|
||||
return 105
|
||||
}
|
||||
|
||||
// copy {csOvmsBinDir}/setupvars.bat to {csOvmsBinDir}/run.bat, and append the following lines to run.bat:
|
||||
// del %USERPROFILE%\.cherrystudio\ovms_log.log
|
||||
// ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\.cherrystudio\ovms_log.log
|
||||
const runBatPath = path.join(csOvmsBinDir, 'run.bat')
|
||||
try {
|
||||
fs.copyFileSync(path.join(csOvmsBinDir, 'setupvars.bat'), runBatPath)
|
||||
fs.appendFileSync(runBatPath, '\r\n')
|
||||
fs.appendFileSync(runBatPath, 'del %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n')
|
||||
fs.appendFileSync(
|
||||
runBatPath,
|
||||
'ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n'
|
||||
)
|
||||
console.log(`Created run.bat at: ${runBatPath}`)
|
||||
} catch (error) {
|
||||
console.error(`Error creating run.bat: ${error.message}`)
|
||||
return 106
|
||||
}
|
||||
|
||||
// create {csOvmsBinDir}/models/config.json with content '{"model_config_list": []}'
|
||||
const configJsonPath = path.join(csOvmsBinDir, 'models', 'config.json')
|
||||
fs.mkdirSync(path.dirname(configJsonPath), { recursive: true })
|
||||
fs.writeFileSync(configJsonPath, '{"mediapipe_config_list":[],"model_config_list":[]}')
|
||||
console.log(`Created config file: ${configJsonPath}`)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Install OVMS Extra package
|
||||
*/
|
||||
async function installOvmsExtra() {
|
||||
// Download the extra package
|
||||
const tempdir = os.tmpdir()
|
||||
const tempFilename = path.join(tempdir, 'ovms_ex.zip')
|
||||
|
||||
try {
|
||||
console.log(`Downloading OVMS Extra Package from ${OVMS_EX_URL} to ${tempFilename}...`)
|
||||
|
||||
// Try PowerShell download first, fallback to Node.js download if it fails
|
||||
await downloadWithPowerShell(OVMS_EX_URL, tempFilename)
|
||||
console.log(`Successfully downloaded from: ${OVMS_EX_URL}`)
|
||||
} catch (error) {
|
||||
console.error(`Download OVMS Extra failed: ${error.message}`)
|
||||
fs.unlinkSync(tempFilename)
|
||||
return 103
|
||||
}
|
||||
|
||||
// unzip the extra package to the target directory
|
||||
const csDir = path.join(os.homedir(), '.cherrystudio')
|
||||
const csOvmsDir = path.join(csDir, 'ovms')
|
||||
|
||||
try {
|
||||
// Use tar.exe to extract the ZIP file
|
||||
console.log(`Extracting OVMS Extra to ${csOvmsDir}...`)
|
||||
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
|
||||
console.log(`OVMS extracted to ${csOvmsDir}`)
|
||||
|
||||
// Clean up temporary file
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Installation directory: ${csDir}`)
|
||||
} catch (error) {
|
||||
console.error(`Error installing OVMS Extra: ${error.message}`)
|
||||
fs.unlinkSync(tempFilename)
|
||||
return 104
|
||||
}
|
||||
|
||||
// apply ovms patch, copy all files in {csOvmsDir}/patch/ovms to {csOvmsDir}/ovms with overwrite mode
|
||||
const patchDir = path.join(csOvmsDir, 'patch', 'ovms')
|
||||
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
|
||||
try {
|
||||
const files = fs.readdirSync(patchDir)
|
||||
files.forEach((file) => {
|
||||
const srcPath = path.join(patchDir, file)
|
||||
const destPath = path.join(csOvmsBinDir, file)
|
||||
fs.copyFileSync(srcPath, destPath)
|
||||
console.log(`Applied patch file: ${file}`)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Error applying OVMS patch: ${error.message}`)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -158,7 +224,27 @@ async function installOvms() {
|
||||
return 102
|
||||
}
|
||||
|
||||
return await downloadOvmsBinary()
|
||||
// Clean old installation if it exists
|
||||
const cleanupCode = cleanOldOvmsInstallation()
|
||||
if (cleanupCode !== 0) {
|
||||
console.error(`OVMS cleanup failed with code: ${cleanupCode}`)
|
||||
return cleanupCode
|
||||
}
|
||||
|
||||
const installBaseCode = await installOvmsBase()
|
||||
if (installBaseCode !== 0) {
|
||||
console.error(`OVMS Base installation failed with code: ${installBaseCode}`)
|
||||
cleanOldOvmsInstallation()
|
||||
return installBaseCode
|
||||
}
|
||||
|
||||
const installExtraCode = await installOvmsExtra()
|
||||
if (installExtraCode !== 0) {
|
||||
console.error(`OVMS Extra installation failed with code: ${installExtraCode}`)
|
||||
return installExtraCode
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// Run the installation
|
||||
|
||||
228
scripts/feishu-notify.js
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Feishu (Lark) Webhook Notification Script
|
||||
* Sends GitHub issue summaries to Feishu with signature verification
|
||||
*/
|
||||
|
||||
const crypto = require('crypto')
|
||||
const https = require('https')
|
||||
|
||||
/**
|
||||
* Generate Feishu webhook signature
|
||||
* @param {string} secret - Feishu webhook secret
|
||||
* @param {number} timestamp - Unix timestamp in seconds
|
||||
* @returns {string} Base64 encoded signature
|
||||
*/
|
||||
function generateSignature(secret, timestamp) {
|
||||
const stringToSign = `${timestamp}\n${secret}`
|
||||
const hmac = crypto.createHmac('sha256', stringToSign)
|
||||
return hmac.digest('base64')
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to Feishu webhook
|
||||
* @param {string} webhookUrl - Feishu webhook URL
|
||||
* @param {string} secret - Feishu webhook secret
|
||||
* @param {object} content - Message content
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function sendToFeishu(webhookUrl, secret, content) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const sign = generateSignature(secret, timestamp)
|
||||
|
||||
const payload = JSON.stringify({
|
||||
timestamp: timestamp.toString(),
|
||||
sign: sign,
|
||||
msg_type: 'interactive',
|
||||
card: content
|
||||
})
|
||||
|
||||
const url = new URL(webhookUrl)
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = ''
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
console.log('✅ Successfully sent to Feishu:', data)
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`Feishu API error: ${res.statusCode} - ${data}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error)
|
||||
})
|
||||
|
||||
req.write(payload)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Feishu card message from issue data
|
||||
* @param {object} issueData - GitHub issue data
|
||||
* @returns {object} Feishu card content
|
||||
*/
|
||||
function createIssueCard(issueData) {
|
||||
const { issueUrl, issueNumber, issueTitle, issueSummary, issueAuthor, labels } = issueData
|
||||
|
||||
// Build labels section if labels exist
|
||||
const labelElements =
|
||||
labels && labels.length > 0
|
||||
? labels.map((label) => ({
|
||||
tag: 'markdown',
|
||||
content: `\`${label}\``
|
||||
}))
|
||||
: []
|
||||
|
||||
return {
|
||||
elements: [
|
||||
{
|
||||
tag: 'div',
|
||||
text: {
|
||||
tag: 'lark_md',
|
||||
content: `**🐛 New GitHub Issue #${issueNumber}**`
|
||||
}
|
||||
},
|
||||
{
|
||||
tag: 'hr'
|
||||
},
|
||||
{
|
||||
tag: 'div',
|
||||
text: {
|
||||
tag: 'lark_md',
|
||||
content: `**📝 Title:** ${issueTitle}`
|
||||
}
|
||||
},
|
||||
{
|
||||
tag: 'div',
|
||||
text: {
|
||||
tag: 'lark_md',
|
||||
content: `**👤 Author:** ${issueAuthor}`
|
||||
}
|
||||
},
|
||||
...(labelElements.length > 0
|
||||
? [
|
||||
{
|
||||
tag: 'div',
|
||||
text: {
|
||||
tag: 'lark_md',
|
||||
content: `**🏷️ Labels:** ${labels.join(', ')}`
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
tag: 'hr'
|
||||
},
|
||||
{
|
||||
tag: 'div',
|
||||
text: {
|
||||
tag: 'lark_md',
|
||||
content: `**📋 Summary:**\n${issueSummary}`
|
||||
}
|
||||
},
|
||||
{
|
||||
tag: 'hr'
|
||||
},
|
||||
{
|
||||
tag: 'action',
|
||||
actions: [
|
||||
{
|
||||
tag: 'button',
|
||||
text: {
|
||||
tag: 'plain_text',
|
||||
content: '🔗 View Issue'
|
||||
},
|
||||
type: 'primary',
|
||||
url: issueUrl
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
header: {
|
||||
template: 'blue',
|
||||
title: {
|
||||
tag: 'plain_text',
|
||||
content: '🆕 Cherry Studio - New Issue'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
async function main() {
|
||||
try {
|
||||
// Get environment variables
|
||||
const webhookUrl = process.env.FEISHU_WEBHOOK_URL
|
||||
const secret = process.env.FEISHU_WEBHOOK_SECRET
|
||||
const issueUrl = process.env.ISSUE_URL
|
||||
const issueNumber = process.env.ISSUE_NUMBER
|
||||
const issueTitle = process.env.ISSUE_TITLE
|
||||
const issueSummary = process.env.ISSUE_SUMMARY
|
||||
const issueAuthor = process.env.ISSUE_AUTHOR
|
||||
const labelsStr = process.env.ISSUE_LABELS || ''
|
||||
|
||||
// Validate required environment variables
|
||||
if (!webhookUrl) {
|
||||
throw new Error('FEISHU_WEBHOOK_URL environment variable is required')
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error('FEISHU_WEBHOOK_SECRET environment variable is required')
|
||||
}
|
||||
if (!issueUrl || !issueNumber || !issueTitle || !issueSummary) {
|
||||
throw new Error('Issue data environment variables are required')
|
||||
}
|
||||
|
||||
// Parse labels
|
||||
const labels = labelsStr
|
||||
? labelsStr
|
||||
.split(',')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
// Create issue data object
|
||||
const issueData = {
|
||||
issueUrl,
|
||||
issueNumber,
|
||||
issueTitle,
|
||||
issueSummary,
|
||||
issueAuthor: issueAuthor || 'Unknown',
|
||||
labels
|
||||
}
|
||||
|
||||
// Create card content
|
||||
const card = createIssueCard(issueData)
|
||||
|
||||
console.log('📤 Sending notification to Feishu...')
|
||||
console.log(`Issue #${issueNumber}: ${issueTitle}`)
|
||||
|
||||
// Send to Feishu
|
||||
await sendToFeishu(webhookUrl, secret, card)
|
||||
|
||||
console.log('✅ Notification sent successfully!')
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Run main function
|
||||
main()
|
||||
@@ -13,45 +13,45 @@ type I18NValue = string | { [key: string]: I18NValue }
|
||||
type I18N = { [key: string]: I18NValue }
|
||||
|
||||
/**
|
||||
* 递归同步 target 对象,使其与 template 对象保持一致
|
||||
* 1. 如果 template 中存在 target 中缺少的 key,则添加('[to be translated]')
|
||||
* 2. 如果 target 中存在 template 中不存在的 key,则删除
|
||||
* 3. 对于子对象,递归同步
|
||||
* Recursively sync target object to match template object structure
|
||||
* 1. Add keys that exist in template but missing in target (with '[to be translated]')
|
||||
* 2. Remove keys that exist in target but not in template
|
||||
* 3. Recursively sync nested objects
|
||||
*
|
||||
* @param target 目标对象(需要更新的语言对象)
|
||||
* @param template 主模板对象(中文)
|
||||
* @returns 返回是否对 target 进行了更新
|
||||
* @param target Target object (language object to be updated)
|
||||
* @param template Base locale object (Chinese)
|
||||
* @returns Returns whether target was updated
|
||||
*/
|
||||
function syncRecursively(target: I18N, template: I18N): void {
|
||||
// 添加 template 中存在但 target 中缺少的 key
|
||||
// Add keys that exist in template but missing in target
|
||||
for (const key in template) {
|
||||
if (!(key in target)) {
|
||||
target[key] =
|
||||
typeof template[key] === 'object' && template[key] !== null ? {} : `[to be translated]:${template[key]}`
|
||||
console.log(`添加新属性:${key}`)
|
||||
console.log(`Added new property: ${key}`)
|
||||
}
|
||||
if (typeof template[key] === 'object' && template[key] !== null) {
|
||||
if (typeof target[key] !== 'object' || target[key] === null) {
|
||||
target[key] = {}
|
||||
}
|
||||
// 递归同步子对象
|
||||
// Recursively sync nested objects
|
||||
syncRecursively(target[key], template[key])
|
||||
}
|
||||
}
|
||||
|
||||
// 删除 target 中存在但 template 中没有的 key
|
||||
// Remove keys that exist in target but not in template
|
||||
for (const targetKey in target) {
|
||||
if (!(targetKey in template)) {
|
||||
console.log(`移除多余属性:${targetKey}`)
|
||||
console.log(`Removed excess property: ${targetKey}`)
|
||||
delete target[targetKey]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 JSON 对象中是否存在重复键,并收集所有重复键
|
||||
* @param obj 要检查的对象
|
||||
* @returns 返回重复键的数组(若无重复则返回空数组)
|
||||
* Check JSON object for duplicate keys and collect all duplicates
|
||||
* @param obj Object to check
|
||||
* @returns Returns array of duplicate keys (empty array if no duplicates)
|
||||
*/
|
||||
function checkDuplicateKeys(obj: I18N): string[] {
|
||||
const keys = new Set<string>()
|
||||
@@ -62,7 +62,7 @@ function checkDuplicateKeys(obj: I18N): string[] {
|
||||
const fullPath = path ? `${path}.${key}` : key
|
||||
|
||||
if (keys.has(fullPath)) {
|
||||
// 发现重复键时,添加到数组中(避免重复添加)
|
||||
// When duplicate key found, add to array (avoid duplicate additions)
|
||||
if (!duplicateKeys.includes(fullPath)) {
|
||||
duplicateKeys.push(fullPath)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function checkDuplicateKeys(obj: I18N): string[] {
|
||||
keys.add(fullPath)
|
||||
}
|
||||
|
||||
// 递归检查子对象
|
||||
// Recursively check nested objects
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
checkObject(obj[key], fullPath)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ function checkDuplicateKeys(obj: I18N): string[] {
|
||||
|
||||
function syncTranslations() {
|
||||
if (!fs.existsSync(baseFilePath)) {
|
||||
console.error(`主模板文件 ${baseFileName} 不存在,请检查路径或文件名`)
|
||||
console.error(`Base locale file ${baseFileName} does not exist, please check path or filename`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -92,24 +92,24 @@ function syncTranslations() {
|
||||
try {
|
||||
baseJson = JSON.parse(baseContent)
|
||||
} catch (error) {
|
||||
console.error(`解析 ${baseFileName} 出错。${error}`)
|
||||
console.error(`Error parsing ${baseFileName}. ${error}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查主模板是否存在重复键
|
||||
// Check if base locale has duplicate keys
|
||||
const duplicateKeys = checkDuplicateKeys(baseJson)
|
||||
if (duplicateKeys.length > 0) {
|
||||
throw new Error(`主模板文件 ${baseFileName} 存在以下重复键:\n${duplicateKeys.join('\n')}`)
|
||||
throw new Error(`Base locale file ${baseFileName} has the following duplicate keys:\n${duplicateKeys.join('\n')}`)
|
||||
}
|
||||
|
||||
// 为主模板排序
|
||||
// Sort base locale
|
||||
const sortedJson = sortedObjectByKeys(baseJson)
|
||||
if (JSON.stringify(baseJson) !== JSON.stringify(sortedJson)) {
|
||||
try {
|
||||
fs.writeFileSync(baseFilePath, JSON.stringify(sortedJson, null, 2) + '\n', 'utf-8')
|
||||
console.log(`主模板已排序`)
|
||||
console.log(`Base locale has been sorted`)
|
||||
} catch (error) {
|
||||
console.error(`写入 ${baseFilePath} 出错。`, error)
|
||||
console.error(`Error writing ${baseFilePath}.`, error)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ function syncTranslations() {
|
||||
.map((filename) => path.join(translateDir, filename))
|
||||
const files = [...localeFiles, ...translateFiles]
|
||||
|
||||
// 同步键
|
||||
// Sync keys
|
||||
for (const filePath of files) {
|
||||
const filename = path.basename(filePath)
|
||||
let targetJson: I18N = {}
|
||||
@@ -132,7 +132,7 @@ function syncTranslations() {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8')
|
||||
targetJson = JSON.parse(fileContent)
|
||||
} catch (error) {
|
||||
console.error(`解析 ${filename} 出错,跳过此文件。`, error)
|
||||
console.error(`Error parsing ${filename}, skipping this file.`, error)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -142,9 +142,9 @@ function syncTranslations() {
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(sortedJson, null, 2) + '\n', 'utf-8')
|
||||
console.log(`文件 ${filename} 已排序并同步更新为主模板的内容`)
|
||||
console.log(`File ${filename} has been sorted and synced to match base locale content`)
|
||||
} catch (error) {
|
||||
console.error(`写入 ${filename} 出错。${error}`)
|
||||
console.error(`Error writing ${filename}. ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import process from 'node:process'
|
||||
import { apiServerService } from './services/ApiServerService'
|
||||
import { initWebviewHotkeys } from './services/WebviewService'
|
||||
|
||||
const logger = loggerService.withContext('MainEntry')
|
||||
|
||||
@@ -106,6 +107,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
// Some APIs can only be used after this event occurs.
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
initWebviewHotkeys()
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import { themeService } from './services/ThemeService'
|
||||
import VertexAIService from './services/VertexAIService'
|
||||
import WebSocketService from './services/WebSocketService'
|
||||
import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { calculateDirectorySize, getResourcePath } from './utils'
|
||||
@@ -132,7 +133,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
|
||||
|
||||
// Update
|
||||
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
|
||||
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
|
||||
|
||||
// language
|
||||
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
||||
@@ -754,7 +755,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
|
||||
setOpenLinkExternal(webviewId, isExternal)
|
||||
)
|
||||
|
||||
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
|
||||
const webview = webContents.fromId(webviewId)
|
||||
if (!webview) return
|
||||
@@ -844,6 +844,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
|
||||
ocrService.ocr(file, provider)
|
||||
)
|
||||
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
|
||||
|
||||
// OVMS
|
||||
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
|
||||
@@ -858,4 +859,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// CherryAI
|
||||
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
||||
|
||||
// WebSocket
|
||||
ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start)
|
||||
ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop)
|
||||
ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus)
|
||||
ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile)
|
||||
ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates)
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export default abstract class BaseReranker {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseBody: error.response?.body, // Include the actual API error response
|
||||
requestBody: requestBody
|
||||
}
|
||||
return JSON.stringify(errorDetails, null, 2)
|
||||
|
||||
@@ -2,6 +2,15 @@ import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types'
|
||||
import { net } from 'electron'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
|
||||
interface RerankError extends Error {
|
||||
response?: {
|
||||
status: number
|
||||
statusText: string
|
||||
body?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export default class GeneralReranker extends BaseReranker {
|
||||
constructor(base: KnowledgeBaseParams) {
|
||||
super(base)
|
||||
@@ -17,7 +26,30 @@ export default class GeneralReranker extends BaseReranker {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
// Read the response body to get detailed error information
|
||||
// Clone the response to avoid consuming the body multiple times
|
||||
const clonedResponse = response.clone()
|
||||
let errorBody: unknown
|
||||
|
||||
try {
|
||||
errorBody = await clonedResponse.json()
|
||||
} catch {
|
||||
// If response body is not JSON, try to read as text
|
||||
try {
|
||||
errorBody = await response.text()
|
||||
} catch {
|
||||
errorBody = null
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as RerankError
|
||||
// Attach response details to the error object for formatErrorMessage
|
||||
error.response = {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: errorBody
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
473
src/main/mcpServers/didi-mcp.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* 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}`)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { getIpCountry } from '@main/utils/ipService'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog, net } from 'electron'
|
||||
import { app, net } from 'electron'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
||||
import path from 'path'
|
||||
import semver from 'semver'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
@@ -26,7 +24,6 @@ const LANG_MARKERS = {
|
||||
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
private releaseInfo: UpdateInfo | undefined
|
||||
private cancellationToken: CancellationToken = new CancellationToken()
|
||||
private updateCheckResult: UpdateCheckResult | null = null
|
||||
|
||||
@@ -66,7 +63,6 @@ export default class AppUpdater {
|
||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
||||
this.releaseInfo = processedReleaseInfo
|
||||
logger.info('update downloaded', processedReleaseInfo)
|
||||
})
|
||||
|
||||
@@ -247,37 +243,9 @@ export default class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
public async showUpdateDialog(mainWindow: BrowserWindow) {
|
||||
if (!this.releaseInfo) {
|
||||
return
|
||||
}
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { update: updateLocale } = locale.translation
|
||||
|
||||
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
|
||||
if (detail === '') {
|
||||
detail = updateLocale.noReleaseNotes
|
||||
}
|
||||
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: updateLocale.title,
|
||||
icon,
|
||||
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
|
||||
detail,
|
||||
buttons: [updateLocale.later, updateLocale.install],
|
||||
defaultId: 1,
|
||||
cancelId: 0
|
||||
})
|
||||
.then(({ response }) => {
|
||||
if (response === 1) {
|
||||
app.isQuitting = true
|
||||
setImmediate(() => autoUpdater.quitAndInstall())
|
||||
} else {
|
||||
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
|
||||
}
|
||||
})
|
||||
public quitAndInstall() {
|
||||
app.isQuitting = true
|
||||
setImmediate(() => autoUpdater.quitAndInstall())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,38 +317,9 @@ export default class AppUpdater {
|
||||
|
||||
return processedInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Format release notes for display
|
||||
* @param releaseNotes - Release notes in various formats
|
||||
* @returns Formatted string for display
|
||||
*/
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof releaseNotes === 'string') {
|
||||
// Check if it contains multi-language markers
|
||||
if (this.hasMultiLanguageMarkers(releaseNotes)) {
|
||||
return this.parseMultiLangReleaseNotes(releaseNotes)
|
||||
}
|
||||
return releaseNotes
|
||||
}
|
||||
|
||||
if (Array.isArray(releaseNotes)) {
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
}
|
||||
interface GithubReleaseInfo {
|
||||
draft: boolean
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
}
|
||||
interface ReleaseNoteInfo {
|
||||
readonly version: string
|
||||
readonly note: string | null
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
|
||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
@@ -147,11 +147,16 @@ class KnowledgeService {
|
||||
}
|
||||
}
|
||||
|
||||
private getDbPath = (id: string): string => {
|
||||
// 消除网络搜索requestI d中的特殊字符
|
||||
return path.join(this.storageDir, sanitizeFilename(id, '_'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete knowledge base file
|
||||
*/
|
||||
private deleteKnowledgeFile = (id: string): boolean => {
|
||||
const dbPath = path.join(this.storageDir, id)
|
||||
const dbPath = this.getDbPath(id)
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
fs.rmSync(dbPath, { recursive: true })
|
||||
@@ -244,7 +249,8 @@ class KnowledgeService {
|
||||
dimensions
|
||||
})
|
||||
try {
|
||||
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
|
||||
const dbPath = this.getDbPath(id)
|
||||
const libSqlDb = new LibSqlDb({ path: dbPath })
|
||||
// Save database instance for later closing
|
||||
this.dbInstances.set(id, libSqlDb)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { app, ProxyConfig, session } from 'electron'
|
||||
import { socksDispatcher } from 'fetch-socks'
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
import * as ipaddr from 'ipaddr.js'
|
||||
import { getSystemProxy } from 'os-proxy-config'
|
||||
import { ProxyAgent } from 'proxy-agent'
|
||||
import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici'
|
||||
@@ -11,41 +12,293 @@ import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher
|
||||
const logger = loggerService.withContext('ProxyManager')
|
||||
let byPassRules: string[] = []
|
||||
|
||||
const isByPass = (url: string) => {
|
||||
if (byPassRules.length === 0) {
|
||||
type HostnameMatchType = 'exact' | 'wildcardSubdomain' | 'generalWildcard'
|
||||
|
||||
const enum ProxyBypassRuleType {
|
||||
Local = 'local',
|
||||
Cidr = 'cidr',
|
||||
Ip = 'ip',
|
||||
Domain = 'domain'
|
||||
}
|
||||
|
||||
interface ParsedProxyBypassRule {
|
||||
type: ProxyBypassRuleType
|
||||
matchType: HostnameMatchType
|
||||
rule: string
|
||||
scheme?: string
|
||||
port?: string
|
||||
domain?: string
|
||||
regex?: RegExp
|
||||
cidr?: [ipaddr.IPv4 | ipaddr.IPv6, number]
|
||||
ip?: string
|
||||
}
|
||||
|
||||
let parsedByPassRules: ParsedProxyBypassRule[] = []
|
||||
|
||||
const getDefaultPortForProtocol = (protocol: string): string | null => {
|
||||
switch (protocol.toLowerCase()) {
|
||||
case 'http:':
|
||||
return '80'
|
||||
case 'https:':
|
||||
return '443'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const buildWildcardRegex = (pattern: string): RegExp => {
|
||||
const escapedSegments = pattern.split('*').map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
return new RegExp(`^${escapedSegments.join('.*')}$`, 'i')
|
||||
}
|
||||
|
||||
const isWildcardIp = (value: string): boolean => {
|
||||
if (!value.includes('*')) {
|
||||
return false
|
||||
}
|
||||
const replaced = value.replace(/\*/g, '0')
|
||||
return ipaddr.isValid(replaced)
|
||||
}
|
||||
|
||||
const matchHostnameRule = (hostname: string, rule: ParsedProxyBypassRule): boolean => {
|
||||
const normalizedHostname = hostname.toLowerCase()
|
||||
|
||||
switch (rule.matchType) {
|
||||
case 'exact':
|
||||
return normalizedHostname === rule.domain
|
||||
case 'wildcardSubdomain': {
|
||||
const domain = rule.domain
|
||||
if (!domain) {
|
||||
return false
|
||||
}
|
||||
return normalizedHostname === domain || normalizedHostname.endsWith(`.${domain}`)
|
||||
}
|
||||
case 'generalWildcard':
|
||||
return rule.regex ? rule.regex.test(normalizedHostname) : false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const parseProxyBypassRule = (rule: string): ParsedProxyBypassRule | null => {
|
||||
const trimmedRule = rule.trim()
|
||||
if (!trimmedRule) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (trimmedRule === '<local>') {
|
||||
return {
|
||||
type: ProxyBypassRuleType.Local,
|
||||
matchType: 'exact',
|
||||
rule: '<local>'
|
||||
}
|
||||
}
|
||||
|
||||
let workingRule = trimmedRule
|
||||
let scheme: string | undefined
|
||||
const schemeMatch = workingRule.match(/^([a-zA-Z][a-zA-Z\d+\-.]*):\/\//)
|
||||
if (schemeMatch) {
|
||||
scheme = schemeMatch[1].toLowerCase()
|
||||
workingRule = workingRule.slice(schemeMatch[0].length)
|
||||
}
|
||||
|
||||
// CIDR notation must be processed before port extraction
|
||||
if (workingRule.includes('/')) {
|
||||
const cleanedCidr = workingRule.replace(/^\[|\]$/g, '')
|
||||
if (ipaddr.isValidCIDR(cleanedCidr)) {
|
||||
return {
|
||||
type: ProxyBypassRuleType.Cidr,
|
||||
matchType: 'exact',
|
||||
rule: workingRule,
|
||||
scheme,
|
||||
cidr: ipaddr.parseCIDR(cleanedCidr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract port: supports "host:port" and "[ipv6]:port" formats
|
||||
let port: string | undefined
|
||||
const portMatch = workingRule.match(/^(.+?):(\d+)$/)
|
||||
if (portMatch) {
|
||||
// For IPv6, ensure we're not splitting inside the brackets
|
||||
const potentialHost = portMatch[1]
|
||||
if (!potentialHost.startsWith('[') || potentialHost.includes(']')) {
|
||||
workingRule = potentialHost
|
||||
port = portMatch[2]
|
||||
}
|
||||
}
|
||||
|
||||
const cleanedHost = workingRule.replace(/^\[|\]$/g, '')
|
||||
const normalizedHost = cleanedHost.toLowerCase()
|
||||
|
||||
if (!cleanedHost) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (ipaddr.isValid(cleanedHost)) {
|
||||
return {
|
||||
type: ProxyBypassRuleType.Ip,
|
||||
matchType: 'exact',
|
||||
rule: cleanedHost,
|
||||
scheme,
|
||||
port,
|
||||
ip: cleanedHost
|
||||
}
|
||||
}
|
||||
|
||||
if (isWildcardIp(cleanedHost)) {
|
||||
const regexPattern = cleanedHost.replace(/\./g, '\\.').replace(/\*/g, '\\d+')
|
||||
return {
|
||||
type: ProxyBypassRuleType.Ip,
|
||||
matchType: 'generalWildcard',
|
||||
rule: cleanedHost,
|
||||
scheme,
|
||||
port,
|
||||
regex: new RegExp(`^${regexPattern}$`)
|
||||
}
|
||||
}
|
||||
|
||||
if (workingRule.startsWith('*.')) {
|
||||
const domain = normalizedHost.slice(2)
|
||||
return {
|
||||
type: ProxyBypassRuleType.Domain,
|
||||
matchType: 'wildcardSubdomain',
|
||||
rule: workingRule,
|
||||
scheme,
|
||||
port,
|
||||
domain
|
||||
}
|
||||
}
|
||||
|
||||
if (workingRule.startsWith('.')) {
|
||||
const domain = normalizedHost.slice(1)
|
||||
return {
|
||||
type: ProxyBypassRuleType.Domain,
|
||||
matchType: 'wildcardSubdomain',
|
||||
rule: workingRule,
|
||||
scheme,
|
||||
port,
|
||||
domain
|
||||
}
|
||||
}
|
||||
|
||||
if (workingRule.includes('*')) {
|
||||
return {
|
||||
type: ProxyBypassRuleType.Domain,
|
||||
matchType: 'generalWildcard',
|
||||
rule: workingRule,
|
||||
scheme,
|
||||
port,
|
||||
regex: buildWildcardRegex(normalizedHost)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: ProxyBypassRuleType.Domain,
|
||||
matchType: 'exact',
|
||||
rule: workingRule,
|
||||
scheme,
|
||||
port,
|
||||
domain: normalizedHost
|
||||
}
|
||||
}
|
||||
|
||||
const isLocalHostname = (hostname: string): boolean => {
|
||||
const normalized = hostname.toLowerCase()
|
||||
if (normalized === 'localhost') {
|
||||
return true
|
||||
}
|
||||
|
||||
const cleaned = hostname.replace(/^\[|\]$/g, '')
|
||||
if (ipaddr.isValid(cleaned)) {
|
||||
const parsed = ipaddr.parse(cleaned)
|
||||
return parsed.range() === 'loopback'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const updateByPassRules = (rules: string[]): void => {
|
||||
byPassRules = rules
|
||||
parsedByPassRules = []
|
||||
|
||||
for (const rule of rules) {
|
||||
const parsedRule = parseProxyBypassRule(rule)
|
||||
if (parsedRule) {
|
||||
parsedByPassRules.push(parsedRule)
|
||||
} else {
|
||||
logger.warn(`Skipping invalid proxy bypass rule: ${rule}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isByPass = (url: string) => {
|
||||
if (parsedByPassRules.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const subjectUrlTokens = new URL(url)
|
||||
for (const rule of byPassRules) {
|
||||
const ruleMatch = rule.replace(/^(?<leadingDot>\.)/, '*').match(/^(?<hostname>.+?)(?::(?<port>\d+))?$/)
|
||||
const parsedUrl = new URL(url)
|
||||
const hostname = parsedUrl.hostname
|
||||
const cleanedHostname = hostname.replace(/^\[|\]$/g, '')
|
||||
const protocol = parsedUrl.protocol
|
||||
const protocolName = protocol.replace(':', '').toLowerCase()
|
||||
const defaultPort = getDefaultPortForProtocol(protocol)
|
||||
const port = parsedUrl.port || defaultPort || ''
|
||||
const hostnameIsIp = ipaddr.isValid(cleanedHostname)
|
||||
|
||||
if (!ruleMatch || !ruleMatch.groups) {
|
||||
logger.warn('Failed to parse bypass rule:', { rule })
|
||||
for (const rule of parsedByPassRules) {
|
||||
if (rule.scheme && rule.scheme !== protocolName) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!ruleMatch.groups.hostname) {
|
||||
if (rule.port && rule.port !== port) {
|
||||
continue
|
||||
}
|
||||
|
||||
const hostnameIsMatch = subjectUrlTokens.hostname === ruleMatch.groups.hostname
|
||||
switch (rule.type) {
|
||||
case ProxyBypassRuleType.Local:
|
||||
if (isLocalHostname(hostname)) {
|
||||
return true
|
||||
}
|
||||
break
|
||||
case ProxyBypassRuleType.Ip:
|
||||
if (!hostnameIsIp) {
|
||||
break
|
||||
}
|
||||
|
||||
if (
|
||||
hostnameIsMatch &&
|
||||
(!ruleMatch.groups ||
|
||||
!ruleMatch.groups.port ||
|
||||
(subjectUrlTokens.port && subjectUrlTokens.port === ruleMatch.groups.port))
|
||||
) {
|
||||
return true
|
||||
if (rule.ip && cleanedHostname === rule.ip) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (rule.regex && rule.regex.test(cleanedHostname)) {
|
||||
return true
|
||||
}
|
||||
break
|
||||
case ProxyBypassRuleType.Cidr:
|
||||
if (hostnameIsIp && rule.cidr) {
|
||||
const parsedHost = ipaddr.parse(cleanedHostname)
|
||||
const [cidrAddress, prefixLength] = rule.cidr
|
||||
// Ensure IP version matches before comparing
|
||||
if (parsedHost.kind() === cidrAddress.kind() && parsedHost.match([cidrAddress, prefixLength])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
break
|
||||
case ProxyBypassRuleType.Domain:
|
||||
if (!hostnameIsIp && matchHostnameRule(hostname, rule)) {
|
||||
return true
|
||||
}
|
||||
break
|
||||
default:
|
||||
logger.error(`Unknown proxy bypass rule type: ${rule.type}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Failed to check bypass:', error as Error)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
class SelectiveDispatcher extends Dispatcher {
|
||||
private proxyDispatcher: Dispatcher
|
||||
@@ -154,19 +407,31 @@ export class ProxyManager {
|
||||
this.isSettingProxy = true
|
||||
|
||||
try {
|
||||
this.config = config
|
||||
this.clearSystemProxyMonitor()
|
||||
if (config.mode === 'system') {
|
||||
const currentProxy = await getSystemProxy()
|
||||
if (currentProxy) {
|
||||
logger.info(`current system proxy: ${currentProxy.proxyUrl}`)
|
||||
this.config.proxyRules = currentProxy.proxyUrl.toLowerCase()
|
||||
logger.info(`current system proxy: ${currentProxy.proxyUrl}, bypass rules: ${currentProxy.noProxy.join(',')}`)
|
||||
config.proxyRules = currentProxy.proxyUrl.toLowerCase()
|
||||
config.proxyBypassRules = currentProxy.noProxy.join(',')
|
||||
}
|
||||
this.monitorSystemProxy()
|
||||
}
|
||||
|
||||
byPassRules = config.proxyBypassRules?.split(',') || []
|
||||
this.setGlobalProxy(this.config)
|
||||
// Support both semicolon and comma as separators
|
||||
if (config.proxyBypassRules !== this.config.proxyBypassRules) {
|
||||
const rawRules = config.proxyBypassRules
|
||||
? config.proxyBypassRules
|
||||
.split(/[;,]/)
|
||||
.map((rule) => rule.trim())
|
||||
.filter((rule) => rule.length > 0)
|
||||
: []
|
||||
|
||||
updateByPassRules(rawRules)
|
||||
}
|
||||
|
||||
this.setGlobalProxy(config)
|
||||
this.config = config
|
||||
} catch (error) {
|
||||
logger.error('Failed to config proxy:', error as Error)
|
||||
throw error
|
||||
|
||||
358
src/main/services/WebSocketService.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { WebSocketCandidatesResponse, WebSocketStatusResponse } from '@shared/config/types'
|
||||
import * as fs from 'fs'
|
||||
import { networkInterfaces } from 'os'
|
||||
import * as path from 'path'
|
||||
import { Server, Socket } from 'socket.io'
|
||||
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
const logger = loggerService.withContext('WebSocketService')
|
||||
|
||||
class WebSocketService {
|
||||
private io: Server | null = null
|
||||
private isStarted = false
|
||||
private port = 7017
|
||||
private connectedClients = new Set<string>()
|
||||
|
||||
private getLocalIpAddress(): string | undefined {
|
||||
const interfaces = networkInterfaces()
|
||||
|
||||
// 按优先级排序的网络接口名称模式
|
||||
const interfacePriority = [
|
||||
// macOS: 以太网/Wi-Fi 优先
|
||||
/^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi)
|
||||
/^(en|eth)[0-9]+$/, // 以太网接口
|
||||
/^wlan[0-9]+$/, // 无线接口
|
||||
// Windows: 以太网/Wi-Fi 优先
|
||||
/^(Ethernet|Wi-Fi|Local Area Connection)/,
|
||||
/^(Wi-Fi|无线网络连接)/,
|
||||
// Linux: 以太网/Wi-Fi 优先
|
||||
/^(eth|enp|wlp|wlan)[0-9]+/,
|
||||
// 虚拟化接口(低优先级)
|
||||
/^bridge[0-9]+$/, // Docker bridge
|
||||
/^veth[0-9]+$/, // Docker veth
|
||||
/^docker[0-9]+/, // Docker interfaces
|
||||
/^br-[0-9a-f]+/, // Docker bridge
|
||||
/^vmnet[0-9]+$/, // VMware
|
||||
/^vboxnet[0-9]+$/, // VirtualBox
|
||||
// VPN 隧道接口(低优先级)
|
||||
/^utun[0-9]+$/, // macOS VPN
|
||||
/^tun[0-9]+$/, // Linux/Unix VPN
|
||||
/^tap[0-9]+$/, // TAP interfaces
|
||||
/^tailscale[0-9]*$/, // Tailscale VPN
|
||||
/^wg[0-9]+$/ // WireGuard VPN
|
||||
]
|
||||
|
||||
const candidates: Array<{ interface: string; address: string; priority: number }> = []
|
||||
|
||||
for (const [name, ifaces] of Object.entries(interfaces)) {
|
||||
for (const iface of ifaces || []) {
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
// 计算接口优先级
|
||||
let priority = 999 // 默认最低优先级
|
||||
for (let i = 0; i < interfacePriority.length; i++) {
|
||||
if (interfacePriority[i].test(name)) {
|
||||
priority = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
interface: name,
|
||||
address: iface.address,
|
||||
priority
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
logger.warn('无法获取局域网 IP,使用默认 IP: 127.0.0.1')
|
||||
return '127.0.0.1'
|
||||
}
|
||||
|
||||
// 按优先级排序,选择优先级最高的
|
||||
candidates.sort((a, b) => a.priority - b.priority)
|
||||
const best = candidates[0]
|
||||
|
||||
logger.info(`获取局域网 IP: ${best.address} (interface: ${best.interface})`)
|
||||
return best.address
|
||||
}
|
||||
|
||||
public start = async (): Promise<{ success: boolean; port?: number; error?: string }> => {
|
||||
if (this.isStarted && this.io) {
|
||||
return { success: true, port: this.port }
|
||||
}
|
||||
|
||||
try {
|
||||
this.io = new Server(this.port, {
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST']
|
||||
},
|
||||
transports: ['websocket', 'polling'],
|
||||
allowEIO3: true,
|
||||
pingTimeout: 60000,
|
||||
pingInterval: 25000
|
||||
})
|
||||
|
||||
this.io.on('connection', (socket: Socket) => {
|
||||
this.connectedClients.add(socket.id)
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (!mainWindow) {
|
||||
logger.error('Main window is null, cannot send connection event')
|
||||
} else {
|
||||
mainWindow.webContents.send('websocket-client-connected', {
|
||||
connected: true,
|
||||
clientId: socket.id
|
||||
})
|
||||
logger.info(`Connection event sent to renderer, total clients: ${this.connectedClients.size}`)
|
||||
}
|
||||
|
||||
socket.on('message', (data) => {
|
||||
logger.info('Received message from mobile:', data)
|
||||
mainWindow?.webContents.send('websocket-message-received', data)
|
||||
socket.emit('message_received', { success: true })
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
logger.info(`Client disconnected: ${socket.id}`)
|
||||
this.connectedClients.delete(socket.id)
|
||||
|
||||
if (this.connectedClients.size === 0) {
|
||||
mainWindow?.webContents.send('websocket-client-connected', {
|
||||
connected: false,
|
||||
clientId: socket.id
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Engine 层面的事件监听
|
||||
this.io.engine.on('connection_error', (err) => {
|
||||
logger.error('Engine connection error:', err)
|
||||
})
|
||||
|
||||
this.io.engine.on('connection', (rawSocket) => {
|
||||
const remoteAddr = rawSocket.request.connection.remoteAddress
|
||||
logger.info(`[Engine] Raw connection from: ${remoteAddr}`)
|
||||
logger.info(`[Engine] Transport: ${rawSocket.transport.name}`)
|
||||
|
||||
rawSocket.on('packet', (packet: { type: string; data?: any }) => {
|
||||
logger.info(
|
||||
`[Engine] ← Packet from ${remoteAddr}: type="${packet.type}"`,
|
||||
packet.data ? { data: packet.data } : {}
|
||||
)
|
||||
})
|
||||
|
||||
rawSocket.on('packetCreate', (packet: { type: string; data?: any }) => {
|
||||
logger.info(`[Engine] → Packet to ${remoteAddr}: type="${packet.type}"`)
|
||||
})
|
||||
|
||||
rawSocket.on('close', (reason: string) => {
|
||||
logger.warn(`[Engine] Connection closed from ${remoteAddr}, reason: ${reason}`)
|
||||
})
|
||||
|
||||
rawSocket.on('error', (error: Error) => {
|
||||
logger.error(`[Engine] Connection error from ${remoteAddr}:`, error)
|
||||
})
|
||||
})
|
||||
|
||||
// Socket.IO 握手失败监听
|
||||
this.io.on('connection_error', (err) => {
|
||||
logger.error('[Socket.IO] Connection error during handshake:', err)
|
||||
})
|
||||
|
||||
this.isStarted = true
|
||||
logger.info(`WebSocket server started on port ${this.port}`)
|
||||
|
||||
return { success: true, port: this.port }
|
||||
} catch (error) {
|
||||
logger.error('Failed to start WebSocket server:', error as Error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public stop = async (): Promise<{ success: boolean }> => {
|
||||
if (!this.isStarted || !this.io) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.io!.close(() => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
this.io = null
|
||||
this.isStarted = false
|
||||
this.connectedClients.clear()
|
||||
logger.info('WebSocket server stopped')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop WebSocket server:', error as Error)
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
public getStatus = async (): Promise<WebSocketStatusResponse> => {
|
||||
return {
|
||||
isRunning: this.isStarted,
|
||||
port: this.isStarted ? this.port : undefined,
|
||||
ip: this.isStarted ? this.getLocalIpAddress() : undefined,
|
||||
clientConnected: this.connectedClients.size > 0
|
||||
}
|
||||
}
|
||||
|
||||
public getAllCandidates = async (): Promise<WebSocketCandidatesResponse[]> => {
|
||||
const interfaces = networkInterfaces()
|
||||
|
||||
// 按优先级排序的网络接口名称模式
|
||||
const interfacePriority = [
|
||||
// macOS: 以太网/Wi-Fi 优先
|
||||
/^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi)
|
||||
/^(en|eth)[0-9]+$/, // 以太网接口
|
||||
/^wlan[0-9]+$/, // 无线接口
|
||||
// Windows: 以太网/Wi-Fi 优先
|
||||
/^(Ethernet|Wi-Fi|Local Area Connection)/,
|
||||
/^(Wi-Fi|无线网络连接)/,
|
||||
// Linux: 以太网/Wi-Fi 优先
|
||||
/^(eth|enp|wlp|wlan)[0-9]+/,
|
||||
// 虚拟化接口(低优先级)
|
||||
/^bridge[0-9]+$/, // Docker bridge
|
||||
/^veth[0-9]+$/, // Docker veth
|
||||
/^docker[0-9]+/, // Docker interfaces
|
||||
/^br-[0-9a-f]+/, // Docker bridge
|
||||
/^vmnet[0-9]+$/, // VMware
|
||||
/^vboxnet[0-9]+$/, // VirtualBox
|
||||
// VPN 隧道接口(低优先级)
|
||||
/^utun[0-9]+$/, // macOS VPN
|
||||
/^tun[0-9]+$/, // Linux/Unix VPN
|
||||
/^tap[0-9]+$/, // TAP interfaces
|
||||
/^tailscale[0-9]*$/, // Tailscale VPN
|
||||
/^wg[0-9]+$/ // WireGuard VPN
|
||||
]
|
||||
|
||||
const candidates: Array<{ host: string; interface: string; priority: number }> = []
|
||||
|
||||
for (const [name, ifaces] of Object.entries(interfaces)) {
|
||||
for (const iface of ifaces || []) {
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
// 计算接口优先级
|
||||
let priority = 999 // 默认最低优先级
|
||||
for (let i = 0; i < interfacePriority.length; i++) {
|
||||
if (interfacePriority[i].test(name)) {
|
||||
priority = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
host: iface.address,
|
||||
interface: name,
|
||||
priority
|
||||
})
|
||||
|
||||
logger.debug(`Found interface: ${name} -> ${iface.address} (priority: ${priority})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按优先级排序返回
|
||||
candidates.sort((a, b) => a.priority - b.priority)
|
||||
logger.info(
|
||||
`Found ${candidates.length} IP candidates: ${candidates.map((c) => `${c.host}(${c.interface})`).join(', ')}`
|
||||
)
|
||||
return candidates
|
||||
}
|
||||
|
||||
public sendFile = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
filePath: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!this.isStarted || !this.io) {
|
||||
const errorMsg = 'WebSocket server is not running.'
|
||||
logger.error(errorMsg)
|
||||
return { success: false, error: errorMsg }
|
||||
}
|
||||
|
||||
if (this.connectedClients.size === 0) {
|
||||
const errorMsg = 'No client connected.'
|
||||
logger.error(errorMsg)
|
||||
return { success: false, error: errorMsg }
|
||||
}
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const stats = fs.statSync(filePath)
|
||||
const totalSize = stats.size
|
||||
const filename = path.basename(filePath)
|
||||
const stream = fs.createReadStream(filePath)
|
||||
let bytesSent = 0
|
||||
const startTime = Date.now()
|
||||
|
||||
logger.info(`Starting file transfer: ${filename} (${this.formatFileSize(totalSize)})`)
|
||||
|
||||
// 向客户端发送文件开始的信号,包含文件名和总大小
|
||||
this.io!.emit('zip-file-start', { filename, totalSize })
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
bytesSent += chunk.length
|
||||
const progress = (bytesSent / totalSize) * 100
|
||||
|
||||
// 向客户端发送文件块
|
||||
this.io!.emit('zip-file-chunk', chunk)
|
||||
|
||||
// 向渲染进程发送进度更新
|
||||
mainWindow?.webContents.send('file-send-progress', { progress })
|
||||
|
||||
// 每10%记录一次进度
|
||||
if (Math.floor(progress) % 10 === 0) {
|
||||
const elapsed = (Date.now() - startTime) / 1000
|
||||
const speed = elapsed > 0 ? bytesSent / elapsed : 0
|
||||
logger.info(`Transfer progress: ${Math.floor(progress)}% (${this.formatFileSize(speed)}/s)`)
|
||||
}
|
||||
})
|
||||
|
||||
stream.on('end', () => {
|
||||
const totalTime = (Date.now() - startTime) / 1000
|
||||
const avgSpeed = totalTime > 0 ? totalSize / totalTime : 0
|
||||
logger.info(
|
||||
`File transfer completed: ${filename} in ${totalTime.toFixed(1)}s (${this.formatFileSize(avgSpeed)}/s)`
|
||||
)
|
||||
|
||||
// 确保发送100%的进度
|
||||
mainWindow?.webContents.send('file-send-progress', { progress: 100 })
|
||||
// 向客户端发送文件结束的信号
|
||||
this.io!.emit('zip-file-end')
|
||||
resolve({ success: true })
|
||||
})
|
||||
|
||||
stream.on('error', (error) => {
|
||||
logger.error(`File transfer failed: ${filename}`, error)
|
||||
reject({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
}
|
||||
|
||||
export default new WebSocketService()
|
||||
@@ -1,4 +1,5 @@
|
||||
import { session, shell, webContents } from 'electron'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { app, session, shell, webContents } from 'electron'
|
||||
|
||||
/**
|
||||
* init the useragent of the webview session
|
||||
@@ -36,3 +37,66 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const attachKeyboardHandler = (contents: Electron.WebContents) => {
|
||||
if (contents.getType?.() !== 'webview') {
|
||||
return
|
||||
}
|
||||
|
||||
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = input.key?.toLowerCase()
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
|
||||
const isFindShortcut = (input.control || input.meta) && key === 'f'
|
||||
const isEscape = key === 'escape'
|
||||
const isEnter = key === 'enter'
|
||||
|
||||
if (!isFindShortcut && !isEscape && !isEnter) {
|
||||
return
|
||||
}
|
||||
|
||||
const host = contents.hostWebContents
|
||||
if (!host || host.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
|
||||
if (isFindShortcut) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// Send the hotkey event to the renderer
|
||||
// The renderer will decide whether to preventDefault for Escape and Enter
|
||||
// based on whether the search bar is visible
|
||||
host.send(IpcChannel.Webview_SearchHotkey, {
|
||||
webviewId: contents.id,
|
||||
key,
|
||||
control: Boolean(input.control),
|
||||
meta: Boolean(input.meta),
|
||||
shift: Boolean(input.shift),
|
||||
alt: Boolean(input.alt)
|
||||
})
|
||||
}
|
||||
|
||||
contents.on('before-input-event', handleBeforeInput)
|
||||
contents.once('destroyed', () => {
|
||||
contents.removeListener('before-input-event', handleBeforeInput)
|
||||
})
|
||||
}
|
||||
|
||||
export function initWebviewHotkeys() {
|
||||
webContents.getAllWebContents().forEach((contents) => {
|
||||
if (contents.isDestroyed()) return
|
||||
attachKeyboardHandler(contents)
|
||||
})
|
||||
|
||||
app.on('web-contents-created', (_, contents) => {
|
||||
attachKeyboardHandler(contents)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -274,46 +274,4 @@ describe('AppUpdater', () => {
|
||||
expect(result.releaseNotes).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatReleaseNotes', () => {
|
||||
it('should format string release notes with markers', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
||||
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('English')
|
||||
})
|
||||
|
||||
it('should format string release notes without markers', () => {
|
||||
const notes = 'Simple notes'
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Simple notes')
|
||||
})
|
||||
|
||||
it('should format array release notes', () => {
|
||||
const notes = [
|
||||
{ version: '1.0.0', note: 'Note 1' },
|
||||
{ version: '1.0.1', note: 'Note 2' }
|
||||
]
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Note 1\nNote 2')
|
||||
})
|
||||
|
||||
it('should handle null release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(null)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle undefined release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(undefined)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
86
src/main/services/__tests__/ProxyManager.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { isByPass, updateByPassRules } from '../ProxyManager'
|
||||
|
||||
describe('ProxyManager - bypass evaluation', () => {
|
||||
beforeEach(() => {
|
||||
updateByPassRules([])
|
||||
})
|
||||
|
||||
it('matches simple hostname patterns', () => {
|
||||
updateByPassRules(['foobar.com'])
|
||||
expect(isByPass('http://foobar.com')).toBe(true)
|
||||
expect(isByPass('http://www.foobar.com')).toBe(false)
|
||||
|
||||
updateByPassRules(['*.foobar.com'])
|
||||
expect(isByPass('http://api.foobar.com')).toBe(true)
|
||||
expect(isByPass('http://foobar.com')).toBe(true)
|
||||
expect(isByPass('http://foobar.org')).toBe(false)
|
||||
|
||||
updateByPassRules(['*foobar.com'])
|
||||
expect(isByPass('http://devfoobar.com')).toBe(true)
|
||||
expect(isByPass('http://foobar.com')).toBe(true)
|
||||
expect(isByPass('http://foobar.company')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches hostname patterns with scheme and port qualifiers', () => {
|
||||
updateByPassRules(['https://secure.example.com'])
|
||||
expect(isByPass('https://secure.example.com')).toBe(true)
|
||||
expect(isByPass('https://secure.example.com:443/home')).toBe(true)
|
||||
expect(isByPass('http://secure.example.com')).toBe(false)
|
||||
|
||||
updateByPassRules(['https://secure.example.com:8443'])
|
||||
expect(isByPass('https://secure.example.com:8443')).toBe(true)
|
||||
expect(isByPass('https://secure.example.com')).toBe(false)
|
||||
expect(isByPass('https://secure.example.com:443')).toBe(false)
|
||||
|
||||
updateByPassRules(['https://x.*.y.com:99'])
|
||||
expect(isByPass('https://x.api.y.com:99')).toBe(true)
|
||||
expect(isByPass('https://x.api.y.com')).toBe(false)
|
||||
expect(isByPass('http://x.api.y.com:99')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches domain suffix patterns with leading dot', () => {
|
||||
updateByPassRules(['.example.com'])
|
||||
expect(isByPass('https://example.com')).toBe(true)
|
||||
expect(isByPass('https://api.example.com')).toBe(true)
|
||||
expect(isByPass('https://deep.api.example.com')).toBe(true)
|
||||
expect(isByPass('https://example.org')).toBe(false)
|
||||
|
||||
updateByPassRules(['.com'])
|
||||
expect(isByPass('https://anything.com')).toBe(true)
|
||||
expect(isByPass('https://example.org')).toBe(false)
|
||||
|
||||
updateByPassRules(['http://.google.com'])
|
||||
expect(isByPass('http://maps.google.com')).toBe(true)
|
||||
expect(isByPass('https://maps.google.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches IP literals, CIDR ranges, and wildcard IPs', () => {
|
||||
updateByPassRules(['127.0.0.1', '[::1]', '192.168.1.0/24', 'fefe:13::abc/33', '192.168.*.*'])
|
||||
|
||||
expect(isByPass('http://127.0.0.1')).toBe(true)
|
||||
expect(isByPass('http://[::1]')).toBe(true)
|
||||
expect(isByPass('http://192.168.1.55')).toBe(true)
|
||||
expect(isByPass('http://192.168.200.200')).toBe(true)
|
||||
expect(isByPass('http://192.169.1.1')).toBe(false)
|
||||
expect(isByPass('http://[fefe:13::abc]')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches CIDR ranges specified with IPv6 prefix lengths', () => {
|
||||
updateByPassRules(['[2001:db8::1]', '2001:db8::/32'])
|
||||
|
||||
expect(isByPass('http://[2001:db8::1]')).toBe(true)
|
||||
expect(isByPass('http://[2001:db8:0:0:0:0:0:ffff]')).toBe(true)
|
||||
expect(isByPass('http://[2001:db9::1]')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches local addresses when <local> keyword is provided', () => {
|
||||
updateByPassRules(['<local>'])
|
||||
|
||||
expect(isByPass('http://localhost')).toBe(true)
|
||||
expect(isByPass('http://127.0.0.1')).toBe(true)
|
||||
expect(isByPass('http://[::1]')).toBe(true)
|
||||
expect(isByPass('http://dev.localdomain')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { loggerService } from '@logger'
|
||||
import { isLinux } from '@main/constant'
|
||||
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
||||
|
||||
import { ovOcrService } from './builtin/OvOcrService'
|
||||
import { ppocrService } from './builtin/PpocrService'
|
||||
import { systemOcrService } from './builtin/SystemOcrService'
|
||||
import { tesseractService } from './builtin/TesseractService'
|
||||
@@ -22,6 +23,10 @@ export class OcrService {
|
||||
this.registry.delete(providerId)
|
||||
}
|
||||
|
||||
public listProviderIds(): string[] {
|
||||
return Array.from(this.registry.keys())
|
||||
}
|
||||
|
||||
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
|
||||
const handler = this.registry.get(provider.id)
|
||||
if (!handler) {
|
||||
@@ -39,3 +44,5 @@ ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(t
|
||||
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
|
||||
|
||||
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
|
||||
|
||||
ovOcrService.isAvailable() && ocrService.register(BuiltinOcrProviderIds.ovocr, ovOcrService.ocr.bind(ovOcrService))
|
||||
|
||||
128
src/main/services/ocr/builtin/OvOcrService.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { isImageFileMetadata, OcrOvConfig, OcrResult, SupportedOcrFile } from '@types'
|
||||
import { exec } from 'child_process'
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { promisify } from 'util'
|
||||
|
||||
import { OcrBaseService } from './OcrBaseService'
|
||||
|
||||
const logger = loggerService.withContext('OvOcrService')
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat')
|
||||
|
||||
export class OvOcrService extends OcrBaseService {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
public isAvailable(): boolean {
|
||||
return (
|
||||
isWin &&
|
||||
os.cpus()[0].model.toLowerCase().includes('intel') &&
|
||||
os.cpus()[0].model.toLowerCase().includes('ultra') &&
|
||||
fs.existsSync(PATH_BAT_FILE)
|
||||
)
|
||||
}
|
||||
|
||||
private getOvOcrPath(): string {
|
||||
return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr')
|
||||
}
|
||||
|
||||
private getImgDir(): string {
|
||||
return path.join(this.getOvOcrPath(), 'img')
|
||||
}
|
||||
|
||||
private getOutputDir(): string {
|
||||
return path.join(this.getOvOcrPath(), 'output')
|
||||
}
|
||||
|
||||
private async clearDirectory(dirPath: string): Promise<void> {
|
||||
if (fs.existsSync(dirPath)) {
|
||||
const files = await fs.promises.readdir(dirPath)
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dirPath, file)
|
||||
const stats = await fs.promises.stat(filePath)
|
||||
if (stats.isDirectory()) {
|
||||
await this.clearDirectory(filePath)
|
||||
await fs.promises.rmdir(filePath)
|
||||
} else {
|
||||
await fs.promises.unlink(filePath)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If the directory does not exist, create it
|
||||
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private async copyFileToImgDir(sourceFilePath: string, targetFileName: string): Promise<void> {
|
||||
const imgDir = this.getImgDir()
|
||||
const targetFilePath = path.join(imgDir, targetFileName)
|
||||
await fs.promises.copyFile(sourceFilePath, targetFilePath)
|
||||
}
|
||||
|
||||
private async runOcrBatch(): Promise<void> {
|
||||
const ovOcrPath = this.getOvOcrPath()
|
||||
|
||||
try {
|
||||
// Execute run.bat in the ov-ocr directory
|
||||
await execAsync(`"${PATH_BAT_FILE}"`, {
|
||||
cwd: ovOcrPath,
|
||||
timeout: 60000 // 60 second timeout
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Error running ovocr batch: ${error}`)
|
||||
throw new Error(`Failed to run OCR batch: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async ocrImage(filePath: string, options?: OcrOvConfig): Promise<OcrResult> {
|
||||
logger.info(`OV OCR called on ${filePath} with options ${JSON.stringify(options)}`)
|
||||
|
||||
try {
|
||||
// 1. Clear img directory and output directory
|
||||
await this.clearDirectory(this.getImgDir())
|
||||
await this.clearDirectory(this.getOutputDir())
|
||||
|
||||
// 2. Copy file to img directory
|
||||
const fileName = path.basename(filePath)
|
||||
await this.copyFileToImgDir(filePath, fileName)
|
||||
logger.info(`File copied to img directory: ${fileName}`)
|
||||
|
||||
// 3. Run run.bat
|
||||
logger.info('Running OV OCR batch process...')
|
||||
await this.runOcrBatch()
|
||||
|
||||
// 4. Check that output/[basename].txt file exists
|
||||
const baseNameWithoutExt = path.basename(fileName, path.extname(fileName))
|
||||
const outputFilePath = path.join(this.getOutputDir(), `${baseNameWithoutExt}.txt`)
|
||||
if (!fs.existsSync(outputFilePath)) {
|
||||
throw new Error(`OV OCR output file not found at: ${outputFilePath}`)
|
||||
}
|
||||
|
||||
// 5. Read output/[basename].txt file content
|
||||
const ocrText = await fs.promises.readFile(outputFilePath, 'utf-8')
|
||||
logger.info(`OV OCR text extracted: ${ocrText.substring(0, 100)}...`)
|
||||
|
||||
// 6. Return result
|
||||
return { text: ocrText }
|
||||
} catch (error) {
|
||||
logger.error(`Error during OV OCR process: ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public ocr = async (file: SupportedOcrFile, options?: OcrOvConfig): Promise<OcrResult> => {
|
||||
if (isImageFileMetadata(file)) {
|
||||
return this.ocrImage(file.path, options)
|
||||
} else {
|
||||
throw new Error('Unsupported file type, currently only image files are supported')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ovOcrService = new OvOcrService()
|
||||
@@ -9,13 +9,20 @@ const logger = loggerService.withContext('URLSchema:handleMcpProtocolUrl')
|
||||
|
||||
function installMCPServer(server: MCPServer) {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
const now = Date.now()
|
||||
|
||||
if (!server.id) {
|
||||
server.id = nanoid()
|
||||
const payload: MCPServer = {
|
||||
...server,
|
||||
id: server.id ?? nanoid(),
|
||||
installSource: 'protocol',
|
||||
isTrusted: false,
|
||||
isActive: false,
|
||||
trustedAt: undefined,
|
||||
installedAt: server.installedAt ?? now
|
||||
}
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IpcChannel.Mcp_AddServer, server)
|
||||
mainWindow.webContents.send(IpcChannel.Mcp_AddServer, payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import EnUs from '../../renderer/src/i18n/locales/en-us.json'
|
||||
import ZhCn from '../../renderer/src/i18n/locales/zh-cn.json'
|
||||
import ZhTw from '../../renderer/src/i18n/locales/zh-tw.json'
|
||||
// Machine translation
|
||||
import deDE from '../../renderer/src/i18n/translate/de-de.json'
|
||||
import elGR from '../../renderer/src/i18n/translate/el-gr.json'
|
||||
import esES from '../../renderer/src/i18n/translate/es-es.json'
|
||||
import frFR from '../../renderer/src/i18n/translate/fr-fr.json'
|
||||
@@ -16,6 +17,7 @@ const locales = Object.fromEntries(
|
||||
['zh-TW', ZhTw],
|
||||
['ja-JP', JaJP],
|
||||
['ru-RU', RuRu],
|
||||
['de-DE', deDE],
|
||||
['el-GR', elGR],
|
||||
['es-ES', esES],
|
||||
['fr-FR', frFR],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import { SpanContext } from '@opentelemetry/api'
|
||||
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
|
||||
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
|
||||
import type { FileChangeEvent } from '@shared/config/types'
|
||||
import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import type { Notification } from '@types'
|
||||
import {
|
||||
@@ -51,7 +51,7 @@ const api = {
|
||||
setProxy: (proxy: string | undefined, bypassRules?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
|
||||
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
||||
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
|
||||
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
|
||||
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
||||
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
|
||||
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
|
||||
@@ -221,7 +221,7 @@ const api = {
|
||||
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
|
||||
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
|
||||
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
|
||||
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
|
||||
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
|
||||
add: ({
|
||||
base,
|
||||
item,
|
||||
@@ -388,7 +388,16 @@ const api = {
|
||||
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
|
||||
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
|
||||
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
|
||||
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
|
||||
return () => {
|
||||
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
|
||||
}
|
||||
}
|
||||
},
|
||||
storeSync: {
|
||||
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
|
||||
@@ -465,7 +474,8 @@ const api = {
|
||||
},
|
||||
ocr: {
|
||||
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
|
||||
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
|
||||
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider),
|
||||
listProviders: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.OCR_ListProviders)
|
||||
},
|
||||
cherryai: {
|
||||
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
|
||||
@@ -485,6 +495,13 @@ const api = {
|
||||
ipcRenderer.removeListener(channel, listener)
|
||||
}
|
||||
}
|
||||
},
|
||||
webSocket: {
|
||||
start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start),
|
||||
stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop),
|
||||
status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status),
|
||||
sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath),
|
||||
getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
|
||||
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
import { ProviderSpecificError } from '@renderer/types/provider-specific-error'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
|
||||
import type { TextStreamPart, ToolSet } from 'ai'
|
||||
import { AISDKError, type TextStreamPart, type ToolSet } from 'ai'
|
||||
|
||||
import { ToolCallChunkHandler } from './handleToolCallChunk'
|
||||
|
||||
@@ -340,7 +342,14 @@ export class AiSdkToChunkAdapter {
|
||||
case 'error':
|
||||
this.onChunk({
|
||||
type: ChunkType.ERROR,
|
||||
error: chunk.error as Record<string, any>
|
||||
error:
|
||||
chunk.error instanceof AISDKError
|
||||
? chunk.error
|
||||
: new ProviderSpecificError({
|
||||
message: formatErrorMessage(chunk.error),
|
||||
provider: 'unknown',
|
||||
cause: chunk.error
|
||||
})
|
||||
})
|
||||
break
|
||||
|
||||
|
||||
@@ -83,10 +83,8 @@ export default class ModernAiProvider {
|
||||
throw new Error('Model is required for completions. Please use constructor with model parameter.')
|
||||
}
|
||||
|
||||
// 确保配置存在
|
||||
if (!this.config) {
|
||||
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
|
||||
}
|
||||
// 每次请求时重新生成配置以确保API key轮换生效
|
||||
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
|
||||
|
||||
// 准备特殊配置
|
||||
await prepareSpecialProviderConfig(this.actualProvider, this.config)
|
||||
|
||||
@@ -70,13 +70,19 @@ export abstract class BaseApiClient<
|
||||
{
|
||||
public provider: Provider
|
||||
protected host: string
|
||||
protected apiKey: string
|
||||
protected sdkInstance?: TSdkInstance
|
||||
|
||||
constructor(provider: Provider) {
|
||||
this.provider = provider
|
||||
this.host = this.getBaseURL()
|
||||
this.apiKey = this.getApiKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current API key with rotation support
|
||||
* This getter ensures API keys rotate on each access when multiple keys are configured
|
||||
*/
|
||||
protected get apiKey(): string {
|
||||
return this.getApiKey()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isGPT5SeriesModel,
|
||||
isGrokReasoningModel,
|
||||
isNotSupportSystemMessageModel,
|
||||
isOpenAIDeepResearchModel,
|
||||
isOpenAIOpenWeightModel,
|
||||
isOpenAIReasoningModel,
|
||||
isQwenAlwaysThinkModel,
|
||||
@@ -125,6 +126,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
return {}
|
||||
}
|
||||
|
||||
if (isOpenAIDeepResearchModel(model)) {
|
||||
return {
|
||||
reasoning_effort: 'medium'
|
||||
}
|
||||
}
|
||||
|
||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
||||
|
||||
if (isSupportedThinkingTokenZhipuModel(model)) {
|
||||
@@ -177,7 +184,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
extra_body: {
|
||||
google: {
|
||||
thinking_config: {
|
||||
thinking_budget: 0
|
||||
thinkingBudget: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,8 +319,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
extra_body: {
|
||||
google: {
|
||||
thinking_config: {
|
||||
thinking_budget: -1,
|
||||
include_thoughts: true
|
||||
thinkingBudget: -1,
|
||||
includeThoughts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,8 +330,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
extra_body: {
|
||||
google: {
|
||||
thinking_config: {
|
||||
thinking_budget: budgetTokens,
|
||||
include_thoughts: true
|
||||
thinkingBudget: budgetTokens,
|
||||
includeThoughts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,7 +662,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
} else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) {
|
||||
suffix = ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}`
|
||||
} else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) {
|
||||
suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}`
|
||||
suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinkingBudget}`
|
||||
}
|
||||
// FIXME: poe 不支持多个text part,上传文本文件的时候用的不是file part而是text part,因此会出问题
|
||||
// 临时解决方案是强制poe用string content,但是其实poe部分支持array
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { MCPTool, Message, Model, Provider } from '@renderer/types'
|
||||
import type { Chunk } from '@renderer/types/chunk'
|
||||
import { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai'
|
||||
|
||||
import { toolChoiceMiddleware } from './toolChoiceMiddleware'
|
||||
|
||||
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
|
||||
|
||||
/**
|
||||
@@ -29,6 +31,8 @@ export interface AiSdkMiddlewareConfig {
|
||||
uiMessages?: Message[]
|
||||
// 内置搜索配置
|
||||
webSearchPluginConfig?: WebSearchPluginConfig
|
||||
// 知识库识别开关,默认开启
|
||||
knowledgeRecognition?: 'off' | 'on'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +123,15 @@ export class AiSdkMiddlewareBuilder {
|
||||
export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageModelMiddleware[] {
|
||||
const builder = new AiSdkMiddlewareBuilder()
|
||||
|
||||
// 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库)
|
||||
if (config.knowledgeRecognition === 'off') {
|
||||
builder.add({
|
||||
name: 'force-knowledge-first',
|
||||
middleware: toolChoiceMiddleware('builtin_knowledge_search')
|
||||
})
|
||||
logger.debug('Added toolChoice middleware to force knowledge base search on first round')
|
||||
}
|
||||
|
||||
// 1. 根据provider添加特定中间件
|
||||
if (config.provider) {
|
||||
addProviderSpecificMiddlewares(builder, config)
|
||||
|
||||
45
src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { LanguageModelMiddleware } from 'ai'
|
||||
|
||||
const logger = loggerService.withContext('toolChoiceMiddleware')
|
||||
|
||||
/**
|
||||
* Tool Choice Middleware
|
||||
* Controls tool selection strategy across multiple rounds of tool calls:
|
||||
* - First round: Forces the model to call a specific tool (e.g., knowledge base search)
|
||||
* - Subsequent rounds: Allows the model to automatically choose any available tool
|
||||
*
|
||||
* This ensures knowledge base is consulted first while still enabling MCP tools
|
||||
* and other capabilities in follow-up interactions.
|
||||
*
|
||||
* @param forceFirstToolName - The tool name to force on the first round
|
||||
* @returns LanguageModelMiddleware
|
||||
*/
|
||||
export function toolChoiceMiddleware(forceFirstToolName: string): LanguageModelMiddleware {
|
||||
let toolCallRound = 0
|
||||
|
||||
return {
|
||||
middlewareVersion: 'v2',
|
||||
|
||||
transformParams: async ({ params }) => {
|
||||
toolCallRound++
|
||||
|
||||
const transformedParams = { ...params }
|
||||
|
||||
if (toolCallRound === 1) {
|
||||
// First round: force the specified tool
|
||||
logger.debug(`Round ${toolCallRound}: Forcing tool choice to '${forceFirstToolName}'`)
|
||||
transformedParams.toolChoice = {
|
||||
type: 'tool',
|
||||
toolName: forceFirstToolName
|
||||
}
|
||||
} else {
|
||||
// Subsequent rounds: allow automatic tool selection
|
||||
logger.debug(`Round ${toolCallRound}: Using automatic tool choice`)
|
||||
transformedParams.toolChoice = { type: 'auto' }
|
||||
}
|
||||
|
||||
return transformedParams
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { isVisionModel } from '@renderer/config/models'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import type { Model } from '@renderer/types'
|
||||
import type { EndpointType, Model } from '@renderer/types'
|
||||
import { FileTypes } from '@renderer/types'
|
||||
|
||||
import { getAiSdkProviderId } from '../provider/factory'
|
||||
@@ -17,12 +17,14 @@ function modelSupportValidator(
|
||||
supportedModels = [],
|
||||
unsupportedModels = [],
|
||||
supportedProviders = [],
|
||||
unsupportedProviders = []
|
||||
unsupportedProviders = [],
|
||||
supportedEndpointTypes = []
|
||||
}: {
|
||||
supportedModels?: string[]
|
||||
unsupportedModels?: string[]
|
||||
supportedProviders?: string[]
|
||||
unsupportedProviders?: string[]
|
||||
supportedEndpointTypes?: EndpointType[]
|
||||
}
|
||||
): boolean {
|
||||
const provider = getProviderByModel(model)
|
||||
@@ -43,6 +45,11 @@ function modelSupportValidator(
|
||||
return true
|
||||
}
|
||||
|
||||
// 根据 EndpointType 进行判断
|
||||
if (model.endpoint_type && supportedEndpointTypes.includes(model.endpoint_type)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 回退到提供商判断
|
||||
return supportedProviders.includes(aiSdkId)
|
||||
}
|
||||
@@ -54,6 +61,7 @@ export function supportsPdfInput(model: Model): boolean {
|
||||
// 基于AI SDK文档,以下模型或提供商支持PDF输入
|
||||
return modelSupportValidator(model, {
|
||||
supportedModels: ['qwen-long', 'qwen-doc'],
|
||||
supportedEndpointTypes: ['anthropic', 'gemini'],
|
||||
supportedProviders: [
|
||||
'openai',
|
||||
'azure-openai',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
isClaude45ReasoningModel,
|
||||
isClaudeReasoningModel,
|
||||
isNotSupportTemperatureAndTopP,
|
||||
isSupportedFlexServiceTier
|
||||
@@ -19,7 +20,10 @@ export function getTemperature(assistant: Assistant, model: Model): number | und
|
||||
if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) {
|
||||
return undefined
|
||||
}
|
||||
if (isNotSupportTemperatureAndTopP(model)) {
|
||||
if (
|
||||
isNotSupportTemperatureAndTopP(model) ||
|
||||
(isClaude45ReasoningModel(model) && assistant.settings?.enableTopP && !assistant.settings?.enableTemperature)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const assistantSettings = getAssistantSettings(assistant)
|
||||
@@ -33,7 +37,10 @@ export function getTopP(assistant: Assistant, model: Model): number | undefined
|
||||
if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) {
|
||||
return undefined
|
||||
}
|
||||
if (isNotSupportTemperatureAndTopP(model)) {
|
||||
if (
|
||||
isNotSupportTemperatureAndTopP(model) ||
|
||||
(isClaude45ReasoningModel(model) && assistant.settings?.enableTemperature)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const assistantSettings = getAssistantSettings(assistant)
|
||||
|
||||
@@ -23,6 +23,7 @@ import { CherryWebSearchConfig } from '@renderer/store/websearch'
|
||||
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
|
||||
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
||||
import { replacePromptVariables } from '@renderer/utils/prompt'
|
||||
import type { ModelMessage, Tool } from 'ai'
|
||||
import { stepCountIs } from 'ai'
|
||||
|
||||
@@ -126,7 +127,7 @@ export async function buildStreamTextParams(
|
||||
let webSearchPluginConfig: WebSearchPluginConfig | undefined = undefined
|
||||
if (enableWebSearch) {
|
||||
if (isBaseProvider(aiSdkProviderId)) {
|
||||
webSearchPluginConfig = buildProviderBuiltinWebSearchConfig(aiSdkProviderId, webSearchConfig)
|
||||
webSearchPluginConfig = buildProviderBuiltinWebSearchConfig(aiSdkProviderId, webSearchConfig, model)
|
||||
}
|
||||
if (!tools) {
|
||||
tools = {}
|
||||
@@ -166,7 +167,7 @@ export async function buildStreamTextParams(
|
||||
params.tools = tools
|
||||
}
|
||||
if (assistant.prompt) {
|
||||
params.system = assistant.prompt
|
||||
params.system = await replacePromptVariables(assistant.prompt, model.name)
|
||||
}
|
||||
logger.debug('params', params)
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* AiHubMix规则集
|
||||
*/
|
||||
import { isOpenAIModel } from '@renderer/config/models'
|
||||
import { isOpenAILLMModel } from '@renderer/config/models'
|
||||
import { Provider } from '@renderer/types'
|
||||
|
||||
import { provider2Provider, startsWith } from './helper'
|
||||
@@ -42,7 +42,7 @@ const AIHUBMIX_RULES: RuleSet = {
|
||||
}
|
||||
},
|
||||
{
|
||||
match: isOpenAIModel,
|
||||
match: isOpenAILLMModel,
|
||||
provider: (provider: Provider) => {
|
||||
return extraProviderConfig({
|
||||
...provider,
|
||||
|
||||
@@ -62,13 +62,14 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
||||
// return createVertexProvider(provider)
|
||||
// }
|
||||
|
||||
if (isNewApiProvider(provider)) {
|
||||
return newApiResolverCreator(model, provider)
|
||||
}
|
||||
|
||||
if (isSystemProvider(provider)) {
|
||||
if (provider.id === 'aihubmix') {
|
||||
return aihubmixProviderCreator(model, provider)
|
||||
}
|
||||
if (isNewApiProvider(provider)) {
|
||||
return newApiResolverCreator(model, provider)
|
||||
}
|
||||
if (provider.id === 'vertexai') {
|
||||
return vertexAnthropicProviderCreator(model, provider)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
|
||||
creatorFunctionName: 'createPerplexity',
|
||||
supportsImageGeneration: false,
|
||||
aliases: ['perplexity']
|
||||
},
|
||||
{
|
||||
id: 'mistral',
|
||||
name: 'Mistral',
|
||||
import: () => import('@ai-sdk/mistral'),
|
||||
creatorFunctionName: 'createMistral',
|
||||
supportsImageGeneration: false,
|
||||
aliases: ['mistral']
|
||||
}
|
||||
] as const
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
GEMINI_FLASH_MODEL_REGEX,
|
||||
getThinkModelType,
|
||||
isDeepSeekHybridInferenceModel,
|
||||
isDoubaoSeedAfter251015,
|
||||
isDoubaoThinkingAutoModel,
|
||||
isGrok4FastReasoningModel,
|
||||
isGrokReasoningModel,
|
||||
isOpenAIDeepResearchModel,
|
||||
isOpenAIReasoningModel,
|
||||
isQwenAlwaysThinkModel,
|
||||
isQwenReasoningModel,
|
||||
@@ -29,6 +32,7 @@ import { getAssistantSettings, getProviderByModel } from '@renderer/services/Ass
|
||||
import { SettingsState } from '@renderer/store/settings'
|
||||
import { Assistant, EFFORT_RATIO, isSystemProvider, Model, SystemProviderIds } from '@renderer/types'
|
||||
import { ReasoningEffortOptionalParams } from '@renderer/types/sdk'
|
||||
import { toInteger } from 'lodash'
|
||||
|
||||
const logger = loggerService.withContext('reasoning')
|
||||
|
||||
@@ -42,6 +46,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
if (!isReasoningModel(model)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (isOpenAIDeepResearchModel(model)) {
|
||||
return {
|
||||
reasoning_effort: 'medium'
|
||||
}
|
||||
}
|
||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
||||
|
||||
if (!reasoningEffort) {
|
||||
@@ -52,7 +62,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
return {}
|
||||
}
|
||||
// Don't disable reasoning for models that require it
|
||||
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
|
||||
if (
|
||||
isGrokReasoningModel(model) ||
|
||||
isOpenAIReasoningModel(model) ||
|
||||
isQwenAlwaysThinkModel(model) ||
|
||||
model.id.includes('seed-oss')
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
return { reasoning: { enabled: false, exclude: true } }
|
||||
@@ -80,7 +95,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
extra_body: {
|
||||
google: {
|
||||
thinking_config: {
|
||||
thinking_budget: 0
|
||||
thinkingBudget: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +113,52 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
}
|
||||
|
||||
// reasoningEffort有效的情况
|
||||
|
||||
// OpenRouter models
|
||||
if (model.provider === SystemProviderIds.openrouter) {
|
||||
// Grok 4 Fast doesn't support effort levels, always use enabled: true
|
||||
if (isGrok4FastReasoningModel(model)) {
|
||||
return {
|
||||
reasoning: {
|
||||
enabled: true // Ignore effort level, just enable reasoning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Other OpenRouter models that support effort levels
|
||||
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
|
||||
return {
|
||||
reasoning: {
|
||||
effort: reasoningEffort === 'auto' ? 'medium' : reasoningEffort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effortRatio = EFFORT_RATIO[reasoningEffort]
|
||||
const tokenLimit = findTokenLimit(model.id)
|
||||
let budgetTokens: number | undefined
|
||||
if (tokenLimit) {
|
||||
budgetTokens = Math.floor((tokenLimit.max - tokenLimit.min) * effortRatio + tokenLimit.min)
|
||||
}
|
||||
|
||||
// See https://docs.siliconflow.cn/cn/api-reference/chat-completions/chat-completions
|
||||
if (model.provider === SystemProviderIds.silicon) {
|
||||
if (
|
||||
isDeepSeekHybridInferenceModel(model) ||
|
||||
isSupportedThinkingTokenZhipuModel(model) ||
|
||||
isSupportedThinkingTokenQwenModel(model) ||
|
||||
isSupportedThinkingTokenHunyuanModel(model)
|
||||
) {
|
||||
return {
|
||||
enable_thinking: true,
|
||||
// Hard-encoded maximum, only for silicon
|
||||
thinking_budget: budgetTokens ? toInteger(Math.max(budgetTokens, 32768)) : undefined
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
|
||||
// 不同的 provider 有不同的思考控制方式,在这里统一解决
|
||||
if (isDeepSeekHybridInferenceModel(model)) {
|
||||
@@ -108,10 +169,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
enable_thinking: true,
|
||||
incremental_output: true
|
||||
}
|
||||
case SystemProviderIds.silicon:
|
||||
return {
|
||||
enable_thinking: true
|
||||
}
|
||||
case SystemProviderIds.hunyuan:
|
||||
case SystemProviderIds['tencent-cloud-ti']:
|
||||
case SystemProviderIds.doubao:
|
||||
@@ -136,39 +193,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
logger.warn(
|
||||
`Skipping thinking options for provider ${provider.name} as DeepSeek v3.1 thinking control method is unknown`
|
||||
)
|
||||
case SystemProviderIds.silicon:
|
||||
// specially handled before
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter models
|
||||
if (model.provider === SystemProviderIds.openrouter) {
|
||||
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
|
||||
return {
|
||||
reasoning: {
|
||||
effort: reasoningEffort === 'auto' ? 'medium' : reasoningEffort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Doubao 思考模式支持
|
||||
if (isSupportedThinkingTokenDoubaoModel(model)) {
|
||||
// reasoningEffort 为空,默认开启 enabled
|
||||
if (reasoningEffort === 'high') {
|
||||
return { thinking: { type: 'enabled' } }
|
||||
}
|
||||
if (reasoningEffort === 'auto' && isDoubaoThinkingAutoModel(model)) {
|
||||
return { thinking: { type: 'auto' } }
|
||||
}
|
||||
// 其他情况不带 thinking 字段
|
||||
return {}
|
||||
}
|
||||
|
||||
const effortRatio = EFFORT_RATIO[reasoningEffort]
|
||||
const budgetTokens = Math.floor(
|
||||
(findTokenLimit(model.id)?.max! - findTokenLimit(model.id)?.min!) * effortRatio + findTokenLimit(model.id)?.min!
|
||||
)
|
||||
|
||||
// OpenRouter models, use thinking
|
||||
if (model.provider === SystemProviderIds.openrouter) {
|
||||
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
|
||||
@@ -209,12 +239,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
const supportedOptions = MODEL_SUPPORTED_REASONING_EFFORT[modelType]
|
||||
if (supportedOptions.includes(reasoningEffort)) {
|
||||
return {
|
||||
reasoning_effort: reasoningEffort
|
||||
reasoningEffort
|
||||
}
|
||||
} else {
|
||||
// 如果不支持,fallback到第一个支持的值
|
||||
return {
|
||||
reasoning_effort: supportedOptions[0]
|
||||
reasoningEffort: supportedOptions[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,8 +256,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
extra_body: {
|
||||
google: {
|
||||
thinking_config: {
|
||||
thinking_budget: -1,
|
||||
include_thoughts: true
|
||||
thinkingBudget: -1,
|
||||
includeThoughts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,8 +267,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
extra_body: {
|
||||
google: {
|
||||
thinking_config: {
|
||||
thinking_budget: budgetTokens,
|
||||
include_thoughts: true
|
||||
thinkingBudget: budgetTokens,
|
||||
includeThoughts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,22 +281,26 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
return {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: Math.floor(
|
||||
Math.max(1024, Math.min(budgetTokens, (maxTokens || DEFAULT_MAX_TOKENS) * effortRatio))
|
||||
)
|
||||
budget_tokens: budgetTokens
|
||||
? Math.floor(Math.max(1024, Math.min(budgetTokens, (maxTokens || DEFAULT_MAX_TOKENS) * effortRatio)))
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use thinking, doubao, zhipu, etc.
|
||||
if (isSupportedThinkingTokenDoubaoModel(model)) {
|
||||
if (assistant.settings?.reasoning_effort === 'high') {
|
||||
return {
|
||||
thinking: {
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
if (isDoubaoSeedAfter251015(model)) {
|
||||
return { reasoningEffort }
|
||||
}
|
||||
if (reasoningEffort === 'high') {
|
||||
return { thinking: { type: 'enabled' } }
|
||||
}
|
||||
if (reasoningEffort === 'auto' && isDoubaoThinkingAutoModel(model)) {
|
||||
return { thinking: { type: 'auto' } }
|
||||
}
|
||||
// 其他情况不带 thinking 字段
|
||||
return {}
|
||||
}
|
||||
if (isSupportedThinkingTokenZhipuModel(model)) {
|
||||
return { thinking: { type: 'enabled' } }
|
||||
@@ -295,7 +329,11 @@ export function getOpenAIReasoningParams(assistant: Assistant, model: Model): Re
|
||||
reasoningSummary = summaryText
|
||||
}
|
||||
|
||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
||||
let reasoningEffort = assistant?.settings?.reasoning_effort
|
||||
|
||||
if (isOpenAIDeepResearchModel(model)) {
|
||||
reasoningEffort = 'medium'
|
||||
}
|
||||
|
||||
if (!reasoningEffort) {
|
||||
return {}
|
||||
@@ -412,6 +450,13 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get XAI-specific reasoning parameters
|
||||
* This function should only be called for XAI provider models
|
||||
* @param assistant - The assistant configuration
|
||||
* @param model - The model being used
|
||||
* @returns XAI-specific reasoning parameters
|
||||
*/
|
||||
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
|
||||
if (!isSupportedReasoningEffortGrokModel(model)) {
|
||||
return {}
|
||||
@@ -419,6 +464,11 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor
|
||||
|
||||
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
|
||||
|
||||
if (!reasoningEffort) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// For XAI provider Grok models, use reasoningEffort parameter directly
|
||||
return {
|
||||
reasoningEffort
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
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 { isOpenAIDeepResearchModel, isOpenAIWebSearchChatCompletionOnlyModel } from '@renderer/config/models'
|
||||
import { CherryWebSearchConfig } from '@renderer/store/websearch'
|
||||
import { Model } from '@renderer/types'
|
||||
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
||||
@@ -43,20 +43,27 @@ function mapMaxResultToOpenAIContextSize(maxResults: number): OpenAISearchConfig
|
||||
|
||||
export function buildProviderBuiltinWebSearchConfig(
|
||||
providerId: BaseProviderId,
|
||||
webSearchConfig: CherryWebSearchConfig
|
||||
webSearchConfig: CherryWebSearchConfig,
|
||||
model?: Model
|
||||
): WebSearchPluginConfig | undefined {
|
||||
switch (providerId) {
|
||||
case 'openai': {
|
||||
const searchContextSize = isOpenAIDeepResearchModel(model)
|
||||
? 'medium'
|
||||
: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults)
|
||||
return {
|
||||
openai: {
|
||||
searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults)
|
||||
searchContextSize
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'openai-chat': {
|
||||
const searchContextSize = isOpenAIDeepResearchModel(model)
|
||||
? 'medium'
|
||||
: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults)
|
||||
return {
|
||||
'openai-chat': {
|
||||
searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults)
|
||||
searchContextSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +78,7 @@ export function buildProviderBuiltinWebSearchConfig(
|
||||
}
|
||||
}
|
||||
case 'xai': {
|
||||
const excludeDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
return {
|
||||
xai: {
|
||||
maxSearchResults: webSearchConfig.maxResults,
|
||||
@@ -78,7 +86,7 @@ export function buildProviderBuiltinWebSearchConfig(
|
||||
sources: [
|
||||
{
|
||||
type: 'web',
|
||||
excludedWebsites: mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
excludedWebsites: excludeDomains.slice(0, Math.min(excludeDomains.length, 5))
|
||||
},
|
||||
{ type: 'news' },
|
||||
{ type: 'x' }
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 9.3 KiB |
BIN
src/renderer/src/assets/images/apps/stepfun.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -326,7 +326,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
* 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡,
|
||||
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
||||
*/
|
||||
min-width: 45ch;
|
||||
min-width: 35ch;
|
||||
|
||||
.code-toolbar {
|
||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemir
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { loggerService } from '@logger'
|
||||
import { Extension, keymap } from '@uiw/react-codemirror'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { getNormalizedExtension } from './utils'
|
||||
|
||||
@@ -203,3 +203,80 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
|
||||
})
|
||||
}, [onHeightChange])
|
||||
}
|
||||
|
||||
interface UseScrollToLineOptions {
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
export function useScrollToLine(editorViewRef: React.MutableRefObject<EditorView | null>) {
|
||||
const findLineElement = useCallback((view: EditorView, position: number): HTMLElement | null => {
|
||||
const domAtPos = view.domAtPos(position)
|
||||
let node: Node | null = domAtPos.node
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
node = node.parentElement
|
||||
}
|
||||
|
||||
while (node) {
|
||||
if (node instanceof HTMLElement && node.classList.contains('cm-line')) {
|
||||
return node
|
||||
}
|
||||
node = node.parentElement
|
||||
}
|
||||
|
||||
return null
|
||||
}, [])
|
||||
|
||||
const highlightLine = useCallback((view: EditorView, element: HTMLElement) => {
|
||||
const previousHighlight = view.dom.querySelector('.animation-locate-highlight') as HTMLElement | null
|
||||
if (previousHighlight) {
|
||||
previousHighlight.classList.remove('animation-locate-highlight')
|
||||
}
|
||||
|
||||
element.classList.add('animation-locate-highlight')
|
||||
|
||||
const handleAnimationEnd = () => {
|
||||
element.classList.remove('animation-locate-highlight')
|
||||
element.removeEventListener('animationend', handleAnimationEnd)
|
||||
}
|
||||
|
||||
element.addEventListener('animationend', handleAnimationEnd)
|
||||
}, [])
|
||||
|
||||
return useCallback(
|
||||
(lineNumber: number, options?: UseScrollToLineOptions) => {
|
||||
const view = editorViewRef.current
|
||||
if (!view) return
|
||||
|
||||
const targetLine = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines))
|
||||
|
||||
const lineElement = findLineElement(view, targetLine.from)
|
||||
if (lineElement) {
|
||||
lineElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||
|
||||
if (options?.highlight) {
|
||||
requestAnimationFrame(() => highlightLine(view, lineElement))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(targetLine.from, {
|
||||
y: 'start'
|
||||
})
|
||||
})
|
||||
|
||||
if (!options?.highlight) {
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const fallbackElement = findLineElement(view, targetLine.from)
|
||||
if (fallbackElement) {
|
||||
highlightLine(view, fallbackElement)
|
||||
}
|
||||
}, 200)
|
||||
},
|
||||
[editorViewRef, findLineElement, highlightLine]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import diff from 'fast-diff'
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
|
||||
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks'
|
||||
|
||||
// 标记非用户编辑的变更
|
||||
const External = Annotation.define<boolean>()
|
||||
|
||||
export interface CodeEditorHandles {
|
||||
save?: () => void
|
||||
scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void
|
||||
}
|
||||
|
||||
export interface CodeEditorProps {
|
||||
@@ -181,8 +182,11 @@ const CodeEditor = ({
|
||||
].flat()
|
||||
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
|
||||
|
||||
const scrollToLine = useScrollToLine(editorViewRef)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: handleSave
|
||||
save: handleSave,
|
||||
scrollToLine
|
||||
}))
|
||||
|
||||
return (
|
||||
|
||||
45
src/renderer/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Button } from '@heroui/react'
|
||||
import { CheckIcon, XIcon } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
interface Props {
|
||||
x: number
|
||||
y: number
|
||||
message: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ConfirmDialog: FC<Props> = ({ x, y, message, onConfirm, onCancel }) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99998] bg-transparent" onClick={onCancel} />
|
||||
<div
|
||||
className="-translate-x-1/2 -translate-y-full fixed z-[99999] mt-[-8px] transform"
|
||||
style={{
|
||||
left: `${x}px`,
|
||||
top: `${y}px`
|
||||
}}>
|
||||
<div className="flex min-w-[160px] items-center rounded-lg border border-[var(--color-border)] bg-[var(--color-background)] p-3 shadow-[0_4px_12px_rgba(0,0,0,0.15)]">
|
||||
<div className="mr-2 text-sm leading-[1.4]">{message}</div>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button onPress={onCancel} radius="full" className="h-6 w-6 min-w-0 p-1" color="danger">
|
||||
<XIcon className="text-danger-foreground" size={16} />
|
||||
</Button>
|
||||
<Button onPress={onConfirm} radius="full" className="h-6 w-6 min-w-0 p-1" color="success">
|
||||
<CheckIcon className="text-success-foreground" size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmDialog
|
||||
48
src/renderer/src/components/HighlightText.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { FC, memo, useMemo } from 'react'
|
||||
|
||||
interface HighlightTextProps {
|
||||
text: string
|
||||
keyword: string
|
||||
caseSensitive?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Text highlighting component that marks keyword matches
|
||||
*/
|
||||
const HighlightText: FC<HighlightTextProps> = ({ text, keyword, caseSensitive = false, className }) => {
|
||||
const highlightedText = useMemo(() => {
|
||||
if (!keyword || !text) {
|
||||
return <span>{text}</span>
|
||||
}
|
||||
|
||||
// Escape regex special characters
|
||||
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const flags = caseSensitive ? 'g' : 'gi'
|
||||
const regex = new RegExp(`(${escapedKeyword})`, flags)
|
||||
|
||||
// Split text by keyword matches
|
||||
const parts = text.split(regex)
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
// Check if part matches keyword
|
||||
const isMatch = regex.test(part)
|
||||
regex.lastIndex = 0 // Reset regex state
|
||||
|
||||
if (isMatch) {
|
||||
return <mark key={index}>{part}</mark>
|
||||
}
|
||||
return <span key={index}>{part}</span>
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}, [text, keyword, caseSensitive])
|
||||
|
||||
const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography'
|
||||
|
||||
return <span className={combinedClassName}>{highlightedText}</span>
|
||||
}
|
||||
|
||||
export default memo(HighlightText)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { restoreFromLocal } from '@renderer/services/BackupService'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, message, Modal, Table, Tooltip } from 'antd'
|
||||
import { Button, message, Modal, Space, Table, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -214,6 +214,26 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
||||
}
|
||||
}
|
||||
|
||||
const footerContent = (
|
||||
<Space align="center">
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
||||
{t('settings.data.local.backup.manager.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
key="delete"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0 || deleting}
|
||||
loading={deleting}>
|
||||
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
|
||||
</Button>
|
||||
<Button key="close" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.local.backup.manager.title')}
|
||||
@@ -222,23 +242,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
||||
width={800}
|
||||
centered
|
||||
transitionName="animation-move-down"
|
||||
footer={[
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
||||
{t('settings.data.local.backup.manager.refresh')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0 || deleting}
|
||||
loading={deleting}>
|
||||
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
|
||||
</Button>,
|
||||
<Button key="close" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
]}>
|
||||
footer={footerContent}>
|
||||
<Table
|
||||
rowKey="fileName"
|
||||
columns={columns}
|
||||
|
||||
@@ -549,7 +549,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
{/* 在所有小程序中显示GoogleLoginTip */}
|
||||
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
||||
{!isReady && (
|
||||
<EmptyView>
|
||||
<EmptyView style={{ backgroundColor: 'var(--color-background-soft)' }}>
|
||||
<Avatar
|
||||
src={currentAppInfo?.logo}
|
||||
size={80}
|
||||
|
||||
@@ -25,7 +25,7 @@ const WebviewContainer = memo(
|
||||
onNavigateCallback: (appid: string, url: string) => void
|
||||
}) => {
|
||||
const webviewRef = useRef<WebviewTag | null>(null)
|
||||
const { enableSpellCheck } = useSettings()
|
||||
const { enableSpellCheck, minappsOpenLinkExternal } = useSettings()
|
||||
|
||||
const setRef = (appid: string) => {
|
||||
onSetRefCallback(appid, null)
|
||||
@@ -76,6 +76,8 @@ const WebviewContainer = memo(
|
||||
const webviewId = webviewRef.current?.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
||||
// Set link opening behavior for this webview
|
||||
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +106,22 @@ const WebviewContainer = memo(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appid, url])
|
||||
|
||||
// Update webview settings when they change
|
||||
useEffect(() => {
|
||||
if (!webviewRef.current) return
|
||||
|
||||
try {
|
||||
const webviewId = webviewRef.current.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
||||
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
} catch (error) {
|
||||
// WebView may not be ready yet, settings will be applied in dom-ready event
|
||||
logger.debug(`WebView ${appid} not ready for settings update`)
|
||||
}
|
||||
}, [appid, minappsOpenLinkExternal, enableSpellCheck])
|
||||
|
||||
const WebviewStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||
604
src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/modal'
|
||||
import { Progress } from '@heroui/progress'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { loggerService } from '@logger'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { SettingHelpText, SettingRow } from '@renderer/pages/settings'
|
||||
import { WebSocketCandidatesResponse } from '@shared/config/types'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
const logger = loggerService.withContext('ExportToPhoneLanPopup')
|
||||
|
||||
interface Props {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
type ConnectionPhase = 'initializing' | 'waiting_qr_scan' | 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
type TransferPhase = 'idle' | 'preparing' | 'sending' | 'completed' | 'error'
|
||||
|
||||
const LoadingQRCode: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<Spinner />
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text-2)' }}>
|
||||
{t('settings.data.export_to_phone.lan.generating_qr')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<QRCodeSVG
|
||||
marginSize={2}
|
||||
value={qrCodeValue}
|
||||
level="H"
|
||||
size={200}
|
||||
imageSettings={{
|
||||
src: AppLogo,
|
||||
width: 60,
|
||||
height: 60,
|
||||
excavate: true
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>
|
||||
{t('settings.data.export_to_phone.lan.scan_qr')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectingAnimation: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '160px',
|
||||
height: '160px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '2px dashed var(--color-status-warning)',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: 'var(--color-status-warning)'
|
||||
}}>
|
||||
<Spinner size="lg" color="warning" />
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '12px' }}>
|
||||
{t('settings.data.export_to_phone.lan.status.connecting')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectedDisplay: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '160px',
|
||||
height: '160px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '2px dashed var(--color-status-success)',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: 'var(--color-status-success)'
|
||||
}}>
|
||||
<span style={{ fontSize: '48px' }}>📱</span>
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '8px' }}>
|
||||
{t('settings.data.export_to_phone.lan.connected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '20px',
|
||||
border: `1px solid var(--color-error)`,
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-error)'
|
||||
}}>
|
||||
<span style={{ fontSize: '48px' }}>⚠️</span>
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}>
|
||||
{t('settings.data.export_to_phone.lan.connection_failed')}
|
||||
</span>
|
||||
{error && <span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>{error}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [connectionPhase, setConnectionPhase] = useState<ConnectionPhase>('initializing')
|
||||
const [transferPhase, setTransferPhase] = useState<TransferPhase>('idle')
|
||||
const [qrCodeValue, setQrCodeValue] = useState('')
|
||||
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
|
||||
const [sendProgress, setSendProgress] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
||||
const [autoCloseCountdown, setAutoCloseCountdown] = useState<number | null>(null)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 派生状态
|
||||
const isConnected = connectionPhase === 'connected'
|
||||
const canSend = isConnected && selectedFolderPath && transferPhase === 'idle'
|
||||
const isSending = transferPhase === 'preparing' || transferPhase === 'sending'
|
||||
|
||||
// 状态文本映射
|
||||
const connectionStatusText = useMemo(() => {
|
||||
const statusMap = {
|
||||
initializing: t('settings.data.export_to_phone.lan.status.initializing'),
|
||||
waiting_qr_scan: t('settings.data.export_to_phone.lan.status.waiting_qr_scan'),
|
||||
connecting: t('settings.data.export_to_phone.lan.status.connecting'),
|
||||
connected: t('settings.data.export_to_phone.lan.status.connected'),
|
||||
disconnected: t('settings.data.export_to_phone.lan.status.disconnected'),
|
||||
error: t('settings.data.export_to_phone.lan.status.error')
|
||||
}
|
||||
return statusMap[connectionPhase]
|
||||
}, [connectionPhase, t])
|
||||
|
||||
const transferStatusText = useMemo(() => {
|
||||
const statusMap = {
|
||||
idle: '',
|
||||
preparing: t('settings.data.export_to_phone.lan.status.preparing'),
|
||||
sending: t('settings.data.export_to_phone.lan.status.sending'),
|
||||
completed: t('settings.data.export_to_phone.lan.status.completed'),
|
||||
error: t('settings.data.export_to_phone.lan.status.error')
|
||||
}
|
||||
return statusMap[transferPhase]
|
||||
}, [transferPhase, t])
|
||||
|
||||
// 状态样式映射
|
||||
const connectionStatusStyles = useMemo(() => {
|
||||
const styleMap = {
|
||||
initializing: {
|
||||
bg: 'var(--color-background-mute)',
|
||||
border: 'var(--color-border-mute)'
|
||||
},
|
||||
waiting_qr_scan: {
|
||||
bg: 'var(--color-primary-mute)',
|
||||
border: 'var(--color-primary-soft)'
|
||||
},
|
||||
connecting: { bg: 'var(--color-status-warning)', border: 'var(--color-status-warning)' },
|
||||
connected: {
|
||||
bg: 'var(--color-status-success)',
|
||||
border: 'var(--color-status-success)'
|
||||
},
|
||||
disconnected: { bg: 'var(--color-error)', border: 'var(--color-error)' },
|
||||
error: { bg: 'var(--color-error)', border: 'var(--color-error)' }
|
||||
}
|
||||
return styleMap[connectionPhase]
|
||||
}, [connectionPhase])
|
||||
|
||||
const initWebSocket = useCallback(async () => {
|
||||
try {
|
||||
setConnectionPhase('initializing')
|
||||
await window.api.webSocket.start()
|
||||
const { port, ip } = await window.api.webSocket.status()
|
||||
|
||||
if (ip && port) {
|
||||
const candidatesData = await window.api.webSocket.getAllCandidates()
|
||||
|
||||
const optimizeConnectionInfo = () => {
|
||||
const ipToNumber = (ip: string) => {
|
||||
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0)
|
||||
}
|
||||
|
||||
const compressedData = [
|
||||
'CSA',
|
||||
ipToNumber(ip),
|
||||
candidatesData.map((candidate: WebSocketCandidatesResponse) => ipToNumber(candidate.host)),
|
||||
port, // 端口号
|
||||
Date.now() % 86400000
|
||||
]
|
||||
|
||||
return compressedData
|
||||
}
|
||||
|
||||
const compressedData = optimizeConnectionInfo()
|
||||
const qrCodeValue = JSON.stringify(compressedData)
|
||||
setQrCodeValue(qrCodeValue)
|
||||
setConnectionPhase('waiting_qr_scan')
|
||||
} else {
|
||||
setError(t('settings.data.export_to_phone.lan.error.no_ip'))
|
||||
setConnectionPhase('error')
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
`${t('settings.data.export_to_phone.lan.error.init_failed')}: ${error instanceof Error ? error.message : ''}`
|
||||
)
|
||||
setConnectionPhase('error')
|
||||
logger.error('Failed to initialize WebSocket:', error as Error)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const handleClientConnected = useCallback((_event: any, data: { connected: boolean }) => {
|
||||
logger.info(`Client connection status: ${data.connected ? 'connected' : 'disconnected'}`)
|
||||
if (data.connected) {
|
||||
setConnectionPhase('connected')
|
||||
setError(null)
|
||||
} else {
|
||||
setConnectionPhase('disconnected')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMessageReceived = useCallback((_event: any, data: any) => {
|
||||
logger.info(`Received message from mobile: ${JSON.stringify(data)}`)
|
||||
}, [])
|
||||
|
||||
const handleSendProgress = useCallback(
|
||||
(_event: any, data: { progress: number }) => {
|
||||
const progress = data.progress
|
||||
setSendProgress(progress)
|
||||
|
||||
if (transferPhase === 'preparing' && progress > 0) {
|
||||
setTransferPhase('sending')
|
||||
}
|
||||
|
||||
if (progress >= 100) {
|
||||
setTransferPhase('completed')
|
||||
// 启动 3 秒倒计时自动关闭
|
||||
setAutoCloseCountdown(3)
|
||||
}
|
||||
},
|
||||
[transferPhase]
|
||||
)
|
||||
|
||||
const handleSelectZip = useCallback(async () => {
|
||||
const result = await window.api.file.select()
|
||||
if (result) {
|
||||
setSelectedFolderPath(result[0].path)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSendZip = useCallback(async () => {
|
||||
if (!selectedFolderPath) {
|
||||
setError(t('settings.data.export_to_phone.lan.error.no_file'))
|
||||
return
|
||||
}
|
||||
|
||||
setTransferPhase('preparing')
|
||||
setError(null)
|
||||
setSendProgress(0)
|
||||
|
||||
try {
|
||||
logger.info(`Starting file transfer: ${selectedFolderPath}`)
|
||||
await window.api.webSocket.sendFile(selectedFolderPath)
|
||||
} catch (error) {
|
||||
setError(
|
||||
`${t('settings.data.export_to_phone.lan.error.send_failed')}: ${error instanceof Error ? error.message : ''}`
|
||||
)
|
||||
setTransferPhase('error')
|
||||
logger.error('Failed to send file:', error as Error)
|
||||
}
|
||||
}, [selectedFolderPath, t])
|
||||
|
||||
// 尝试关闭弹窗 - 如果正在传输则显示确认
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSending) {
|
||||
setShowCloseConfirm(true)
|
||||
} else {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}, [isSending])
|
||||
|
||||
// 确认强制关闭
|
||||
const handleForceClose = useCallback(() => {
|
||||
logger.info('Force closing popup during transfer')
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
|
||||
// 取消关闭确认
|
||||
const handleCancelClose = useCallback(() => {
|
||||
setShowCloseConfirm(false)
|
||||
}, [])
|
||||
|
||||
// 清理并关闭
|
||||
const handleClose = useCallback(async () => {
|
||||
try {
|
||||
// 主动断开 WebSocket 连接
|
||||
if (isConnected || connectionPhase !== 'disconnected') {
|
||||
logger.info('Closing popup, stopping WebSocket')
|
||||
await window.api.webSocket.stop()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop WebSocket on close:', error as Error)
|
||||
}
|
||||
resolve({})
|
||||
}, [resolve, isConnected, connectionPhase])
|
||||
|
||||
useEffect(() => {
|
||||
initWebSocket()
|
||||
|
||||
const removeClientConnectedListener = window.electron.ipcRenderer.on(
|
||||
'websocket-client-connected',
|
||||
handleClientConnected
|
||||
)
|
||||
const removeMessageReceivedListener = window.electron.ipcRenderer.on(
|
||||
'websocket-message-received',
|
||||
handleMessageReceived
|
||||
)
|
||||
const removeSendProgressListener = window.electron.ipcRenderer.on('file-send-progress', handleSendProgress)
|
||||
|
||||
return () => {
|
||||
removeClientConnectedListener()
|
||||
removeMessageReceivedListener()
|
||||
removeSendProgressListener()
|
||||
window.api.webSocket.stop()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 自动关闭倒计时
|
||||
useEffect(() => {
|
||||
if (autoCloseCountdown === null) return
|
||||
|
||||
if (autoCloseCountdown <= 0) {
|
||||
logger.debug('Auto-closing popup after transfer completion')
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setAutoCloseCountdown(autoCloseCountdown - 1)
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [autoCloseCountdown])
|
||||
|
||||
// 状态指示器组件
|
||||
const StatusIndicator = useCallback(
|
||||
() => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: connectionStatusStyles.bg,
|
||||
border: `1px solid ${connectionStatusStyles.border}`
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500', color: 'var(--color-text)' }}>{connectionStatusText}</span>
|
||||
</div>
|
||||
),
|
||||
[connectionStatusStyles, connectionStatusText]
|
||||
)
|
||||
|
||||
// 二维码显示组件 - 使用显式条件渲染以避免类型不匹配
|
||||
const QRCodeDisplay = useCallback(() => {
|
||||
switch (connectionPhase) {
|
||||
case 'waiting_qr_scan':
|
||||
case 'disconnected':
|
||||
return <ScanQRCode qrCodeValue={qrCodeValue} />
|
||||
case 'initializing':
|
||||
return <LoadingQRCode />
|
||||
case 'connecting':
|
||||
return <ConnectingAnimation />
|
||||
case 'connected':
|
||||
return <ConnectedDisplay />
|
||||
case 'error':
|
||||
return <ErrorQRCode error={error} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [connectionPhase, qrCodeValue, error])
|
||||
|
||||
// 传输进度组件
|
||||
const TransferProgress = useCallback(() => {
|
||||
if (!isSending && transferPhase !== 'completed') return null
|
||||
|
||||
return (
|
||||
<div style={{ paddingTop: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '12px',
|
||||
border: `1px solid var(--color-border)`,
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-background-mute)'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
<span style={{ color: 'var(--color-text)' }}>
|
||||
{t('settings.data.export_to_phone.lan.transfer_progress')}
|
||||
</span>
|
||||
<span
|
||||
style={{ color: transferPhase === 'completed' ? 'var(--color-status-success)' : 'var(--color-primary)' }}>
|
||||
{transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
value={Math.round(sendProgress)}
|
||||
size="md"
|
||||
color={transferPhase === 'completed' ? 'success' : 'primary'}
|
||||
showValueLabel={false}
|
||||
aria-label="Send progress"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [isSending, transferPhase, sendProgress, t])
|
||||
|
||||
const AutoCloseCountdown = useCallback(() => {
|
||||
if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-2)',
|
||||
textAlign: 'center',
|
||||
paddingTop: '4px'
|
||||
}}>
|
||||
{t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })}
|
||||
</div>
|
||||
)
|
||||
}, [transferPhase, autoCloseCountdown, t])
|
||||
|
||||
// 错误显示组件
|
||||
const ErrorDisplay = useCallback(() => {
|
||||
if (!error || transferPhase !== 'error') return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
border: `1px solid var(--color-error)`,
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-error)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}>❌ {error}</span>
|
||||
</div>
|
||||
)
|
||||
}, [error, transferPhase])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCancel()
|
||||
}
|
||||
}}
|
||||
isDismissable={false}
|
||||
isKeyboardDismissDisabled={false}
|
||||
placement="center"
|
||||
onClose={handleClose}>
|
||||
<ModalContent>
|
||||
{() => (
|
||||
<>
|
||||
<ModalHeader>{t('settings.data.export_to_phone.lan.title')}</ModalHeader>
|
||||
<ModalBody>
|
||||
<SettingRow>
|
||||
<StatusIndicator />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow>
|
||||
<div>{t('settings.data.export_to_phone.lan.content')}</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px' }}>
|
||||
<QRCodeDisplay />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
|
||||
<Button color="default" variant="flat" onPress={handleSelectZip} isDisabled={isSending}>
|
||||
{t('settings.data.export_to_phone.lan.selectZip')}
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleSendZip} isDisabled={!canSend} isLoading={isSending}>
|
||||
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingHelpText
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')}
|
||||
</SettingHelpText>
|
||||
|
||||
<TransferProgress />
|
||||
<AutoCloseCountdown />
|
||||
<ErrorDisplay />
|
||||
</ModalBody>
|
||||
|
||||
{showCloseConfirm && (
|
||||
<ModalFooter>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
gap: '12px',
|
||||
padding: '8px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-status-warning)',
|
||||
border: '1px solid var(--color-status-warning)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '20px' }}>⚠️</span>
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)', fontWeight: '500' }}>
|
||||
{t('settings.data.export_to_phone.lan.confirm_close_title')}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', color: 'var(--color-text-2)', marginLeft: '28px' }}>
|
||||
{t('settings.data.export_to_phone.lan.confirm_close_message')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
|
||||
<Button size="sm" color="default" variant="flat" onPress={handleCancelClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" color="danger" onPress={handleForceClose}>
|
||||
{t('settings.data.export_to_phone.lan.force_close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'ExportToPhoneLanPopup'
|
||||
|
||||
export default class ExportToPhoneLanPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show() {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
2
src/renderer/src/components/RichEditor/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Attribute used to store the original source line number in markdown editors
|
||||
export const MARKDOWN_SOURCE_LINE_ATTR = 'data-source-line'
|
||||
@@ -1,5 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
|
||||
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
|
||||
import DragHandle from '@tiptap/extension-drag-handle-react'
|
||||
import { EditorContent } from '@tiptap/react'
|
||||
import { Tooltip } from 'antd'
|
||||
@@ -29,6 +30,156 @@ import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types'
|
||||
import { useRichEditor } from './useRichEditor'
|
||||
const logger = loggerService.withContext('RichEditor')
|
||||
|
||||
/**
|
||||
* Find element by line number with fallback strategies:
|
||||
* 1. Exact line + content match
|
||||
* 2. Exact line match
|
||||
* 3. Closest line <= target
|
||||
*/
|
||||
function findElementByLine(editorDom: HTMLElement, lineNumber: number, lineContent?: string): HTMLElement | null {
|
||||
const allElements = Array.from(editorDom.querySelectorAll(`[${MARKDOWN_SOURCE_LINE_ATTR}]`)) as HTMLElement[]
|
||||
if (allElements.length === 0) {
|
||||
logger.warn('No elements with data-source-line attribute found')
|
||||
return null
|
||||
}
|
||||
const exactMatches = editorDom.querySelectorAll(
|
||||
`[${MARKDOWN_SOURCE_LINE_ATTR}="${lineNumber}"]`
|
||||
) as NodeListOf<HTMLElement>
|
||||
|
||||
// Strategy 1: Exact line + content match
|
||||
if (exactMatches.length > 1 && lineContent) {
|
||||
for (const match of Array.from(exactMatches)) {
|
||||
if (match.textContent?.includes(lineContent)) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Exact line match
|
||||
if (exactMatches.length > 0) {
|
||||
return exactMatches[0]
|
||||
}
|
||||
|
||||
// Strategy 3: Closest line <= target
|
||||
let closestElement: HTMLElement | null = null
|
||||
let closestLine = 0
|
||||
|
||||
for (const el of allElements) {
|
||||
const sourceLine = parseInt(el.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) || '0', 10)
|
||||
if (sourceLine <= lineNumber && sourceLine > closestLine) {
|
||||
closestLine = sourceLine
|
||||
closestElement = el
|
||||
}
|
||||
}
|
||||
|
||||
return closestElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Create fixed-position highlight overlay at element location
|
||||
* with boundary detection to prevent overflow and toolbar overlap
|
||||
*/
|
||||
function createHighlightOverlay(element: HTMLElement, container: HTMLElement): void {
|
||||
try {
|
||||
// Remove previous overlay
|
||||
const previousOverlay = document.body.querySelector('.highlight-overlay')
|
||||
if (previousOverlay) {
|
||||
previousOverlay.remove()
|
||||
}
|
||||
|
||||
const editorWrapper = container.closest('.rich-editor-wrapper')
|
||||
|
||||
// Create overlay at element position
|
||||
const rect = element.getBoundingClientRect()
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'highlight-overlay animation-locate-highlight'
|
||||
overlay.style.position = 'fixed'
|
||||
overlay.style.left = `${rect.left}px`
|
||||
overlay.style.top = `${rect.top}px`
|
||||
overlay.style.width = `${rect.width}px`
|
||||
overlay.style.height = `${rect.height}px`
|
||||
overlay.style.pointerEvents = 'none'
|
||||
overlay.style.zIndex = '9999'
|
||||
overlay.style.borderRadius = '4px'
|
||||
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
// Update overlay position and visibility on scroll
|
||||
const updatePosition = () => {
|
||||
const newRect = element.getBoundingClientRect()
|
||||
const newContainerRect = container.getBoundingClientRect()
|
||||
|
||||
// Update position
|
||||
overlay.style.left = `${newRect.left}px`
|
||||
overlay.style.top = `${newRect.top}px`
|
||||
overlay.style.width = `${newRect.width}px`
|
||||
overlay.style.height = `${newRect.height}px`
|
||||
|
||||
// Get current toolbar bottom (it might change)
|
||||
const currentToolbar = editorWrapper?.querySelector('[class*="ToolbarWrapper"]')
|
||||
const currentToolbarRect = currentToolbar?.getBoundingClientRect()
|
||||
const currentToolbarBottom = currentToolbarRect ? currentToolbarRect.bottom : newContainerRect.top
|
||||
|
||||
// Check if overlay is within visible bounds
|
||||
const overlayTop = newRect.top
|
||||
const overlayBottom = newRect.bottom
|
||||
const visibleTop = currentToolbarBottom // Don't overlap toolbar
|
||||
const visibleBottom = newContainerRect.bottom
|
||||
|
||||
// Hide overlay if any part is outside the visible container area
|
||||
if (overlayTop < visibleTop || overlayBottom > visibleBottom) {
|
||||
overlay.style.opacity = '0'
|
||||
overlay.style.visibility = 'hidden'
|
||||
} else {
|
||||
overlay.style.opacity = '1'
|
||||
overlay.style.visibility = 'visible'
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', updatePosition)
|
||||
|
||||
// Auto-remove after animation
|
||||
const handleAnimationEnd = () => {
|
||||
overlay.remove()
|
||||
container.removeEventListener('scroll', updatePosition)
|
||||
overlay.removeEventListener('animationend', handleAnimationEnd)
|
||||
}
|
||||
overlay.addEventListener('animationend', handleAnimationEnd)
|
||||
} catch (error) {
|
||||
logger.error('Failed to create highlight overlay:', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to element and show highlight after scroll completes
|
||||
*/
|
||||
function scrollAndHighlight(element: HTMLElement, container: HTMLElement): void {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
|
||||
|
||||
let scrollTimeout: NodeJS.Timeout
|
||||
const handleScroll = () => {
|
||||
clearTimeout(scrollTimeout)
|
||||
scrollTimeout = setTimeout(() => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
requestAnimationFrame(() => createHighlightOverlay(element, container))
|
||||
}, 150)
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll)
|
||||
|
||||
// Fallback: if element already in view (no scroll happens)
|
||||
setTimeout(() => {
|
||||
const initialScrollTop = container.scrollTop
|
||||
setTimeout(() => {
|
||||
if (Math.abs(container.scrollTop - initialScrollTop) < 1) {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
clearTimeout(scrollTimeout)
|
||||
requestAnimationFrame(() => createHighlightOverlay(element, container))
|
||||
}
|
||||
}, 200)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const RichEditor = ({
|
||||
ref,
|
||||
initialContent = '',
|
||||
@@ -372,6 +523,22 @@ const RichEditor = ({
|
||||
scrollContainerRef.current.scrollTop = value
|
||||
}
|
||||
},
|
||||
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => {
|
||||
if (!editor || !scrollContainerRef.current) return
|
||||
|
||||
try {
|
||||
const element = findElementByLine(editor.view.dom, lineNumber, options?.lineContent)
|
||||
if (!element) return
|
||||
|
||||
if (options?.highlight) {
|
||||
scrollAndHighlight(element, scrollContainerRef.current)
|
||||
} else {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed in scrollToLine:', error as Error)
|
||||
}
|
||||
},
|
||||
// Dynamic command management
|
||||
registerCommand,
|
||||
registerToolbarCommand,
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface RichEditorRef {
|
||||
getScrollTop: () => number
|
||||
/** Set scrollTop of the editor scroll container */
|
||||
setScrollTop: (value: number) => void
|
||||
/** Scroll to specific line number in markdown */
|
||||
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => void
|
||||
// Dynamic command management
|
||||
/** Register a new command/toolbar item */
|
||||
registerCommand: (cmd: Command) => void
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'katex/dist/katex.min.css'
|
||||
|
||||
import { TableKit } from '@cherrystudio/extension-table-plus'
|
||||
import { loggerService } from '@logger'
|
||||
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
|
||||
import type { FormattingState } from '@renderer/components/RichEditor/types'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
markdownToPreviewText
|
||||
} from '@renderer/utils/markdownConverter'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { TaskItem, TaskList } from '@tiptap/extension-list'
|
||||
import { migrateMathStrings } from '@tiptap/extension-mathematics'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
@@ -36,6 +38,31 @@ import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers
|
||||
|
||||
const logger = loggerService.withContext('useRichEditor')
|
||||
|
||||
// Create extension to preserve data-source-line attribute
|
||||
const SourceLineAttribute = Extension.create({
|
||||
name: 'sourceLineAttribute',
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'listItem', 'horizontalRule'],
|
||||
attributes: {
|
||||
dataSourceLine: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const value = element.getAttribute(MARKDOWN_SOURCE_LINE_ATTR)
|
||||
return value
|
||||
},
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.dataSourceLine) return {}
|
||||
return { [MARKDOWN_SOURCE_LINE_ATTR]: attributes.dataSourceLine }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export interface UseRichEditorOptions {
|
||||
/** Initial markdown content */
|
||||
initialContent?: string
|
||||
@@ -196,6 +223,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
// TipTap editor extensions
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
SourceLineAttribute,
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3, 4, 5, 6]
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-
|
||||
import { restoreFromS3 } from '@renderer/services/BackupService'
|
||||
import type { S3Config } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, Modal, Table, Tooltip } from 'antd'
|
||||
import { Button, Modal, Space, Table, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -253,6 +253,26 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
|
||||
}
|
||||
}
|
||||
|
||||
const footerContent = (
|
||||
<Space align="center">
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
||||
{t('settings.data.s3.manager.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
key="delete"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0 || deleting}
|
||||
loading={deleting}>
|
||||
{t('settings.data.s3.manager.delete.selected', { count: selectedRowKeys.length })}
|
||||
</Button>
|
||||
<Button key="close" onClick={onClose}>
|
||||
{t('settings.data.s3.manager.close')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.s3.manager.title')}
|
||||
@@ -261,23 +281,7 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
|
||||
width={800}
|
||||
centered
|
||||
transitionName="animation-move-down"
|
||||
footer={[
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
||||
{t('settings.data.s3.manager.refresh')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0 || deleting}
|
||||
loading={deleting}>
|
||||
{t('settings.data.s3.manager.delete.selected', { count: selectedRowKeys.length })}
|
||||
</Button>,
|
||||
<Button key="close" onClick={onClose}>
|
||||
{t('settings.data.s3.manager.close')}
|
||||
</Button>
|
||||
]}>
|
||||
footer={footerContent}>
|
||||
<Table
|
||||
rowKey="fileName"
|
||||
columns={columns}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface CustomTagProps {
|
||||
closable?: boolean
|
||||
onClose?: () => void
|
||||
onClick?: MouseEventHandler<HTMLDivElement>
|
||||
onContextMenu?: MouseEventHandler<HTMLDivElement>
|
||||
disabled?: boolean
|
||||
inactive?: boolean
|
||||
}
|
||||
@@ -27,6 +28,7 @@ const CustomTag: FC<CustomTagProps> = ({
|
||||
closable = false,
|
||||
onClose,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
disabled,
|
||||
inactive
|
||||
}) => {
|
||||
@@ -39,6 +41,7 @@ const CustomTag: FC<CustomTagProps> = ({
|
||||
$closable={closable}
|
||||
$clickable={!disabled && !!onClick}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onContextMenu={disabled ? undefined : onContextMenu}
|
||||
style={{
|
||||
...(disabled && { cursor: 'not-allowed' }),
|
||||
...style
|
||||
@@ -56,7 +59,7 @@ const CustomTag: FC<CustomTagProps> = ({
|
||||
)}
|
||||
</Tag>
|
||||
),
|
||||
[actualColor, children, closable, disabled, icon, onClick, onClose, size, style]
|
||||
[actualColor, children, closable, disabled, icon, onClick, onClose, onContextMenu, size, style]
|
||||
)
|
||||
|
||||
return tooltip ? (
|
||||
|
||||
101
src/renderer/src/components/UpdateDialog.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import { handleSaveData } from '@renderer/store'
|
||||
import { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Markdown from 'react-markdown'
|
||||
|
||||
const logger = loggerService.withContext('UpdateDialog')
|
||||
|
||||
interface UpdateDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
releaseInfo: UpdateInfo | null
|
||||
}
|
||||
|
||||
const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInfo }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isInstalling, setIsInstalling] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && releaseInfo) {
|
||||
logger.info('Update dialog opened', { version: releaseInfo.version })
|
||||
}
|
||||
}, [isOpen, releaseInfo])
|
||||
|
||||
const handleInstall = async () => {
|
||||
setIsInstalling(true)
|
||||
try {
|
||||
await handleSaveData()
|
||||
await window.api.quitAndInstall()
|
||||
} catch (error) {
|
||||
logger.error('Failed to save data before update', error as Error)
|
||||
setIsInstalling(false)
|
||||
window.toast.error(t('update.saveDataError'))
|
||||
}
|
||||
}
|
||||
|
||||
const releaseNotes = releaseInfo?.releaseNotes
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="2xl"
|
||||
scrollBehavior="inside"
|
||||
classNames={{
|
||||
base: 'max-h-[85vh]',
|
||||
header: 'border-b border-divider',
|
||||
footer: 'border-t border-divider'
|
||||
}}>
|
||||
<ModalContent>
|
||||
{(onModalClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h3 className="font-semibold text-lg">{t('update.title')}</h3>
|
||||
<p className="text-default-500 text-small">
|
||||
{t('update.message').replace('{{version}}', releaseInfo?.version || '')}
|
||||
</p>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<ScrollShadow className="max-h-[450px]" hideScrollBar>
|
||||
<div className="markdown rounded-lg bg-default-50 p-4">
|
||||
<Markdown>
|
||||
{typeof releaseNotes === 'string'
|
||||
? releaseNotes
|
||||
: Array.isArray(releaseNotes)
|
||||
? releaseNotes
|
||||
.map((note: ReleaseNoteInfo) => note.note)
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
: t('update.noReleaseNotes')}
|
||||
</Markdown>
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onModalClose} isDisabled={isInstalling}>
|
||||
{t('update.later')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={async () => {
|
||||
await handleInstall()
|
||||
onModalClose()
|
||||
}}
|
||||
isLoading={isInstalling}>
|
||||
{t('update.install')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdateDialog
|
||||
@@ -65,7 +65,7 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: ${isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)'};
|
||||
min-height: ${({ $isFullScreen }) => (!$isFullScreen && isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)')};
|
||||
max-height: var(--navbar-height);
|
||||
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
|
||||
padding-left: ${({ $isFullScreen }) =>
|
||||
|
||||
166
src/renderer/src/config/__test__/reasoning.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel } from '../models/reasoning'
|
||||
|
||||
// FIXME: Idk why it's imported. Maybe circular dependency somewhere
|
||||
vi.mock('@renderer/services/AssistantService.ts', () => ({
|
||||
getDefaultAssistant: () => {
|
||||
return {
|
||||
id: 'default',
|
||||
name: 'default',
|
||||
emoji: '😀',
|
||||
prompt: '',
|
||||
topics: [],
|
||||
messages: [],
|
||||
type: 'assistant',
|
||||
regularPhrases: [],
|
||||
settings: {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('Doubao Models', () => {
|
||||
describe('isDoubaoThinkingAutoModel', () => {
|
||||
it('should return false for invalid models', () => {
|
||||
expect(
|
||||
isDoubaoThinkingAutoModel({
|
||||
id: 'doubao-seed-1-6-251015',
|
||||
name: 'doubao-seed-1-6-251015',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDoubaoThinkingAutoModel({
|
||||
id: 'doubao-seed-1-6-lite-251015',
|
||||
name: 'doubao-seed-1-6-lite-251015',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDoubaoThinkingAutoModel({
|
||||
id: 'doubao-seed-1-6-thinking-250715',
|
||||
name: 'doubao-seed-1-6-thinking-250715',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDoubaoThinkingAutoModel({
|
||||
id: 'doubao-seed-1-6-flash',
|
||||
name: 'doubao-seed-1-6-flash',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDoubaoThinkingAutoModel({
|
||||
id: 'doubao-seed-1-6-thinking',
|
||||
name: 'doubao-seed-1-6-thinking',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for valid models', () => {
|
||||
expect(
|
||||
isDoubaoThinkingAutoModel({
|
||||
id: 'doubao-seed-1-6-250615',
|
||||
name: 'doubao-seed-1-6-250615',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isDoubaoThinkingAutoModel({
|
||||
id: 'Doubao-Seed-1.6',
|
||||
name: 'Doubao-Seed-1.6',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isDoubaoThinkingAutoModel({
|
||||
id: 'doubao-1-5-thinking-pro-m',
|
||||
name: 'doubao-1-5-thinking-pro-m',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isDoubaoThinkingAutoModel({
|
||||
id: 'doubao-seed-1.6-lite',
|
||||
name: 'doubao-seed-1.6-lite',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isDoubaoThinkingAutoModel({
|
||||
id: 'doubao-1-5-thinking-pro-m-12345',
|
||||
name: 'doubao-1-5-thinking-pro-m-12345',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDoubaoSeedAfter251015', () => {
|
||||
it('should return true for models matching the pattern', () => {
|
||||
expect(
|
||||
isDoubaoSeedAfter251015({
|
||||
id: 'doubao-seed-1-6-251015',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isDoubaoSeedAfter251015({
|
||||
id: 'doubao-seed-1-6-lite-251015',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for models not matching the pattern', () => {
|
||||
expect(
|
||||
isDoubaoSeedAfter251015({
|
||||
id: 'doubao-seed-1-6-250615',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDoubaoSeedAfter251015({
|
||||
id: 'Doubao-Seed-1.6',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDoubaoSeedAfter251015({
|
||||
id: 'doubao-1-5-thinking-pro-m',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDoubaoSeedAfter251015({
|
||||
id: 'doubao-seed-1-6-lite-251016',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,8 +22,6 @@ import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?
|
||||
import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url'
|
||||
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
|
||||
import GrokXAppLogo from '@renderer/assets/images/apps/grok-x.png?url'
|
||||
import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
|
||||
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg?url'
|
||||
import KimiAppLogo from '@renderer/assets/images/apps/kimi.webp?url'
|
||||
import LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?url'
|
||||
import LeChatLogo from '@renderer/assets/images/apps/lechat.png?url'
|
||||
@@ -32,13 +30,13 @@ import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
|
||||
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
|
||||
import n8nLogo from '@renderer/assets/images/apps/n8n.svg?url'
|
||||
import NamiAiLogo from '@renderer/assets/images/apps/nm.png?url'
|
||||
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm-search.webp?url'
|
||||
import NotebookLMAppLogo from '@renderer/assets/images/apps/notebooklm.svg?url'
|
||||
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
|
||||
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
|
||||
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
|
||||
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
|
||||
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.webp?url'
|
||||
import StepfunAppLogo from '@renderer/assets/images/apps/stepfun.png?url'
|
||||
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
|
||||
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
|
||||
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
|
||||
@@ -46,7 +44,6 @@ import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
|
||||
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
|
||||
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
|
||||
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?url'
|
||||
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
|
||||
import ZaiAppLogo from '@renderer/assets/images/apps/zai.png?url'
|
||||
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
|
||||
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
|
||||
@@ -145,14 +142,14 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
{
|
||||
id: 'dashscope',
|
||||
name: i18n.t('minapps.qwen'),
|
||||
url: 'https://tongyi.aliyun.com/qianwen/',
|
||||
url: 'https://www.tongyi.com/',
|
||||
logo: QwenModelLogo
|
||||
},
|
||||
{
|
||||
id: 'stepfun',
|
||||
name: i18n.t('minapps.yuewen'),
|
||||
url: 'https://yuewen.cn/chats/new',
|
||||
logo: YuewenAppLogo,
|
||||
name: i18n.t('minapps.stepfun'),
|
||||
url: 'https://stepfun.com',
|
||||
logo: StepfunAppLogo,
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
@@ -263,13 +260,6 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
url: 'https://www.tiangong.cn/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'hugging-chat',
|
||||
name: 'HuggingChat',
|
||||
logo: HuggingChatLogo,
|
||||
url: 'https://huggingface.co/chat/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'Felo',
|
||||
name: 'Felo',
|
||||
@@ -297,13 +287,6 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
url: 'https://bot.n.cn/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'nm-search',
|
||||
name: i18n.t('minapps.nami-ai-search'),
|
||||
logo: NamiAiSearchLogo,
|
||||
url: 'https://www.n.cn/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'thinkany',
|
||||
name: 'ThinkAny',
|
||||
@@ -314,13 +297,6 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
padding: 5
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'hika',
|
||||
name: 'Hika',
|
||||
logo: HikaLogo,
|
||||
url: 'https://hika.fyi/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'github-copilot',
|
||||
name: 'GitHub Copilot',
|
||||
|
||||
@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
// Default quick assistant model
|
||||
glm45FlashModel
|
||||
],
|
||||
// cherryin: [],
|
||||
cherryin: [],
|
||||
vertexai: [],
|
||||
'302ai': [
|
||||
{
|
||||
@@ -430,6 +430,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
}
|
||||
],
|
||||
anthropic: [
|
||||
{
|
||||
id: 'claude-haiku-4-5-20251001',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude Haiku 4.5',
|
||||
group: 'Claude 4.5'
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-5-20250929',
|
||||
provider: 'anthropic',
|
||||
|
||||
@@ -10,26 +10,29 @@ import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
|
||||
import { isEmbeddingModel, isRerankModel } from './embedding'
|
||||
import { isGPT5SeriesModel } from './utils'
|
||||
import { isTextToImageModel } from './vision'
|
||||
import { GEMINI_FLASH_MODEL_REGEX } from './websearch'
|
||||
import { GEMINI_FLASH_MODEL_REGEX, isOpenAIDeepResearchModel } from './websearch'
|
||||
|
||||
// Reasoning models
|
||||
export const REASONING_REGEX =
|
||||
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4)(?:-[\w-]+)?\b.*)$/i
|
||||
/^(?!.*-non-reasoning\b)(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4|4-fast)(?:-[\w-]+)?\b.*)$/i
|
||||
|
||||
// 模型类型到支持的reasoning_effort的映射表
|
||||
// TODO: refactor this. too many identical options
|
||||
export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
||||
default: ['low', 'medium', 'high'] as const,
|
||||
o: ['low', 'medium', 'high'] as const,
|
||||
openai_deep_research: ['medium'] as const,
|
||||
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
|
||||
gpt5_codex: ['low', 'medium', 'high'] as const,
|
||||
grok: ['low', 'high'] as const,
|
||||
grok4_fast: ['auto'] as const,
|
||||
gemini: ['low', 'medium', 'high', 'auto'] as const,
|
||||
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
|
||||
qwen: ['low', 'medium', 'high'] as const,
|
||||
qwen_thinking: ['low', 'medium', 'high'] as const,
|
||||
doubao: ['auto', 'high'] as const,
|
||||
doubao_no_auto: ['high'] as const,
|
||||
doubao_after_251015: ['minimal', 'low', 'medium', 'high'] as const,
|
||||
hunyuan: ['auto'] as const,
|
||||
zhipu: ['auto'] as const,
|
||||
perplexity: ['low', 'medium', 'high'] as const,
|
||||
@@ -40,15 +43,18 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
||||
export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
|
||||
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
|
||||
openai_deep_research: MODEL_SUPPORTED_REASONING_EFFORT.openai_deep_research,
|
||||
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
|
||||
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
|
||||
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
|
||||
grok4_fast: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
|
||||
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
||||
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
|
||||
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
|
||||
qwen_thinking: MODEL_SUPPORTED_REASONING_EFFORT.qwen_thinking,
|
||||
doubao: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
|
||||
doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
|
||||
doubao_after_251015: MODEL_SUPPORTED_REASONING_EFFORT.doubao_after_251015,
|
||||
hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
|
||||
zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
|
||||
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity,
|
||||
@@ -58,6 +64,9 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||
export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
let thinkingModelType: ThinkingModelType = 'default'
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
if (isOpenAIDeepResearchModel(model)) {
|
||||
return 'openai_deep_research'
|
||||
}
|
||||
if (isGPT5SeriesModel(model)) {
|
||||
if (modelId.includes('codex')) {
|
||||
thinkingModelType = 'gpt5_codex'
|
||||
@@ -66,6 +75,8 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
}
|
||||
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||
thinkingModelType = 'o'
|
||||
} else if (isGrok4FastReasoningModel(model)) {
|
||||
thinkingModelType = 'grok4_fast'
|
||||
} else if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
thinkingModelType = 'gemini'
|
||||
@@ -81,6 +92,8 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
} else if (isSupportedThinkingTokenDoubaoModel(model)) {
|
||||
if (isDoubaoThinkingAutoModel(model)) {
|
||||
thinkingModelType = 'doubao'
|
||||
} else if (isDoubaoSeedAfter251015(model)) {
|
||||
thinkingModelType = 'doubao_after_251015'
|
||||
} else {
|
||||
thinkingModelType = 'doubao_no_auto'
|
||||
}
|
||||
@@ -142,19 +155,46 @@ export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
|
||||
}
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
const providerId = model.provider.toLowerCase()
|
||||
if (modelId.includes('grok-3-mini')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (providerId === 'openrouter' && modelId.includes('grok-4-fast')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the model is Grok 4 Fast reasoning version
|
||||
* Explicitly excludes non-reasoning variants (models with 'non-reasoning' in their ID)
|
||||
*
|
||||
* Note: XAI official uses different model IDs for reasoning vs non-reasoning
|
||||
* Third-party providers like OpenRouter expose a single ID with reasoning parameters, while first-party providers require separate IDs. Only the OpenRouter variant supports toggling.
|
||||
*
|
||||
* @param model - The model to check
|
||||
* @returns true if the model is a reasoning-enabled Grok 4 Fast model
|
||||
*/
|
||||
export function isGrok4FastReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return modelId.includes('grok-4-fast') && !modelId.includes('non-reasoning')
|
||||
}
|
||||
|
||||
export function isGrokReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
if (isSupportedReasoningEffortGrokModel(model) || modelId.includes('grok-4')) {
|
||||
if (
|
||||
isSupportedReasoningEffortGrokModel(model) ||
|
||||
(modelId.includes('grok-4') && !modelId.includes('non-reasoning'))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -265,7 +305,11 @@ export function isQwenAlwaysThinkModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
return modelId.startsWith('qwen3') && modelId.includes('thinking')
|
||||
// 包括 qwen3 开头的 thinking 模型和 qwen3-vl 的 thinking 模型
|
||||
return (
|
||||
(modelId.startsWith('qwen3') && modelId.includes('thinking')) ||
|
||||
(modelId.includes('qwen3-vl') && modelId.includes('thinking'))
|
||||
)
|
||||
}
|
||||
|
||||
// Doubao 支持思考模式的模型正则
|
||||
@@ -273,14 +317,21 @@ export const DOUBAO_THINKING_MODEL_REGEX =
|
||||
/doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?(?!-(?:thinking)(?:-|$)))(?:-[\w-]+)*/i
|
||||
|
||||
// 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx
|
||||
// Auto thinking is no longer supported after version 251015, see https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seed-1-6
|
||||
export const DOUBAO_THINKING_AUTO_MODEL_REGEX =
|
||||
/doubao-(1-5-thinking-pro-m|seed-1[.-]6)(?!-(?:flash|thinking)(?:-|$))(?:-[\w-]+)*/i
|
||||
/doubao-(1-5-thinking-pro-m|seed-1[.-]6)(?!-(?:flash|thinking)(?:-|$))(?:-lite)?(?!-251015)(?:-\d+)?$/i
|
||||
|
||||
export function isDoubaoThinkingAutoModel(model: Model): boolean {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(modelId) || DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.name)
|
||||
}
|
||||
|
||||
export function isDoubaoSeedAfter251015(model: Model): boolean {
|
||||
const pattern = new RegExp(/doubao-seed-1-6-(?:lite-)?251015/i)
|
||||
const result = pattern.test(model.id)
|
||||
return result
|
||||
}
|
||||
|
||||
export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
@@ -291,6 +342,12 @@ export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean {
|
||||
return DOUBAO_THINKING_MODEL_REGEX.test(modelId) || DOUBAO_THINKING_MODEL_REGEX.test(model.name)
|
||||
}
|
||||
|
||||
export function isClaude45ReasoningModel(model: Model): boolean {
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
const regex = /claude-(sonnet|opus|haiku)-4(-|.)5(?:-[\w-]+)?$/i
|
||||
return regex.test(modelId)
|
||||
}
|
||||
|
||||
export function isClaudeReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
@@ -300,7 +357,8 @@ export function isClaudeReasoningModel(model?: Model): boolean {
|
||||
modelId.includes('claude-3-7-sonnet') ||
|
||||
modelId.includes('claude-3.7-sonnet') ||
|
||||
modelId.includes('claude-sonnet-4') ||
|
||||
modelId.includes('claude-opus-4')
|
||||
modelId.includes('claude-opus-4') ||
|
||||
modelId.includes('claude-haiku-4')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -329,7 +387,10 @@ export const isPerplexityReasoningModel = (model?: Model): boolean => {
|
||||
}
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
return isSupportedReasoningEffortPerplexityModel(model) || modelId.includes('reasoning')
|
||||
return (
|
||||
isSupportedReasoningEffortPerplexityModel(model) ||
|
||||
(modelId.includes('reasoning') && !modelId.includes('non-reasoning'))
|
||||
)
|
||||
}
|
||||
|
||||
export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean => {
|
||||
@@ -443,6 +504,8 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
|
||||
// qwen-plus-x 系列自 qwen-plus-2025-07-28 后模型最长思维链变为 81_920, qwen-plus 模型于 2025.9.16 同步变更
|
||||
'qwen3-235b-a22b-thinking-2507$': { min: 0, max: 81_920 },
|
||||
'qwen3-30b-a3b-thinking-2507$': { min: 0, max: 81_920 },
|
||||
'qwen3-vl-235b-a22b-thinking$': { min: 0, max: 81_920 },
|
||||
'qwen3-vl-30b-a3b-thinking$': { min: 0, max: 81_920 },
|
||||
'qwen-plus-2025-07-14$': { min: 0, max: 38_912 },
|
||||
'qwen-plus-2025-04-28$': { min: 0, max: 38_912 },
|
||||
'qwen3-1\\.7b$': { min: 0, max: 30_720 },
|
||||
@@ -453,8 +516,9 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
|
||||
'qwen3-(?!max).*$': { min: 1024, max: 38_912 },
|
||||
|
||||
// Claude models
|
||||
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
|
||||
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 }
|
||||
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64_000 },
|
||||
'claude-(:?haiku|sonnet)-4.*$': { min: 1024, max: 64_000 },
|
||||
'claude-opus-4-1.*$': { min: 1024, max: 32_000 }
|
||||
}
|
||||
|
||||
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {
|
||||
|
||||
@@ -15,6 +15,7 @@ const visionAllowedModels = [
|
||||
'gemini-(flash|pro|flash-lite)-latest',
|
||||
'gemini-exp',
|
||||
'claude-3',
|
||||
'claude-haiku-4',
|
||||
'claude-sonnet-4',
|
||||
'claude-opus-4',
|
||||
'vision',
|
||||
@@ -24,7 +25,7 @@ const visionAllowedModels = [
|
||||
'qwen2.5-vl',
|
||||
'qwen3-vl',
|
||||
'qwen2.5-omni',
|
||||
'qwen3-omni',
|
||||
'qwen3-omni(?:-[\\w-]+)?',
|
||||
'qvq',
|
||||
'internvl2',
|
||||
'grok-vision-beta',
|
||||
@@ -82,14 +83,14 @@ export const IMAGE_ENHANCEMENT_MODELS = [
|
||||
'grok-2-image(?:-[\\w-]+)?',
|
||||
'qwen-image-edit',
|
||||
'gpt-image-1',
|
||||
'gemini-2.5-flash-image-preview',
|
||||
'gemini-2.5-flash-image',
|
||||
'gemini-2.0-flash-preview-image-generation'
|
||||
]
|
||||
|
||||
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
|
||||
|
||||
// Models that should auto-enable image generation button when selected
|
||||
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image-preview', ...DEDICATED_IMAGE_MODELS]
|
||||
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
|
||||
|
||||
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
|
||||
'o3',
|
||||
@@ -107,7 +108,7 @@ export const GENERATE_IMAGE_MODELS = [
|
||||
'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-flash-exp-image-generation',
|
||||
'gemini-2.0-flash-preview-image-generation',
|
||||
'gemini-2.5-flash-image-preview',
|
||||
'gemini-2.5-flash-image',
|
||||
...DEDICATED_IMAGE_MODELS
|
||||
]
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isAnthropicModel } from './utils'
|
||||
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
|
||||
|
||||
export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
|
||||
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`,
|
||||
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-(haiku|sonnet|opus)-4(?:-[\\w-]+)?)\\b`,
|
||||
'i'
|
||||
)
|
||||
|
||||
@@ -26,6 +26,22 @@ export const PERPLEXITY_SEARCH_MODELS = [
|
||||
'sonar-deep-research'
|
||||
]
|
||||
|
||||
const OPENAI_DEEP_RESEARCH_MODEL_REGEX = /deep[-_]?research/
|
||||
|
||||
export function isOpenAIDeepResearchModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
const providerId = model.provider
|
||||
if (providerId !== 'openai' && providerId !== 'openai-chat') {
|
||||
return false
|
||||
}
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
return OPENAI_DEEP_RESEARCH_MODEL_REGEX.test(modelId)
|
||||
}
|
||||
|
||||
export function isWebSearchModel(model: Model): boolean {
|
||||
if (
|
||||
!model ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BuiltinOcrProvider,
|
||||
BuiltinOcrProviderId,
|
||||
OcrOvProvider,
|
||||
OcrPpocrProvider,
|
||||
OcrProviderCapability,
|
||||
OcrSystemProvider,
|
||||
@@ -50,10 +51,23 @@ const ppocrOcr: OcrPpocrProvider = {
|
||||
}
|
||||
} as const
|
||||
|
||||
const ovOcr: OcrOvProvider = {
|
||||
id: 'ovocr',
|
||||
name: 'Intel OV(NPU) OCR',
|
||||
config: {
|
||||
langs: isWin ? ['en-us', 'zh-cn'] : undefined
|
||||
},
|
||||
capabilities: {
|
||||
image: true
|
||||
// pdf: true
|
||||
}
|
||||
} as const satisfies OcrOvProvider
|
||||
|
||||
export const BUILTIN_OCR_PROVIDERS_MAP = {
|
||||
tesseract,
|
||||
system: systemOcr,
|
||||
paddleocr: ppocrOcr
|
||||
paddleocr: ppocrOcr,
|
||||
ovocr: ovOcr
|
||||
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
|
||||
|
||||
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)
|
||||
|
||||
@@ -80,16 +80,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
|
||||
}
|
||||
|
||||
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
|
||||
// cherryin: {
|
||||
// id: 'cherryin',
|
||||
// name: 'CherryIN',
|
||||
// type: 'openai',
|
||||
// apiKey: '',
|
||||
// apiHost: 'https://open.cherryin.ai',
|
||||
// models: [],
|
||||
// isSystem: true,
|
||||
// enabled: true
|
||||
// },
|
||||
cherryin: {
|
||||
id: 'cherryin',
|
||||
name: 'CherryIN',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://open.cherryin.net',
|
||||
models: [],
|
||||
isSystem: true,
|
||||
enabled: true
|
||||
},
|
||||
silicon: {
|
||||
id: 'silicon',
|
||||
name: 'Silicon',
|
||||
@@ -283,7 +283,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
'new-api': {
|
||||
id: 'new-api',
|
||||
name: 'New API',
|
||||
type: 'openai',
|
||||
type: 'new-api',
|
||||
apiKey: '',
|
||||
apiHost: 'http://localhost:3000',
|
||||
models: SYSTEM_MODELS['new-api'],
|
||||
@@ -732,17 +732,17 @@ type ProviderUrls = {
|
||||
}
|
||||
|
||||
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
// cherryin: {
|
||||
// api: {
|
||||
// url: 'https://open.cherryin.ai'
|
||||
// },
|
||||
// websites: {
|
||||
// official: 'https://open.cherryin.ai',
|
||||
// apiKey: 'https://open.cherryin.ai/console/token',
|
||||
// docs: 'https://open.cherryin.ai',
|
||||
// models: 'https://open.cherryin.ai/pricing'
|
||||
// }
|
||||
// },
|
||||
cherryin: {
|
||||
api: {
|
||||
url: 'https://open.cherryin.net'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://open.cherryin.ai',
|
||||
apiKey: 'https://open.cherryin.ai/console/token',
|
||||
docs: 'https://open.cherryin.ai',
|
||||
models: 'https://open.cherryin.ai/pricing'
|
||||
}
|
||||
},
|
||||
ph8: {
|
||||
api: {
|
||||
url: 'https://ph8.co'
|
||||
@@ -1422,5 +1422,5 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
|
||||
}
|
||||
|
||||
export const isNewApiProvider = (provider: Provider) => {
|
||||
return ['new-api', 'cherryin'].includes(provider.id)
|
||||
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { LanguageVarious } from '@renderer/types'
|
||||
import { ConfigProvider, theme } from 'antd'
|
||||
import deDE from 'antd/locale/de_DE'
|
||||
import elGR from 'antd/locale/el_GR'
|
||||
import enUS from 'antd/locale/en_US'
|
||||
import esES from 'antd/locale/es_ES'
|
||||
@@ -126,6 +127,8 @@ function getAntdLocale(language: LanguageVarious) {
|
||||
return zhTW
|
||||
case 'en-US':
|
||||
return enUS
|
||||
case 'de-DE':
|
||||
return deDE
|
||||
case 'ru-RU':
|
||||
return ruRU
|
||||
case 'ja-JP':
|
||||
|
||||
@@ -76,14 +76,31 @@ export function useAppInit() {
|
||||
}, [avatar, dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
const checkForUpdates = async () => {
|
||||
const { isPackaged } = await window.api.getAppInfo()
|
||||
|
||||
if (!isPackaged || !autoCheckUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
const { updateInfo } = await window.api.checkForUpdate()
|
||||
dispatch(setUpdateState({ info: updateInfo }))
|
||||
}
|
||||
|
||||
// Initial check with delay
|
||||
runAsyncFunction(async () => {
|
||||
const { isPackaged } = await window.api.getAppInfo()
|
||||
if (isPackaged && autoCheckUpdate) {
|
||||
await delay(2)
|
||||
const { updateInfo } = await window.api.checkForUpdate()
|
||||
dispatch(setUpdateState({ info: updateInfo }))
|
||||
await checkForUpdates()
|
||||
}
|
||||
})
|
||||
|
||||
// Set up 4-hour interval check
|
||||
const FOUR_HOURS = 4 * 60 * 60 * 1000
|
||||
const intervalId = setInterval(checkForUpdates, FOUR_HOURS)
|
||||
|
||||
return () => clearInterval(intervalId)
|
||||
}, [dispatch, autoCheckUpdate])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -70,7 +70,7 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
saveEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
|
||||
@@ -360,7 +360,7 @@ export const useKnowledgeBases = () => {
|
||||
const deleteKnowledgeBase = (baseId: string) => {
|
||||
const base = bases.find((b) => b.id === baseId)
|
||||
if (!base) return
|
||||
dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) }))
|
||||
dispatch(deleteBase({ baseId }))
|
||||
|
||||
// remove assistant knowledge_base
|
||||
const _assistants = assistants.map((assistant) => {
|
||||
|
||||
57
src/renderer/src/hooks/useMCPServerTrust.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import ProtocolInstallWarningContent from '@renderer/pages/settings/MCPSettings/ProtocolInstallWarning'
|
||||
import {
|
||||
ensureServerTrusted as ensureServerTrustedCore,
|
||||
getCommandPreview
|
||||
} from '@renderer/pages/settings/MCPSettings/utils'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { modalConfirm } from '@renderer/utils'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useMCPServers } from './useMCPServers'
|
||||
|
||||
/**
|
||||
* Hook for handling MCP server trust verification
|
||||
* Binds UI (modal dialog) to the core trust verification logic
|
||||
*/
|
||||
export const useMCPServerTrust = () => {
|
||||
const { updateMCPServer } = useMCPServers()
|
||||
const { t } = useTranslation()
|
||||
|
||||
/**
|
||||
* Request user confirmation to trust a server
|
||||
* Shows a warning modal with server command preview
|
||||
*/
|
||||
const requestConfirm = useCallback(
|
||||
async (server: MCPServer): Promise<boolean> => {
|
||||
const commandPreview = getCommandPreview(server)
|
||||
return modalConfirm({
|
||||
title: t('settings.mcp.protocolInstallWarning.title'),
|
||||
content: (
|
||||
<ProtocolInstallWarningContent
|
||||
message={t('settings.mcp.protocolInstallWarning.message')}
|
||||
commandLabel={t('settings.mcp.protocolInstallWarning.command')}
|
||||
commandPreview={commandPreview}
|
||||
/>
|
||||
),
|
||||
okText: t('settings.mcp.protocolInstallWarning.run'),
|
||||
cancelText: t('common.cancel'),
|
||||
okButtonProps: { danger: true }
|
||||
})
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
/**
|
||||
* Ensures a server is trusted before proceeding
|
||||
* Combines core logic with UI confirmation
|
||||
*/
|
||||
const ensureServerTrusted = useCallback(
|
||||
async (server: MCPServer): Promise<MCPServer | null> => {
|
||||
return ensureServerTrustedCore(server, requestConfirm, updateMCPServer)
|
||||
},
|
||||
[requestConfirm, updateMCPServer]
|
||||
)
|
||||
|
||||
return { ensureServerTrusted }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import IntelLogo from '@renderer/assets/images/providers/intel.png'
|
||||
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png'
|
||||
import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png'
|
||||
import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
|
||||
@@ -83,6 +84,8 @@ export const useOcrProviders = () => {
|
||||
return <MonitorIcon size={size} />
|
||||
case 'paddleocr':
|
||||
return <Avatar size={size} src={PaddleocrLogo} />
|
||||
case 'ovocr':
|
||||
return <Avatar size={size} src={IntelLogo} />
|
||||
}
|
||||
}
|
||||
return <FileQuestionMarkIcon size={size} />
|
||||
|
||||
@@ -7,7 +7,7 @@ interface UseSmoothStreamOptions {
|
||||
initialText?: string
|
||||
}
|
||||
|
||||
const languages = ['en-US', 'es-ES', 'zh-CN', 'zh-TW', 'ja-JP', 'ru-RU', 'el-GR', 'fr-FR', 'pt-PT']
|
||||
const languages = ['en-US', 'de-DE', 'es-ES', 'zh-CN', 'zh-TW', 'ja-JP', 'ru-RU', 'el-GR', 'fr-FR', 'pt-PT']
|
||||
const segmenter = new Intl.Segmenter(languages)
|
||||
|
||||
export const useSmoothStream = ({ onUpdate, streamDone, minDelay = 10, initialText = '' }: UseSmoothStreamOptions) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import enUS from './locales/en-us.json'
|
||||
import zhCN from './locales/zh-cn.json'
|
||||
import zhTW from './locales/zh-tw.json'
|
||||
// Machine translation
|
||||
import deDE from './translate/de-de.json'
|
||||
import elGR from './translate/el-gr.json'
|
||||
import esES from './translate/es-es.json'
|
||||
import frFR from './translate/fr-fr.json'
|
||||
@@ -24,6 +25,7 @@ const resources = Object.fromEntries(
|
||||
['ru-RU', ruRU],
|
||||
['zh-CN', zhCN],
|
||||
['zh-TW', zhTW],
|
||||
['de-DE', deDE],
|
||||
['el-GR', elGR],
|
||||
['es-ES', esES],
|
||||
['fr-FR', frFR],
|
||||
|
||||
@@ -322,7 +322,8 @@ const builtInMcpDescriptionKeyMap: Record<BuiltinMCPServerName, string> = {
|
||||
[BuiltinMCPServerNames.fetch]: 'settings.mcp.builtinServersDescriptions.fetch',
|
||||
[BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem',
|
||||
[BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge',
|
||||
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python'
|
||||
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python',
|
||||
[BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp'
|
||||
} as const
|
||||
|
||||
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
|
||||
@@ -332,11 +333,13 @@ export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
|
||||
const builtinOcrProviderKeyMap = {
|
||||
system: 'ocr.builtin.system',
|
||||
tesseract: '',
|
||||
paddleocr: ''
|
||||
paddleocr: '',
|
||||
ovocr: ''
|
||||
} as const satisfies Record<BuiltinOcrProviderId, string>
|
||||
|
||||
export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => {
|
||||
if (key === 'tesseract') return 'Tesseract'
|
||||
else if (key == 'paddleocr') return 'PaddleOCR'
|
||||
else if (key == 'ovocr') return 'Intel OV(NPU) OCR'
|
||||
else return getLabel(builtinOcrProviderKeyMap, key)
|
||||
}
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
"context": "Clear Context {{Command}}"
|
||||
},
|
||||
"new_topic": "New Topic {{Command}}",
|
||||
"paste_text_file_confirm": "Paste into input bar?",
|
||||
"pause": "Pause",
|
||||
"placeholder": "Type your message here, press {{key}} to send - @ to Select Model, / to Include Tools",
|
||||
"placeholder_without_triggers": "Type your message here, press {{key}} to send",
|
||||
@@ -757,6 +758,7 @@
|
||||
"clear": "Clear",
|
||||
"close": "Close",
|
||||
"collapse": "Collapse",
|
||||
"completed": "Completed",
|
||||
"confirm": "Confirm",
|
||||
"copied": "Copied",
|
||||
"copy": "Copy",
|
||||
@@ -1583,13 +1585,13 @@
|
||||
"nami-ai-search": "Nami AI Search",
|
||||
"qwen": "Qwen",
|
||||
"sensechat": "SenseChat",
|
||||
"stepfun": "Stepfun",
|
||||
"tencent-yuanbao": "Yuanbao",
|
||||
"tiangong-ai": "Skywork",
|
||||
"wanzhi": "Wanzhi",
|
||||
"wenxin": "ERNIE",
|
||||
"wps-copilot": "WPS Copilot",
|
||||
"xiaoyi": "Xiaoyi",
|
||||
"yuewen": "Yuewen",
|
||||
"zhihu": "Zhihu"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -1728,6 +1730,14 @@
|
||||
"rename": "Rename",
|
||||
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
|
||||
"save": "Save to Notes",
|
||||
"search": {
|
||||
"both": "Name+Content",
|
||||
"content": "Content",
|
||||
"found_results": "Found {{count}} results (Name: {{nameCount}}, Content: {{contentCount}})",
|
||||
"more_matches": "more matches",
|
||||
"searching": "Searching...",
|
||||
"show_less": "Show less"
|
||||
},
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "Apply",
|
||||
@@ -1812,6 +1822,7 @@
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Cannot delete built-in provider",
|
||||
"existing": "The provider already exists",
|
||||
"get_providers": "Failed to get available providers",
|
||||
"not_found": "OCR provider does not exist",
|
||||
"update_failed": "Failed to update configuration"
|
||||
},
|
||||
@@ -1873,8 +1884,10 @@
|
||||
"install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU",
|
||||
"install_code_102": "Only supports Windows",
|
||||
"install_code_103": "Download OVMS runtime failed",
|
||||
"install_code_104": "Uncompress OVMS runtime failed",
|
||||
"install_code_105": "Clean OVMS runtime failed",
|
||||
"install_code_104": "Failed to install OVMS runtime",
|
||||
"install_code_105": "Failed to create ovdnd.exe",
|
||||
"install_code_106": "Failed to create run.bat",
|
||||
"install_code_110": "Failed to clean old OVMS runtime",
|
||||
"run": "Run OVMS failed:",
|
||||
"stop": "Stop OVMS failed:"
|
||||
},
|
||||
@@ -2571,15 +2584,14 @@
|
||||
},
|
||||
"description": "A powerful AI assistant for producer",
|
||||
"downloading": "Downloading...",
|
||||
"enterprise": {
|
||||
"title": "Enterprise"
|
||||
},
|
||||
"feedback": {
|
||||
"button": "Feedback",
|
||||
"title": "Feedback"
|
||||
},
|
||||
"label": "About & Feedback",
|
||||
"license": {
|
||||
"button": "License",
|
||||
"title": "License"
|
||||
},
|
||||
"releases": {
|
||||
"button": "Releases",
|
||||
"title": "Release Notes"
|
||||
@@ -2687,6 +2699,46 @@
|
||||
"title": "Export Menu Settings",
|
||||
"yuque": "Export to Yuque"
|
||||
},
|
||||
"export_to_phone": {
|
||||
"confirm": {
|
||||
"button": "Select backup file"
|
||||
},
|
||||
"content": "Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.",
|
||||
"lan": {
|
||||
"auto_close_tip": "Auto-closing in {{seconds}} seconds...",
|
||||
"confirm_close_message": "File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||
"confirm_close_title": "Confirm Close",
|
||||
"connected": "Connected",
|
||||
"connection_failed": "Connection failed",
|
||||
"content": "Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.",
|
||||
"error": {
|
||||
"init_failed": "Initialization failed",
|
||||
"no_file": "No file selected",
|
||||
"no_ip": "Unable to get IP address",
|
||||
"send_failed": "Failed to send file"
|
||||
},
|
||||
"force_close": "Force Close",
|
||||
"generating_qr": "Generating QR code...",
|
||||
"noZipSelected": "No compressed file selected",
|
||||
"scan_qr": "Please scan QR code with your phone",
|
||||
"selectZip": "Select a compressed file",
|
||||
"sendZip": "Begin data recovery",
|
||||
"status": {
|
||||
"completed": "Transfer completed",
|
||||
"connected": "Connected",
|
||||
"connecting": "Connecting...",
|
||||
"disconnected": "Disconnected",
|
||||
"error": "Connection error",
|
||||
"initializing": "Initializing connection...",
|
||||
"preparing": "Preparing transfer...",
|
||||
"sending": "Transferring {{progress}}%",
|
||||
"waiting_qr_scan": "Please scan QR code to connect"
|
||||
},
|
||||
"title": "LAN transmission",
|
||||
"transfer_progress": "Transfer progress"
|
||||
},
|
||||
"title": "Export to phone"
|
||||
},
|
||||
"hour_interval_one": "{{count}} hour",
|
||||
"hour_interval_other": "{{count}} hours",
|
||||
"joplin": {
|
||||
@@ -3345,6 +3397,7 @@
|
||||
"builtinServers": "Builtin Servers",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "An MCP server implementation integrating the Brave Search API, providing both web and local search functionalities. Requires configuring the BRAVE_API_KEY environment variable",
|
||||
"didi_mcp": "DiDi MCP server providing ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in Mainland China. Requires configuring the DIDI_API_KEY environment variable",
|
||||
"dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key",
|
||||
"fetch": "MCP server for retrieving URL web content",
|
||||
"filesystem": "A Node.js server implementing the Model Context Protocol (MCP) for file system operations. Requires configuration of directories allowed for access.",
|
||||
@@ -3432,6 +3485,12 @@
|
||||
"noPromptsAvailable": "No prompts available",
|
||||
"requiredField": "Required Field"
|
||||
},
|
||||
"protocolInstallWarning": {
|
||||
"command": "Startup command",
|
||||
"message": "This MCP was installed from an external source via protocol. Running unknown tools may harm your computer.",
|
||||
"run": "Run",
|
||||
"title": "Run external MCP?"
|
||||
},
|
||||
"provider": "Provider",
|
||||
"providerPlaceholder": "Provider name",
|
||||
"providerUrl": "Provider URL",
|
||||
@@ -3844,7 +3903,7 @@
|
||||
"api_host": "API Host",
|
||||
"api_key": {
|
||||
"label": "API Key",
|
||||
"tip": "Multiple keys separated by commas or spaces"
|
||||
"tip": "Use commas to separate multiple keys"
|
||||
},
|
||||
"api_version": "API Version",
|
||||
"aws-bedrock": {
|
||||
@@ -3978,7 +4037,8 @@
|
||||
"none": "No Proxy",
|
||||
"system": "System Proxy",
|
||||
"title": "Proxy Mode"
|
||||
}
|
||||
},
|
||||
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
|
||||
},
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "Click the tray icon to start",
|
||||
@@ -4412,6 +4472,7 @@
|
||||
"later": "Later",
|
||||
"message": "New version {{version}} is ready, do you want to install it now?",
|
||||
"noReleaseNotes": "No release notes",
|
||||
"saveDataError": "Failed to save data, please try again.",
|
||||
"title": "Update"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
"context": "清除上下文 {{Command}}"
|
||||
},
|
||||
"new_topic": "新话题 {{Command}}",
|
||||
"paste_text_file_confirm": "粘贴到输入框?",
|
||||
"pause": "暂停",
|
||||
"placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择模型, / 选择工具",
|
||||
"placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送",
|
||||
@@ -757,6 +758,7 @@
|
||||
"clear": "清除",
|
||||
"close": "关闭",
|
||||
"collapse": "折叠",
|
||||
"completed": "完成",
|
||||
"confirm": "确认",
|
||||
"copied": "已复制",
|
||||
"copy": "复制",
|
||||
@@ -1583,13 +1585,13 @@
|
||||
"nami-ai-search": "纳米AI搜索",
|
||||
"qwen": "通义千问",
|
||||
"sensechat": "商量",
|
||||
"stepfun": "阶跃AI",
|
||||
"tencent-yuanbao": "腾讯元宝",
|
||||
"tiangong-ai": "天工AI",
|
||||
"wanzhi": "万知",
|
||||
"wenxin": "文心一言",
|
||||
"wps-copilot": "WPS灵犀",
|
||||
"xiaoyi": "小艺",
|
||||
"yuewen": "跃问",
|
||||
"zhihu": "知乎直答"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -1728,6 +1730,14 @@
|
||||
"rename": "重命名",
|
||||
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
|
||||
"save": "保存到笔记",
|
||||
"search": {
|
||||
"both": "名称+内容",
|
||||
"content": "内容",
|
||||
"found_results": "找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
|
||||
"more_matches": "个匹配",
|
||||
"searching": "搜索中...",
|
||||
"show_less": "收起"
|
||||
},
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "应用",
|
||||
@@ -1812,6 +1822,7 @@
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "不能删除内置提供商",
|
||||
"existing": "提供商已存在",
|
||||
"get_providers": "获取可用提供商失败",
|
||||
"not_found": "OCR 提供商不存在",
|
||||
"update_failed": "更新配置失败"
|
||||
},
|
||||
@@ -1848,7 +1859,7 @@
|
||||
"description": "<div><p>1. 下载 OV 模型.</p><p>2. 在 'Manager' 中添加模型.</p><p>仅支持 Windows!</p><p>OVMS 安装路径: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>请参考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>",
|
||||
"download": {
|
||||
"button": "下载",
|
||||
"error": "选择失败",
|
||||
"error": "下载失败",
|
||||
"model_id": {
|
||||
"label": "模型 ID",
|
||||
"model_id_pattern": "模型 ID 必须以 OpenVINO/ 开头",
|
||||
@@ -1873,8 +1884,10 @@
|
||||
"install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU",
|
||||
"install_code_102": "仅支持 Windows",
|
||||
"install_code_103": "下载 OVMS runtime 失败",
|
||||
"install_code_104": "解压 OVMS runtime 失败",
|
||||
"install_code_105": "清理 OVMS runtime 失败",
|
||||
"install_code_104": "安装 OVMS runtime 失败",
|
||||
"install_code_105": "创建 ovdnd.exe 失败",
|
||||
"install_code_106": "创建 run.bat 失败",
|
||||
"install_code_110": "清理旧 OVMS runtime 失败",
|
||||
"run": "运行 OVMS 失败:",
|
||||
"stop": "停止 OVMS 失败:"
|
||||
},
|
||||
@@ -2571,15 +2584,14 @@
|
||||
},
|
||||
"description": "一款为创造者而生的 AI 助手",
|
||||
"downloading": "正在下载更新...",
|
||||
"enterprise": {
|
||||
"title": "企业版"
|
||||
},
|
||||
"feedback": {
|
||||
"button": "反馈",
|
||||
"title": "意见反馈"
|
||||
},
|
||||
"label": "关于我们",
|
||||
"license": {
|
||||
"button": "查看",
|
||||
"title": "许可证"
|
||||
},
|
||||
"releases": {
|
||||
"button": "查看",
|
||||
"title": "更新日志"
|
||||
@@ -2687,6 +2699,46 @@
|
||||
"title": "导出菜单设置",
|
||||
"yuque": "导出到语雀"
|
||||
},
|
||||
"export_to_phone": {
|
||||
"confirm": {
|
||||
"button": "选择备份文件"
|
||||
},
|
||||
"content": "导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||
"lan": {
|
||||
"auto_close_tip": "{{seconds}} 秒后自动关闭...",
|
||||
"confirm_close_message": "文件正在传输中,关闭将中断传输。确定要强制关闭吗?",
|
||||
"confirm_close_title": "确认关闭",
|
||||
"connected": "连接成功",
|
||||
"connection_failed": "连接失败",
|
||||
"content": "请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||
"error": {
|
||||
"init_failed": "初始化失败",
|
||||
"no_file": "未选择文件",
|
||||
"no_ip": "无法获取 IP 地址",
|
||||
"send_failed": "发送文件失败"
|
||||
},
|
||||
"force_close": "强制关闭",
|
||||
"generating_qr": "正在生成二维码...",
|
||||
"noZipSelected": "未选择压缩文件",
|
||||
"scan_qr": "请使用手机扫码连接",
|
||||
"selectZip": "选择压缩文件",
|
||||
"sendZip": "开始恢复数据",
|
||||
"status": {
|
||||
"completed": "传输完成",
|
||||
"connected": "连接成功",
|
||||
"connecting": "正在连接中...",
|
||||
"disconnected": "连接已断开",
|
||||
"error": "连接出错",
|
||||
"initializing": "正在初始化连接...",
|
||||
"preparing": "准备传输中...",
|
||||
"sending": "传输中 {{progress}}%",
|
||||
"waiting_qr_scan": "请扫描二维码连接"
|
||||
},
|
||||
"title": "局域网传输",
|
||||
"transfer_progress": "传输进度"
|
||||
},
|
||||
"title": "导出至手机"
|
||||
},
|
||||
"hour_interval_one": "{{count}} 小时",
|
||||
"hour_interval_other": "{{count}} 小时",
|
||||
"joplin": {
|
||||
@@ -3345,6 +3397,7 @@
|
||||
"builtinServers": "内置服务器",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量",
|
||||
"didi_mcp": "一个集成了滴滴 MCP 服务器实现,提供网约车服务包括地图搜索、价格预估、订单管理和司机跟踪。仅支持中国大陆地区。需要配置 DIDI_API_KEY 环境变量",
|
||||
"dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key",
|
||||
"fetch": "用于获取 URL 网页内容的 MCP 服务器",
|
||||
"filesystem": "实现文件系统操作的模型上下文协议(MCP)的 Node.js 服务器。需要配置允许访问的目录",
|
||||
@@ -3432,6 +3485,12 @@
|
||||
"noPromptsAvailable": "无可用提示",
|
||||
"requiredField": "必填字段"
|
||||
},
|
||||
"protocolInstallWarning": {
|
||||
"command": "启动命令",
|
||||
"message": "该 MCP 是通过协议从外部来源安装的,运行来历不明的工具可能对您的计算机造成危害。",
|
||||
"run": "运行",
|
||||
"title": "运行外部 MCP?"
|
||||
},
|
||||
"provider": "提供者",
|
||||
"providerPlaceholder": "提供者名称",
|
||||
"providerUrl": "提供者网址",
|
||||
@@ -3844,7 +3903,7 @@
|
||||
"api_host": "API 地址",
|
||||
"api_key": {
|
||||
"label": "API 密钥",
|
||||
"tip": "多个密钥使用逗号或空格分隔"
|
||||
"tip": "多个密钥使用逗号分隔"
|
||||
},
|
||||
"api_version": "API 版本",
|
||||
"aws-bedrock": {
|
||||
@@ -3978,7 +4037,8 @@
|
||||
"none": "不使用代理",
|
||||
"system": "系统代理",
|
||||
"title": "代理模式"
|
||||
}
|
||||
},
|
||||
"tip": "支持模糊匹配(*.test.com,192.168.0.0/16)"
|
||||
},
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "点击托盘图标启动",
|
||||
@@ -4412,6 +4472,7 @@
|
||||
"later": "稍后",
|
||||
"message": "发现新版本 {{version}},是否立即安装?",
|
||||
"noReleaseNotes": "暂无更新日志",
|
||||
"saveDataError": "保存数据失败,请重试",
|
||||
"title": "更新提示"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
"context": "清除上下文 {{Command}}"
|
||||
},
|
||||
"new_topic": "新話題 {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "暫停",
|
||||
"placeholder": "在此輸入您的訊息,按 {{key}} 傳送 - @ 選擇模型,/ 包含工具",
|
||||
"placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送",
|
||||
@@ -757,6 +758,7 @@
|
||||
"clear": "清除",
|
||||
"close": "關閉",
|
||||
"collapse": "折疊",
|
||||
"completed": "[to be translated]:Completed",
|
||||
"confirm": "確認",
|
||||
"copied": "已複製",
|
||||
"copy": "複製",
|
||||
@@ -1583,13 +1585,13 @@
|
||||
"nami-ai-search": "納米AI搜索",
|
||||
"qwen": "通義千問",
|
||||
"sensechat": "商量",
|
||||
"stepfun": "階躍AI",
|
||||
"tencent-yuanbao": "騰訊元寶",
|
||||
"tiangong-ai": "天工AI",
|
||||
"wanzhi": "萬知",
|
||||
"wenxin": "文心一言",
|
||||
"wps-copilot": "WPS靈犀",
|
||||
"xiaoyi": "小藝",
|
||||
"yuewen": "躍問",
|
||||
"zhihu": "知乎直答"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -1728,6 +1730,14 @@
|
||||
"rename": "重命名",
|
||||
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
|
||||
"save": "儲存到筆記",
|
||||
"search": {
|
||||
"both": "名稱+內容",
|
||||
"content": "內容",
|
||||
"found_results": "找到 {{count}} 個結果 (名稱: {{nameCount}}, 內容: {{contentCount}})",
|
||||
"more_matches": "個匹配",
|
||||
"searching": "搜索中...",
|
||||
"show_less": "收起"
|
||||
},
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "應用",
|
||||
@@ -1811,8 +1821,9 @@
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "不能刪除內建提供者",
|
||||
"existing": "提供商已存在",
|
||||
"not_found": "OCR 提供商不存在",
|
||||
"existing": "提供者已存在",
|
||||
"get_providers": "取得可用提供者失敗",
|
||||
"not_found": "OCR 提供者不存在",
|
||||
"update_failed": "更新配置失敗"
|
||||
},
|
||||
"unknown": "OCR過程發生錯誤"
|
||||
@@ -1848,7 +1859,7 @@
|
||||
"description": "<div><p>1. 下載 OV 模型。</p><p>2. 在 'Manager' 中新增模型。</p><p>僅支援 Windows!</p><p>OVMS 安裝路徑: '%USERPROFILE%\\.cherrystudio\\ovms' 。</p><p>請參考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>",
|
||||
"download": {
|
||||
"button": "下載",
|
||||
"error": "選擇失敗",
|
||||
"error": "下載失敗",
|
||||
"model_id": {
|
||||
"label": "模型 ID",
|
||||
"model_id_pattern": "模型 ID 必須以 OpenVINO/ 開頭",
|
||||
@@ -1873,8 +1884,10 @@
|
||||
"install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU",
|
||||
"install_code_102": "僅支援 Windows",
|
||||
"install_code_103": "下載 OVMS runtime 失敗",
|
||||
"install_code_104": "解壓 OVMS runtime 失敗",
|
||||
"install_code_105": "清理 OVMS runtime 失敗",
|
||||
"install_code_104": "安裝 OVMS runtime 失敗",
|
||||
"install_code_105": "創建 ovdnd.exe 失敗",
|
||||
"install_code_106": "創建 run.bat 失敗",
|
||||
"install_code_110": "清理舊 OVMS runtime 失敗",
|
||||
"run": "執行 OVMS 失敗:",
|
||||
"stop": "停止 OVMS 失敗:"
|
||||
},
|
||||
@@ -2571,15 +2584,14 @@
|
||||
},
|
||||
"description": "一款為創作者而生的強大 AI 助手",
|
||||
"downloading": "正在下載...",
|
||||
"enterprise": {
|
||||
"title": "企業版"
|
||||
},
|
||||
"feedback": {
|
||||
"button": "回饋",
|
||||
"title": "回饋"
|
||||
},
|
||||
"label": "關於與回饋",
|
||||
"license": {
|
||||
"button": "檢視",
|
||||
"title": "授權"
|
||||
},
|
||||
"releases": {
|
||||
"button": "檢視",
|
||||
"title": "更新日誌"
|
||||
@@ -2687,6 +2699,46 @@
|
||||
"title": "匯出選單設定",
|
||||
"yuque": "匯出到語雀"
|
||||
},
|
||||
"export_to_phone": {
|
||||
"confirm": {
|
||||
"button": "選擇備份檔案"
|
||||
},
|
||||
"content": "匯出部分數據,包括聊天記錄、設定。請注意,備份過程可能需要一些時間,感謝您的耐心等候。",
|
||||
"lan": {
|
||||
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connection_failed": "[to be translated]:Connection failed",
|
||||
"content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請打開 Cherry Studio App 掃描此 QR 碼。",
|
||||
"error": {
|
||||
"init_failed": "[to be translated]:Initialization failed",
|
||||
"no_file": "[to be translated]:No file selected",
|
||||
"no_ip": "[to be translated]:Unable to get IP address",
|
||||
"send_failed": "[to be translated]:Failed to send file"
|
||||
},
|
||||
"force_close": "[to be translated]:Force Close",
|
||||
"generating_qr": "[to be translated]:Generating QR code...",
|
||||
"noZipSelected": "未選取壓縮檔案",
|
||||
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||
"selectZip": "選擇壓縮檔案",
|
||||
"sendZip": "開始恢復資料",
|
||||
"status": {
|
||||
"completed": "[to be translated]:Transfer completed",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connecting": "[to be translated]:Connecting...",
|
||||
"disconnected": "[to be translated]:Disconnected",
|
||||
"error": "[to be translated]:Connection error",
|
||||
"initializing": "[to be translated]:Initializing connection...",
|
||||
"preparing": "[to be translated]:Preparing transfer...",
|
||||
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||
},
|
||||
"title": "區域網路傳輸",
|
||||
"transfer_progress": "[to be translated]:Transfer progress"
|
||||
},
|
||||
"title": "匯出手機"
|
||||
},
|
||||
"hour_interval_one": "{{count}} 小時",
|
||||
"hour_interval_other": "{{count}} 小時",
|
||||
"joplin": {
|
||||
@@ -3345,6 +3397,7 @@
|
||||
"builtinServers": "內置伺服器",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "一個集成了Brave 搜索 API 的 MCP 伺服器實現,提供網頁與本地搜尋雙重功能。需要配置 BRAVE_API_KEY 環境變數",
|
||||
"didi_mcp": "一個集成了滴滴 MCP 伺服器實現,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要配置 DIDI_API_KEY 環境變數",
|
||||
"dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key",
|
||||
"fetch": "用於獲取 URL 網頁內容的 MCP 伺服器",
|
||||
"filesystem": "實現文件系統操作的模型上下文協議(MCP)的 Node.js 伺服器。需要配置允許訪問的目錄",
|
||||
@@ -3432,6 +3485,12 @@
|
||||
"noPromptsAvailable": "無可用提示",
|
||||
"requiredField": "必填欄位"
|
||||
},
|
||||
"protocolInstallWarning": {
|
||||
"command": "啟動命令",
|
||||
"message": "此 MCP 透過協議從外部來源安裝,執行來源不明的工具可能會對您的電腦造成危害。",
|
||||
"run": "執行",
|
||||
"title": "執行外部 MCP?"
|
||||
},
|
||||
"provider": "提供者",
|
||||
"providerPlaceholder": "提供者名稱",
|
||||
"providerUrl": "提供者網址",
|
||||
@@ -3844,7 +3903,7 @@
|
||||
"api_host": "API 主機地址",
|
||||
"api_key": {
|
||||
"label": "API 金鑰",
|
||||
"tip": "多個金鑰使用逗號或空格分隔"
|
||||
"tip": "多個金鑰使用逗號分隔"
|
||||
},
|
||||
"api_version": "API 版本",
|
||||
"aws-bedrock": {
|
||||
@@ -3978,7 +4037,8 @@
|
||||
"none": "不使用代理伺服器",
|
||||
"system": "系統代理伺服器",
|
||||
"title": "代理伺服器模式"
|
||||
}
|
||||
},
|
||||
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
|
||||
},
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "點選工具列圖示啟動",
|
||||
@@ -4412,6 +4472,7 @@
|
||||
"later": "稍後",
|
||||
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
|
||||
"noReleaseNotes": "暫無更新日誌",
|
||||
"saveDataError": "保存數據失敗,請重試",
|
||||
"title": "更新提示"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
4702
src/renderer/src/i18n/translate/de-de.json
Normal file
@@ -332,6 +332,7 @@
|
||||
"context": "Καθαρισμός ενδιάμεσων {{Command}}"
|
||||
},
|
||||
"new_topic": "Νέο θέμα {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "Παύση",
|
||||
"placeholder": "Εισάγετε μήνυμα εδώ...",
|
||||
"placeholder_without_triggers": "Εδώ εισαγάγετε το μήνυμα, πατήστε {{key}} για αποστολή",
|
||||
@@ -757,6 +758,7 @@
|
||||
"clear": "Καθαρισμός",
|
||||
"close": "Κλείσιμο",
|
||||
"collapse": "Σύμπτυξη",
|
||||
"completed": "[to be translated]:Completed",
|
||||
"confirm": "Επιβεβαίωση",
|
||||
"copied": "Αντιγράφηκε",
|
||||
"copy": "Αντιγραφή",
|
||||
@@ -1583,13 +1585,13 @@
|
||||
"nami-ai-search": "Nami AI Search",
|
||||
"qwen": "Qwen",
|
||||
"sensechat": "SenseChat",
|
||||
"stepfun": "Stepfun",
|
||||
"tencent-yuanbao": "Yuanbao",
|
||||
"tiangong-ai": "Skywork",
|
||||
"wanzhi": "Wanzhi",
|
||||
"wenxin": "ERNIE",
|
||||
"wps-copilot": "WPS Copilot",
|
||||
"xiaoyi": "Xiaoyi",
|
||||
"yuewen": "Yuewen",
|
||||
"zhihu": "Zhihu"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -1728,6 +1730,14 @@
|
||||
"rename": "μετονομασία",
|
||||
"rename_changed": "Λόγω πολιτικής ασφάλειας, το όνομα του αρχείου έχει αλλάξει από {{original}} σε {{final}}",
|
||||
"save": "αποθήκευση στις σημειώσεις",
|
||||
"search": {
|
||||
"both": "[to be translated]:名称+内容",
|
||||
"content": "[to be translated]:内容",
|
||||
"found_results": "[to be translated]:找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
|
||||
"more_matches": "[to be translated]:个匹配",
|
||||
"searching": "[to be translated]:搜索中...",
|
||||
"show_less": "[to be translated]:收起"
|
||||
},
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "εφαρμογή",
|
||||
@@ -1812,6 +1822,7 @@
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου",
|
||||
"existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη",
|
||||
"get_providers": "Αποτυχία λήψης διαθέσιμων παρόχων",
|
||||
"not_found": "Ο πάροχος OCR δεν υπάρχει",
|
||||
"update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης"
|
||||
},
|
||||
@@ -1875,6 +1886,8 @@
|
||||
"install_code_103": "Η λήψη του OVMS runtime απέτυχε",
|
||||
"install_code_104": "Η αποσυμπίεση του OVMS runtime απέτυχε",
|
||||
"install_code_105": "Ο καθαρισμός του OVMS runtime απέτυχε",
|
||||
"install_code_106": "[to be translated]:创建 run.bat 失败",
|
||||
"install_code_110": "[to be translated]:清理旧 OVMS runtime 失败",
|
||||
"run": "Η εκτέλεση του OVMS απέτυχε:",
|
||||
"stop": "Η διακοπή του OVMS απέτυχε:"
|
||||
},
|
||||
@@ -2571,15 +2584,14 @@
|
||||
},
|
||||
"description": "Ένα AI ασιστάντα που έχει σχεδιαστεί για δημιουργούς",
|
||||
"downloading": "Λήψη ενημερώσεων...",
|
||||
"enterprise": {
|
||||
"title": "Επιχείρηση"
|
||||
},
|
||||
"feedback": {
|
||||
"button": "Σχόλια και Παρατηρήσεις",
|
||||
"title": "Αποστολή σχολίων"
|
||||
},
|
||||
"label": "Περί μας",
|
||||
"license": {
|
||||
"button": "Προβολή",
|
||||
"title": "Licenses"
|
||||
},
|
||||
"releases": {
|
||||
"button": "Προβολή",
|
||||
"title": "Ημερολόγιο Ενημερώσεων"
|
||||
@@ -2687,6 +2699,46 @@
|
||||
"title": "Εξαγωγή ρυθμίσεων μενού",
|
||||
"yuque": "Εξαγωγή στο Yuque"
|
||||
},
|
||||
"export_to_phone": {
|
||||
"confirm": {
|
||||
"button": "[to be translated]:选择备份文件"
|
||||
},
|
||||
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||
"lan": {
|
||||
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connection_failed": "[to be translated]:Connection failed",
|
||||
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||
"error": {
|
||||
"init_failed": "[to be translated]:Initialization failed",
|
||||
"no_file": "[to be translated]:No file selected",
|
||||
"no_ip": "[to be translated]:Unable to get IP address",
|
||||
"send_failed": "[to be translated]:Failed to send file"
|
||||
},
|
||||
"force_close": "[to be translated]:Force Close",
|
||||
"generating_qr": "[to be translated]:Generating QR code...",
|
||||
"noZipSelected": "[to be translated]:未选择压缩文件",
|
||||
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||
"selectZip": "[to be translated]:选择压缩文件",
|
||||
"sendZip": "[to be translated]:开始恢复数据",
|
||||
"status": {
|
||||
"completed": "[to be translated]:Transfer completed",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connecting": "[to be translated]:Connecting...",
|
||||
"disconnected": "[to be translated]:Disconnected",
|
||||
"error": "[to be translated]:Connection error",
|
||||
"initializing": "[to be translated]:Initializing connection...",
|
||||
"preparing": "[to be translated]:Preparing transfer...",
|
||||
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||
},
|
||||
"title": "[to be translated]:局域网传输",
|
||||
"transfer_progress": "[to be translated]:Transfer progress"
|
||||
},
|
||||
"title": "[to be translated]:导出至手机"
|
||||
},
|
||||
"hour_interval_one": "{{count}} ώρα",
|
||||
"hour_interval_other": "{{count}} ώρες",
|
||||
"joplin": {
|
||||
@@ -3345,6 +3397,7 @@
|
||||
"builtinServers": "Ενσωματωμένοι Διακομιστές",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY",
|
||||
"didi_mcp": "Διακομιστής DiDi MCP που παρέχει υπηρεσίες μεταφοράς συμπεριλαμβανομένης της αναζήτησης χαρτών, εκτίμησης τιμών, διαχείρισης παραγγελιών και παρακολούθησης οδηγών. Διαθέσιμο μόνο στην ηπειρωτική Κίνα. Απαιτεί διαμόρφωση της μεταβλητής περιβάλλοντος DIDI_API_KEY",
|
||||
"dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify",
|
||||
"fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL",
|
||||
"filesystem": "Εξυπηρετητής Node.js για το πρωτόκολλο περιβάλλοντος μοντέλου (MCP) που εφαρμόζει λειτουργίες συστήματος αρχείων. Απαιτείται διαμόρφωση για την επιτροπή πρόσβασης σε καταλόγους",
|
||||
@@ -3432,6 +3485,12 @@
|
||||
"noPromptsAvailable": "Δεν υπάρχουν διαθέσιμες υποδείξεις",
|
||||
"requiredField": "Υποχρεωτικό πεδίο"
|
||||
},
|
||||
"protocolInstallWarning": {
|
||||
"command": "Εντολή εκκίνησης",
|
||||
"message": "Αυτό το MCP εγκαταστάθηκε από εξωτερική πηγή μέσω πρωτοκόλλου. Η εκτέλεση άγνωστων εργαλείων ενδέχεται να βλάψει τον υπολογιστή σας.",
|
||||
"run": "Τρέξε",
|
||||
"title": "Εκτέλεση εξωτερικού MCP;"
|
||||
},
|
||||
"provider": "Πάροχος",
|
||||
"providerPlaceholder": "Όνομα παρόχου",
|
||||
"providerUrl": "URL Παρόχου",
|
||||
@@ -3978,7 +4037,8 @@
|
||||
"none": "χωρίς πρόξενο",
|
||||
"system": "συστηματική προξενική",
|
||||
"title": "κλίμακα προξενικής"
|
||||
}
|
||||
},
|
||||
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
|
||||
},
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "Επιλέξτε την εικόνα στο πίνακα για να ενεργοποιήσετε",
|
||||
@@ -4412,6 +4472,7 @@
|
||||
"later": "Μετά",
|
||||
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
|
||||
"noReleaseNotes": "Χωρίς σημειώσεις",
|
||||
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
|
||||
"title": "Ενημέρωση"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
"context": "Limpiar contexto {{Command}}"
|
||||
},
|
||||
"new_topic": "Nuevo tema {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "Pausar",
|
||||
"placeholder": "Escribe aquí tu mensaje...",
|
||||
"placeholder_without_triggers": "Escriba un mensaje aquí y presione {{key}} para enviar",
|
||||
@@ -757,6 +758,7 @@
|
||||
"clear": "Limpiar",
|
||||
"close": "Cerrar",
|
||||
"collapse": "Colapsar",
|
||||
"completed": "[to be translated]:Completed",
|
||||
"confirm": "Confirmar",
|
||||
"copied": "Copiado",
|
||||
"copy": "Copiar",
|
||||
@@ -1583,13 +1585,13 @@
|
||||
"nami-ai-search": "Nami AI Search",
|
||||
"qwen": "Qwen",
|
||||
"sensechat": "SenseChat",
|
||||
"stepfun": "Stepfun",
|
||||
"tencent-yuanbao": "Yuanbao",
|
||||
"tiangong-ai": "Skywork",
|
||||
"wanzhi": "Wanzhi",
|
||||
"wenxin": "ERNIE",
|
||||
"wps-copilot": "WPS Copilot",
|
||||
"xiaoyi": "Xiaoyi",
|
||||
"yuewen": "Yuewen",
|
||||
"zhihu": "Zhihu"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -1728,6 +1730,14 @@
|
||||
"rename": "renombrar",
|
||||
"rename_changed": "Debido a políticas de seguridad, el nombre del archivo ha cambiado de {{original}} a {{final}}",
|
||||
"save": "Guardar en notas",
|
||||
"search": {
|
||||
"both": "[to be translated]:名称+内容",
|
||||
"content": "[to be translated]:内容",
|
||||
"found_results": "[to be translated]:找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
|
||||
"more_matches": "[to be translated]:个匹配",
|
||||
"searching": "[to be translated]:搜索中...",
|
||||
"show_less": "[to be translated]:收起"
|
||||
},
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "aplicación",
|
||||
@@ -1812,6 +1822,7 @@
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "No se puede eliminar el proveedor integrado",
|
||||
"existing": "El proveedor ya existe",
|
||||
"get_providers": "Error al obtener proveedores disponibles",
|
||||
"not_found": "El proveedor de OCR no existe",
|
||||
"update_failed": "Actualización de la configuración fallida"
|
||||
},
|
||||
@@ -1875,6 +1886,8 @@
|
||||
"install_code_103": "Error al descargar el tiempo de ejecución de OVMS",
|
||||
"install_code_104": "Error al descomprimir el tiempo de ejecución de OVMS",
|
||||
"install_code_105": "Error al limpiar el tiempo de ejecución de OVMS",
|
||||
"install_code_106": "[to be translated]:创建 run.bat 失败",
|
||||
"install_code_110": "[to be translated]:清理旧 OVMS runtime 失败",
|
||||
"run": "Error al ejecutar OVMS:",
|
||||
"stop": "Error al detener OVMS:"
|
||||
},
|
||||
@@ -2571,15 +2584,14 @@
|
||||
},
|
||||
"description": "Una asistente de IA creada para los creadores",
|
||||
"downloading": "Descargando actualización...",
|
||||
"enterprise": {
|
||||
"title": "Empresa"
|
||||
},
|
||||
"feedback": {
|
||||
"button": "Enviar feedback",
|
||||
"title": "Enviar comentarios"
|
||||
},
|
||||
"label": "Acerca de nosotros",
|
||||
"license": {
|
||||
"button": "Ver",
|
||||
"title": "Licencia"
|
||||
},
|
||||
"releases": {
|
||||
"button": "Ver",
|
||||
"title": "Registro de cambios"
|
||||
@@ -2687,6 +2699,46 @@
|
||||
"title": "Exportar configuración del menú",
|
||||
"yuque": "Exportar a Yuque"
|
||||
},
|
||||
"export_to_phone": {
|
||||
"confirm": {
|
||||
"button": "[to be translated]:选择备份文件"
|
||||
},
|
||||
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||
"lan": {
|
||||
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connection_failed": "[to be translated]:Connection failed",
|
||||
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||
"error": {
|
||||
"init_failed": "[to be translated]:Initialization failed",
|
||||
"no_file": "[to be translated]:No file selected",
|
||||
"no_ip": "[to be translated]:Unable to get IP address",
|
||||
"send_failed": "[to be translated]:Failed to send file"
|
||||
},
|
||||
"force_close": "[to be translated]:Force Close",
|
||||
"generating_qr": "[to be translated]:Generating QR code...",
|
||||
"noZipSelected": "[to be translated]:未选择压缩文件",
|
||||
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||
"selectZip": "[to be translated]:选择压缩文件",
|
||||
"sendZip": "[to be translated]:开始恢复数据",
|
||||
"status": {
|
||||
"completed": "[to be translated]:Transfer completed",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connecting": "[to be translated]:Connecting...",
|
||||
"disconnected": "[to be translated]:Disconnected",
|
||||
"error": "[to be translated]:Connection error",
|
||||
"initializing": "[to be translated]:Initializing connection...",
|
||||
"preparing": "[to be translated]:Preparing transfer...",
|
||||
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||
},
|
||||
"title": "[to be translated]:局域网传输",
|
||||
"transfer_progress": "[to be translated]:Transfer progress"
|
||||
},
|
||||
"title": "[to be translated]:导出至手机"
|
||||
},
|
||||
"hour_interval_one": "{{count}} hora",
|
||||
"hour_interval_other": "{{count}} horas",
|
||||
"joplin": {
|
||||
@@ -3345,6 +3397,7 @@
|
||||
"builtinServers": "Servidores integrados",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "Una implementación de servidor MCP que integra la API de búsqueda de Brave, proporcionando funciones de búsqueda web y búsqueda local. Requiere configurar la variable de entorno BRAVE_API_KEY",
|
||||
"didi_mcp": "Servidor DiDi MCP que proporciona servicios de transporte incluyendo búsqueda de mapas, estimación de precios, gestión de pedidos y seguimiento de conductores. Disponible solo en China Continental. Requiere configurar la variable de entorno DIDI_API_KEY",
|
||||
"dify_knowledge": "Implementación del servidor MCP de Dify, que proporciona una API sencilla para interactuar con Dify. Se requiere configurar la clave de Dify.",
|
||||
"fetch": "Servidor MCP para obtener el contenido de la página web de una URL",
|
||||
"filesystem": "Servidor Node.js que implementa el protocolo de contexto del modelo (MCP) para operaciones del sistema de archivos. Requiere configuración del directorio permitido para el acceso",
|
||||
@@ -3432,6 +3485,12 @@
|
||||
"noPromptsAvailable": "No hay indicaciones disponibles",
|
||||
"requiredField": "Campo obligatorio"
|
||||
},
|
||||
"protocolInstallWarning": {
|
||||
"command": "Comando de inicio",
|
||||
"message": "Este MCP fue instalado desde una fuente externa a través del protocolo. Ejecutar herramientas desconocidas puede dañar tu computadora.",
|
||||
"run": "Correr",
|
||||
"title": "¿Ejecutar MCP externo?"
|
||||
},
|
||||
"provider": "Proveedor",
|
||||
"providerPlaceholder": "Nombre del proveedor",
|
||||
"providerUrl": "URL del proveedor",
|
||||
@@ -3978,7 +4037,8 @@
|
||||
"none": "No usar proxy",
|
||||
"system": "Proxy del sistema",
|
||||
"title": "Modo de proxy"
|
||||
}
|
||||
},
|
||||
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
|
||||
},
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "Haz clic en el icono de la bandeja para iniciar",
|
||||
@@ -4412,6 +4472,7 @@
|
||||
"later": "Más tarde",
|
||||
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
|
||||
"noReleaseNotes": "Sin notas de la versión",
|
||||
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
|
||||
"title": "Actualización"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
"context": "Effacer le contexte {{Command}}"
|
||||
},
|
||||
"new_topic": "Nouveau sujet {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "Pause",
|
||||
"placeholder": "Entrez votre message ici...",
|
||||
"placeholder_without_triggers": "Entrez votre message ici, appuyez sur {{key}} pour envoyer",
|
||||
@@ -757,6 +758,7 @@
|
||||
"clear": "Effacer",
|
||||
"close": "Fermer",
|
||||
"collapse": "Réduire",
|
||||
"completed": "[to be translated]:Completed",
|
||||
"confirm": "Confirmer",
|
||||
"copied": "Copié",
|
||||
"copy": "Copier",
|
||||
@@ -1583,13 +1585,13 @@
|
||||
"nami-ai-search": "Nami AI Search",
|
||||
"qwen": "Qwen",
|
||||
"sensechat": "SenseChat",
|
||||
"stepfun": "Stepfun",
|
||||
"tencent-yuanbao": "Yuanbao",
|
||||
"tiangong-ai": "Skywork",
|
||||
"wanzhi": "Wanzhi",
|
||||
"wenxin": "ERNIE",
|
||||
"wps-copilot": "WPS Copilot",
|
||||
"xiaoyi": "Xiaoyi",
|
||||
"yuewen": "Yuewen",
|
||||
"zhihu": "Zhihu"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -1728,6 +1730,14 @@
|
||||
"rename": "renommer",
|
||||
"rename_changed": "En raison de la politique de sécurité, le nom du fichier a été changé de {{original}} à {{final}}",
|
||||
"save": "sauvegarder dans les notes",
|
||||
"search": {
|
||||
"both": "[to be translated]:名称+内容",
|
||||
"content": "[to be translated]:内容",
|
||||
"found_results": "[to be translated]:找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
|
||||
"more_matches": "[to be translated]:个匹配",
|
||||
"searching": "[to be translated]:搜索中...",
|
||||
"show_less": "[to be translated]:收起"
|
||||
},
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "application",
|
||||
@@ -1812,6 +1822,7 @@
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré",
|
||||
"existing": "Le fournisseur existe déjà",
|
||||
"get_providers": "Échec de l'obtention des fournisseurs disponibles",
|
||||
"not_found": "Le fournisseur OCR n'existe pas",
|
||||
"update_failed": "Échec de la mise à jour de la configuration"
|
||||
},
|
||||
@@ -1875,6 +1886,8 @@
|
||||
"install_code_103": "Échec du téléchargement du runtime OVMS",
|
||||
"install_code_104": "Échec de la décompression du runtime OVMS",
|
||||
"install_code_105": "Échec du nettoyage du runtime OVMS",
|
||||
"install_code_106": "[to be translated]:创建 run.bat 失败",
|
||||
"install_code_110": "[to be translated]:清理旧 OVMS runtime 失败",
|
||||
"run": "Échec de l'exécution d'OVMS :",
|
||||
"stop": "Échec de l'arrêt d'OVMS :"
|
||||
},
|
||||
@@ -2571,15 +2584,14 @@
|
||||
},
|
||||
"description": "Un assistant IA conçu pour les créateurs",
|
||||
"downloading": "Téléchargement de la mise à jour en cours...",
|
||||
"enterprise": {
|
||||
"title": "Entreprise"
|
||||
},
|
||||
"feedback": {
|
||||
"button": "Faire un retour",
|
||||
"title": "Retour d'information"
|
||||
},
|
||||
"label": "À propos de nous",
|
||||
"license": {
|
||||
"button": "Afficher",
|
||||
"title": "Licence"
|
||||
},
|
||||
"releases": {
|
||||
"button": "Afficher",
|
||||
"title": "Journal des mises à jour"
|
||||
@@ -2687,6 +2699,46 @@
|
||||
"title": "Exporter les paramètres du menu",
|
||||
"yuque": "Exporter vers Yuque"
|
||||
},
|
||||
"export_to_phone": {
|
||||
"confirm": {
|
||||
"button": "[to be translated]:选择备份文件"
|
||||
},
|
||||
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||
"lan": {
|
||||
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connection_failed": "[to be translated]:Connection failed",
|
||||
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||
"error": {
|
||||
"init_failed": "[to be translated]:Initialization failed",
|
||||
"no_file": "[to be translated]:No file selected",
|
||||
"no_ip": "[to be translated]:Unable to get IP address",
|
||||
"send_failed": "[to be translated]:Failed to send file"
|
||||
},
|
||||
"force_close": "[to be translated]:Force Close",
|
||||
"generating_qr": "[to be translated]:Generating QR code...",
|
||||
"noZipSelected": "[to be translated]:未选择压缩文件",
|
||||
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||
"selectZip": "[to be translated]:选择压缩文件",
|
||||
"sendZip": "[to be translated]:开始恢复数据",
|
||||
"status": {
|
||||
"completed": "[to be translated]:Transfer completed",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connecting": "[to be translated]:Connecting...",
|
||||
"disconnected": "[to be translated]:Disconnected",
|
||||
"error": "[to be translated]:Connection error",
|
||||
"initializing": "[to be translated]:Initializing connection...",
|
||||
"preparing": "[to be translated]:Preparing transfer...",
|
||||
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||
},
|
||||
"title": "[to be translated]:局域网传输",
|
||||
"transfer_progress": "[to be translated]:Transfer progress"
|
||||
},
|
||||
"title": "[to be translated]:导出至手机"
|
||||
},
|
||||
"hour_interval_one": "{{count}} heure",
|
||||
"hour_interval_other": "{{count}} heures",
|
||||
"joplin": {
|
||||
@@ -3345,6 +3397,7 @@
|
||||
"builtinServers": "Serveurs intégrés",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "Une implémentation de serveur MCP intégrant l'API de recherche Brave, offrant des fonctionnalités de recherche web et locale. Nécessite la configuration de la variable d'environnement BRAVE_API_KEY",
|
||||
"didi_mcp": "Serveur DiDi MCP fournissant des services de transport incluant la recherche de cartes, l'estimation des prix, la gestion des commandes et le suivi des conducteurs. Disponible uniquement en Chine continentale. Nécessite la configuration de la variable d'environnement DIDI_API_KEY",
|
||||
"dify_knowledge": "Implémentation du serveur MCP de Dify, fournissant une API simple pour interagir avec Dify. Nécessite la configuration de la clé Dify",
|
||||
"fetch": "serveur MCP utilisé pour récupérer le contenu des pages web URL",
|
||||
"filesystem": "Serveur Node.js implémentant le protocole de contexte de modèle (MCP) pour les opérations de système de fichiers. Nécessite une configuration des répertoires autorisés à être accédés.",
|
||||
@@ -3432,6 +3485,12 @@
|
||||
"noPromptsAvailable": "Aucune invite disponible",
|
||||
"requiredField": "Champ obligatoire"
|
||||
},
|
||||
"protocolInstallWarning": {
|
||||
"command": "Commande de démarrage",
|
||||
"message": "Ce MCP a été installé depuis une source externe via le protocole. L'exécution d'outils inconnus peut endommager votre ordinateur.",
|
||||
"run": "Courir",
|
||||
"title": "Exécuter un MCP externe ?"
|
||||
},
|
||||
"provider": "Поставщик",
|
||||
"providerPlaceholder": "Название поставщика",
|
||||
"providerUrl": "Адрес поставщика",
|
||||
@@ -3978,7 +4037,8 @@
|
||||
"none": "Ne pas utiliser de proxy",
|
||||
"system": "Proxy système",
|
||||
"title": "Mode de proxy"
|
||||
}
|
||||
},
|
||||
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
|
||||
},
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "Cliquez sur l'icône dans la barre d'état système pour démarrer",
|
||||
@@ -4412,6 +4472,7 @@
|
||||
"later": "Plus tard",
|
||||
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
|
||||
"noReleaseNotes": "Aucune note de version",
|
||||
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
|
||||
"title": "Mise à jour"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
"context": "コンテキストをクリア {{Command}}"
|
||||
},
|
||||
"new_topic": "新しいトピック {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "一時停止",
|
||||
"placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
|
||||
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信してください",
|
||||
@@ -757,6 +758,7 @@
|
||||
"clear": "クリア",
|
||||
"close": "閉じる",
|
||||
"collapse": "折りたたむ",
|
||||
"completed": "[to be translated]:Completed",
|
||||
"confirm": "確認",
|
||||
"copied": "コピーされました",
|
||||
"copy": "コピー",
|
||||
@@ -1583,13 +1585,13 @@
|
||||
"nami-ai-search": "Nami AI Search",
|
||||
"qwen": "通義千問",
|
||||
"sensechat": "SenseChat",
|
||||
"stepfun": "Stepfun",
|
||||
"tencent-yuanbao": "騰訊元宝",
|
||||
"tiangong-ai": "Skywork",
|
||||
"wanzhi": "万知",
|
||||
"wenxin": "ERNIE",
|
||||
"wps-copilot": "WPS Copilot",
|
||||
"xiaoyi": "小藝",
|
||||
"yuewen": "躍問",
|
||||
"zhihu": "知乎直答"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -1728,6 +1730,14 @@
|
||||
"rename": "名前の変更",
|
||||
"rename_changed": "セキュリティポリシーにより、ファイル名は{{original}}から{{final}}に変更されました",
|
||||
"save": "メモに保存する",
|
||||
"search": {
|
||||
"both": "[to be translated]:名称+内容",
|
||||
"content": "[to be translated]:内容",
|
||||
"found_results": "[to be translated]:找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
|
||||
"more_matches": "[to be translated]:个匹配",
|
||||
"searching": "[to be translated]:搜索中...",
|
||||
"show_less": "[to be translated]:收起"
|
||||
},
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "応用",
|
||||
@@ -1812,6 +1822,7 @@
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "組み込みプロバイダーは削除できません",
|
||||
"existing": "プロバイダーはすでに存在します",
|
||||
"get_providers": "利用可能なプロバイダーの取得に失敗しました",
|
||||
"not_found": "OCRプロバイダーが存在しません",
|
||||
"update_failed": "更新構成に失敗しました"
|
||||
},
|
||||
@@ -1875,6 +1886,8 @@
|
||||
"install_code_103": "OVMSランタイムのダウンロードに失敗しました",
|
||||
"install_code_104": "OVMSランタイムの解凍に失敗しました",
|
||||
"install_code_105": "OVMSランタイムのクリーンアップに失敗しました",
|
||||
"install_code_106": "[to be translated]:创建 run.bat 失败",
|
||||
"install_code_110": "[to be translated]:清理旧 OVMS runtime 失败",
|
||||
"run": "OVMSの実行に失敗しました:",
|
||||
"stop": "OVMSの停止に失敗しました:"
|
||||
},
|
||||
@@ -2571,15 +2584,14 @@
|
||||
},
|
||||
"description": "クリエイターのための強力なAIアシスタント",
|
||||
"downloading": "ダウンロード中...",
|
||||
"enterprise": {
|
||||
"title": "エンタープライズ"
|
||||
},
|
||||
"feedback": {
|
||||
"button": "フィードバック",
|
||||
"title": "フィードバック"
|
||||
},
|
||||
"label": "について",
|
||||
"license": {
|
||||
"button": "ライセンス",
|
||||
"title": "ライセンス"
|
||||
},
|
||||
"releases": {
|
||||
"button": "リリース",
|
||||
"title": "リリースノート"
|
||||
@@ -2687,6 +2699,46 @@
|
||||
"title": "エクスポートメニュー設定",
|
||||
"yuque": "語雀にエクスポート"
|
||||
},
|
||||
"export_to_phone": {
|
||||
"confirm": {
|
||||
"button": "[to be translated]:选择备份文件"
|
||||
},
|
||||
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||
"lan": {
|
||||
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connection_failed": "[to be translated]:Connection failed",
|
||||
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||
"error": {
|
||||
"init_failed": "[to be translated]:Initialization failed",
|
||||
"no_file": "[to be translated]:No file selected",
|
||||
"no_ip": "[to be translated]:Unable to get IP address",
|
||||
"send_failed": "[to be translated]:Failed to send file"
|
||||
},
|
||||
"force_close": "[to be translated]:Force Close",
|
||||
"generating_qr": "[to be translated]:Generating QR code...",
|
||||
"noZipSelected": "[to be translated]:未选择压缩文件",
|
||||
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||
"selectZip": "[to be translated]:选择压缩文件",
|
||||
"sendZip": "[to be translated]:开始恢复数据",
|
||||
"status": {
|
||||
"completed": "[to be translated]:Transfer completed",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connecting": "[to be translated]:Connecting...",
|
||||
"disconnected": "[to be translated]:Disconnected",
|
||||
"error": "[to be translated]:Connection error",
|
||||
"initializing": "[to be translated]:Initializing connection...",
|
||||
"preparing": "[to be translated]:Preparing transfer...",
|
||||
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||
},
|
||||
"title": "[to be translated]:局域网传输",
|
||||
"transfer_progress": "[to be translated]:Transfer progress"
|
||||
},
|
||||
"title": "[to be translated]:导出至手机"
|
||||
},
|
||||
"hour_interval_one": "{{count}} 時間",
|
||||
"hour_interval_other": "{{count}} 時間",
|
||||
"joplin": {
|
||||
@@ -3345,6 +3397,7 @@
|
||||
"builtinServers": "組み込みサーバー",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です",
|
||||
"didi_mcp": "DiDi MCPサーバーは、地図検索、料金見積もり、注文管理、ドライバー追跡を含むライドシェアサービスを提供します。中国本土でのみ利用可能です。DIDI_API_KEY環境変数の設定が必要です",
|
||||
"dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。",
|
||||
"fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー",
|
||||
"filesystem": "Node.jsサーバーによるファイルシステム操作を実現するモデルコンテキストプロトコル(MCP)。アクセスを許可するディレクトリの設定が必要です",
|
||||
@@ -3432,6 +3485,12 @@
|
||||
"noPromptsAvailable": "利用可能なプロンプトはありません",
|
||||
"requiredField": "必須フィールド"
|
||||
},
|
||||
"protocolInstallWarning": {
|
||||
"command": "起動コマンド",
|
||||
"message": "このMCPは外部ソースからプロトコル経由でインストールされました。不明なツールを実行すると、コンピューターに危害を及ぼす可能性があります。",
|
||||
"run": "走る",
|
||||
"title": "外部のMCPを実行しますか?"
|
||||
},
|
||||
"provider": "プロバイダー",
|
||||
"providerPlaceholder": "プロバイダー名",
|
||||
"providerUrl": "プロバイダーURL",
|
||||
@@ -3978,7 +4037,8 @@
|
||||
"none": "プロキシを使用しない",
|
||||
"system": "システムプロキシ",
|
||||
"title": "プロキシモード"
|
||||
}
|
||||
},
|
||||
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
|
||||
},
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "トレイアイコンをクリックして起動",
|
||||
@@ -4412,6 +4472,7 @@
|
||||
"later": "後で",
|
||||
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
|
||||
"noReleaseNotes": "暫無更新日誌",
|
||||
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
|
||||
"title": "更新"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
"context": "Limpar contexto {{Command}}"
|
||||
},
|
||||
"new_topic": "Novo tópico {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "Pausar",
|
||||
"placeholder": "Digite sua mensagem aqui...",
|
||||
"placeholder_without_triggers": "Digite a mensagem aqui, pressione {{key}} para enviar",
|
||||
@@ -757,6 +758,7 @@
|
||||
"clear": "Limpar",
|
||||
"close": "Fechar",
|
||||
"collapse": "Recolher",
|
||||
"completed": "[to be translated]:Completed",
|
||||
"confirm": "Confirmar",
|
||||
"copied": "Copiado",
|
||||
"copy": "Copiar",
|
||||
@@ -1583,13 +1585,13 @@
|
||||
"nami-ai-search": "Nami AI Search",
|
||||
"qwen": "Qwen",
|
||||
"sensechat": "SenseChat",
|
||||
"stepfun": "Stepfun",
|
||||
"tencent-yuanbao": "Yuanbao",
|
||||
"tiangong-ai": "Skywork",
|
||||
"wanzhi": "Wanzhi",
|
||||
"wenxin": "ERNIE",
|
||||
"wps-copilot": "WPS Copilot",
|
||||
"xiaoyi": "Xiaoyi",
|
||||
"yuewen": "Yuewen",
|
||||
"zhihu": "Zhihu"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -1728,6 +1730,14 @@
|
||||
"rename": "renomear",
|
||||
"rename_changed": "Devido às políticas de segurança, o nome do arquivo foi alterado de {{original}} para {{final}}",
|
||||
"save": "salvar em notas",
|
||||
"search": {
|
||||
"both": "[to be translated]:名称+内容",
|
||||
"content": "[to be translated]:内容",
|
||||
"found_results": "[to be translated]:找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
|
||||
"more_matches": "[to be translated]:个匹配",
|
||||
"searching": "[to be translated]:搜索中...",
|
||||
"show_less": "[to be translated]:收起"
|
||||
},
|
||||
"settings": {
|
||||
"data": {
|
||||
"apply": "aplicativo",
|
||||
@@ -1812,6 +1822,7 @@
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Não é possível excluir o provedor integrado",
|
||||
"existing": "O provedor já existe",
|
||||
"get_providers": "Falha ao obter provedores disponíveis",
|
||||
"not_found": "O provedor OCR não existe",
|
||||
"update_failed": "Falha ao atualizar a configuração"
|
||||
},
|
||||
@@ -1875,6 +1886,8 @@
|
||||
"install_code_103": "Falha ao baixar o tempo de execução do OVMS",
|
||||
"install_code_104": "Falha ao descompactar o tempo de execução do OVMS",
|
||||
"install_code_105": "Falha ao limpar o tempo de execução do OVMS",
|
||||
"install_code_106": "[to be translated]:创建 run.bat 失败",
|
||||
"install_code_110": "[to be translated]:清理旧 OVMS runtime 失败",
|
||||
"run": "Falha ao executar o OVMS:",
|
||||
"stop": "Falha ao parar o OVMS:"
|
||||
},
|
||||
@@ -2571,15 +2584,14 @@
|
||||
},
|
||||
"description": "Um assistente de IA criado para criadores",
|
||||
"downloading": "Baixando atualizações...",
|
||||
"enterprise": {
|
||||
"title": "Empresa"
|
||||
},
|
||||
"feedback": {
|
||||
"button": "Feedback",
|
||||
"title": "Enviar feedback"
|
||||
},
|
||||
"label": "Sobre Nós",
|
||||
"license": {
|
||||
"button": "Ver",
|
||||
"title": "Licença"
|
||||
},
|
||||
"releases": {
|
||||
"button": "Ver",
|
||||
"title": "Registro de alterações"
|
||||
@@ -2687,6 +2699,46 @@
|
||||
"title": "Exportar Configurações do Menu",
|
||||
"yuque": "Exportar para Yuque"
|
||||
},
|
||||
"export_to_phone": {
|
||||
"confirm": {
|
||||
"button": "[to be translated]:选择备份文件"
|
||||
},
|
||||
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||
"lan": {
|
||||
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connection_failed": "[to be translated]:Connection failed",
|
||||
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||
"error": {
|
||||
"init_failed": "[to be translated]:Initialization failed",
|
||||
"no_file": "[to be translated]:No file selected",
|
||||
"no_ip": "[to be translated]:Unable to get IP address",
|
||||
"send_failed": "[to be translated]:Failed to send file"
|
||||
},
|
||||
"force_close": "[to be translated]:Force Close",
|
||||
"generating_qr": "[to be translated]:Generating QR code...",
|
||||
"noZipSelected": "[to be translated]:未选择压缩文件",
|
||||
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||
"selectZip": "[to be translated]:选择压缩文件",
|
||||
"sendZip": "[to be translated]:开始恢复数据",
|
||||
"status": {
|
||||
"completed": "[to be translated]:Transfer completed",
|
||||
"connected": "[to be translated]:Connected",
|
||||
"connecting": "[to be translated]:Connecting...",
|
||||
"disconnected": "[to be translated]:Disconnected",
|
||||
"error": "[to be translated]:Connection error",
|
||||
"initializing": "[to be translated]:Initializing connection...",
|
||||
"preparing": "[to be translated]:Preparing transfer...",
|
||||
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||
},
|
||||
"title": "[to be translated]:局域网传输",
|
||||
"transfer_progress": "[to be translated]:Transfer progress"
|
||||
},
|
||||
"title": "[to be translated]:导出至手机"
|
||||
},
|
||||
"hour_interval_one": "{{count}} hora",
|
||||
"hour_interval_other": "{{count}} horas",
|
||||
"joplin": {
|
||||
@@ -3345,6 +3397,7 @@
|
||||
"builtinServers": "Servidores integrados",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "uma implementação de servidor MCP integrada com a API de pesquisa Brave, fornecendo funcionalidades de pesquisa web e local. Requer a configuração da variável de ambiente BRAVE_API_KEY",
|
||||
"didi_mcp": "Servidor DiDi MCP que fornece serviços de transporte incluindo pesquisa de mapas, estimativa de preços, gestão de pedidos e rastreamento de motoristas. Disponível apenas na China Continental. Requer configuração da variável de ambiente DIDI_API_KEY",
|
||||
"dify_knowledge": "Implementação do servidor MCP do Dify, que fornece uma API simples para interagir com o Dify. Requer a configuração da chave Dify",
|
||||
"fetch": "servidor MCP para obter o conteúdo da página web do URL",
|
||||
"filesystem": "Servidor Node.js do protocolo de contexto de modelo (MCP) para implementar operações de sistema de ficheiros. Requer configuração do diretório permitido para acesso",
|
||||
@@ -3432,6 +3485,12 @@
|
||||
"noPromptsAvailable": "Nenhuma dica disponível",
|
||||
"requiredField": "Campo obrigatório"
|
||||
},
|
||||
"protocolInstallWarning": {
|
||||
"command": "Comando de inicialização",
|
||||
"message": "Este MCP foi instalado a partir de uma fonte externa via protocolo. Executar ferramentas desconhecidas pode prejudicar seu computador.",
|
||||
"run": "Correr",
|
||||
"title": "Executar MCP externo?"
|
||||
},
|
||||
"provider": "Fornecedor",
|
||||
"providerPlaceholder": "Nome do Fornecedor",
|
||||
"providerUrl": "URL do Fornecedor",
|
||||
@@ -3978,7 +4037,8 @@
|
||||
"none": "Não Usar Proxy",
|
||||
"system": "Proxy do Sistema",
|
||||
"title": "Modo de Proxy"
|
||||
}
|
||||
},
|
||||
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
|
||||
},
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "Clique no ícone da bandeja para iniciar",
|
||||
@@ -4412,6 +4472,7 @@
|
||||
"later": "Mais tarde",
|
||||
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
|
||||
"noReleaseNotes": "Sem notas de versão",
|
||||
"saveDataError": "Falha ao salvar os dados, tente novamente",
|
||||
"title": "Atualização"
|
||||
},
|
||||
"warning": {
|
||||
|
||||