Compare commits

..

2 Commits

Author SHA1 Message Date
GeorgeDong32
42ee8a68ce feat(markdown): add image export controls and hook into export logic 2025-10-24 17:39:11 +08:00
GeorgeDong32
d521a88d30 feat(export): add image export options to markdown settings 2025-10-24 17:39:11 +08:00
146 changed files with 1943 additions and 9466 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,5 +1,4 @@
/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

160
.github/pr-modules.yml vendored
View File

@@ -1,160 +0,0 @@
# 模块 → 路径匹配globs与 GitHub 审核人列表
# 多模块命中时取优先级最高的为主类,其余在卡片中显示“涉及模块”
categories:
ai_core:
name: "AI Core"
globs:
- "packages/aiCore/**"
- "src/renderer/src/aiCore/**"
github_reviewers: ["DeJeune", "MyPrototypeWhat", "Vaayne"]
agent:
name: "Agent"
globs:
- "packages/shared/agents/**"
- "resources/data/agents-*.json"
- "src/renderer/src/api/agent.ts"
- "src/renderer/src/types/agent.ts"
- "src/renderer/src/utils/agentSession.ts"
- "src/renderer/src/services/db/AgentMessageDataSource.ts"
- "src/renderer/src/hooks/agents/**"
- "src/renderer/src/components/Popups/agent/**"
- "src/renderer/src/pages/home/**/Agent*.tsx"
- "src/renderer/src/pages/settings/AgentSettings/**"
- "src/main/services/agents/**"
- "src/main/apiServer/routes/agents/**"
github_reviewers: ["EurFelux", "Vaayne", "DeJeune"]
provider:
name: "Provider"
globs:
- "src/renderer/src/config/providers.ts"
- "src/renderer/src/config/preprocessProviders.ts"
- "src/renderer/src/config/webSearchProviders.ts"
- "src/renderer/src/hooks/useWebSearchProviders.ts"
- "src/renderer/src/providers/**"
- "src/renderer/src/pages/settings/ProviderSettings/**"
- "src/renderer/src/pages/settings/WebSearchSettings/**"
- "src/renderer/src/pages/settings/DocProcessSettings/PreprocessProviderSettings.tsx"
- "src/renderer/src/pages/settings/MCPSettings/providers/**"
- "src/renderer/src/assets/images/providers/**"
github_reviewers: ["YinsenHo", "kangfenmao", "alephpiece"]
backend:
name: "后端/平台"
globs:
- "src/main/apiServer/**"
- "src/main/services/**"
- "src/main/*.ts"
- "src/preload/**"
- "src/main/mcpServers/**"
github_reviewers: ["beyondkmp", "Vaayne", "kangfenmao"]
knowledge:
name: "知识库"
globs:
- "src/main/knowledge/**"
- "src/renderer/src/pages/knowledge/**"
- "src/renderer/src/store/knowledge.ts"
- "src/renderer/src/queue/KnowledgeQueue.ts"
github_reviewers: ["eeee0717", "alephpiece", "GeorgeDong32"]
data_storage:
name: "数据与存储"
globs:
- "src/renderer/src/databases/**"
- "src/renderer/src/services/db/**"
- "src/main/services/agents/database/**"
- "resources/database/drizzle/**"
- "src/renderer/src/store/migrate.ts"
- "src/renderer/src/databases/upgrades.ts"
github_reviewers: ["0xfullex", "kangfenmao", "Vaayne", "DeJeune"]
backup_export:
name: "备份/导出"
globs:
- "src/renderer/src/components/*Backup*"
- "src/renderer/src/components/Webdav*"
- "src/renderer/src/components/ObsidianExportDialog.tsx"
- "src/renderer/src/components/S3*"
- "src/renderer/src/store/backup.ts"
- "src/renderer/src/store/nutstore.ts"
- "src/renderer/src/pages/settings/DataSettings/**"
github_reviewers: ["beyondkmp", "GeorgeDong32"]
minapps:
name: "小程序"
globs:
- "src/renderer/src/pages/minapps/**"
- "src/renderer/src/store/minapps.ts"
- "src/renderer/src/config/minapps.ts"
github_reviewers: ["GeorgeDong32", "beyondkmp"]
chat:
name: "对话"
globs:
- "src/renderer/src/pages/home/**"
- "src/renderer/src/store/newMessage.ts"
- "src/renderer/src/store/messageBlock.ts"
- "src/renderer/src/store/memory.ts"
- "src/renderer/src/store/llm.ts"
github_reviewers: ["kangfenmao", "alephpiece", "EurFelux"]
draw:
name: "绘图"
globs:
- "src/renderer/src/pages/paintings/**"
- "src/renderer/src/store/paintings.ts"
github_reviewers: ["EurFelux", "DeJeune"]
uiux:
name: "UI/UX"
globs:
- "src/renderer/src/components/**"
- "src/renderer/src/ui/**"
- "src/renderer/src/assets/styles/**"
- "src/renderer/src/windows/**"
github_reviewers: ["kangfenmao", "MyPrototypeWhat", "alephpiece"]
build-config:
name: "构建/配置"
globs:
- "package.json"
- "tsconfig*.json"
- "electron-builder.yml"
- "electron.vite.config.ts"
- "vitest.config.ts"
- "playwright.config.ts"
- ".github/workflows/**"
- "scripts/**"
github_reviewers: ["kangfenmao", "beyondkmp", "alephpiece"]
test:
name: "测试"
globs:
- "tests/**"
- "src/**/__tests__/**"
- "scripts/__tests__/**"
github_reviewers: ["alephpiece", "DeJeune", "EurFelux"]
docs:
name: "文档"
globs:
- "docs/**"
- "README*.md"
- "SECURITY.md"
- "CODE_OF_CONDUCT.md"
- "AGENTS.md"
github_reviewers: ["kangfenmao", "0xfullex", "EurFelux"]
rules:
vendor_added:
# 新增供应商时的强制审核人
github_reviewers: ["YinsenHo"]
large_change:
# 重大变更阈值(改动文件数 > changed_files_gt 触发)
changed_files_gt: 30
github_reviewers: ["kangfenmao"]

View File

@@ -3,18 +3,6 @@
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:

View File

@@ -1,455 +0,0 @@
{
"generatedAt": "2025-10-29T06:19:19.098Z",
"suggestions": {
"ai_core": [
{
"github": "SuYao",
"name": "SuYao",
"email": "sy20010504@gmail.com",
"commits": 33
},
{
"github": "MyPrototypeWhat",
"name": "MyPrototypeWhat",
"email": "daoquqiexing@gmail.com",
"commits": 12
},
{
"github": "Vaayne",
"name": "Vaayne",
"email": "liu.vaayne@gmail.com",
"commits": 10
},
{
"github": "EurFelux",
"name": "Phantom",
"email": "59059173+EurFelux@users.noreply.github.com",
"commits": 9
},
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 6
}
],
"agent": [
{
"github": "icarus",
"name": "icarus",
"email": "eurfelux@gmail.com",
"commits": 152
},
{
"github": "Vaayne",
"name": "Vaayne",
"email": "liu.vaayne@gmail.com",
"commits": 80
},
{
"github": "suyao",
"name": "suyao",
"email": "sy20010504@gmail.com",
"commits": 29
},
{
"github": "defi-failure",
"name": "defi-failure",
"email": "159208748+defi-failure@users.noreply.github.com",
"commits": 8
},
{
"github": "Phantom",
"name": "Phantom",
"email": "eurfelux@gmail.com",
"commits": 5
}
],
"provider": [
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 53
},
{
"github": "one",
"name": "one",
"email": "wangan.cs@gmail.com",
"commits": 32
},
{
"github": "EurFelux",
"name": "Phantom",
"email": "59059173+EurFelux@users.noreply.github.com",
"commits": 30
},
{
"github": "SuYao",
"name": "SuYao",
"email": "sy20010504@gmail.com",
"commits": 14
},
{
"github": "eeee0717",
"name": "Chen Tao",
"email": "70054568+eeee0717@users.noreply.github.com",
"commits": 10
}
],
"backend": [
{
"github": "beyondkmp",
"name": "beyondkmp",
"email": "beyondkmp@gmail.com",
"commits": 99
},
{
"github": "Vaayne",
"name": "Vaayne",
"email": "liu.vaayne@gmail.com",
"commits": 96
},
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 84
},
{
"github": "0xfullex",
"name": "fullex",
"email": "106392080+0xfullex@users.noreply.github.com",
"commits": 49
},
{
"github": "vaayne",
"name": "LiuVaayne",
"email": "10231735+vaayne@users.noreply.github.com",
"commits": 33
}
],
"knowledge": [
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 20
},
{
"github": "eeee0717",
"name": "Chen Tao",
"email": "70054568+eeee0717@users.noreply.github.com",
"commits": 13
},
{
"github": "one",
"name": "one",
"email": "wangan.cs@gmail.com",
"commits": 8
},
{
"github": "EurFelux",
"name": "Phantom",
"email": "59059173+EurFelux@users.noreply.github.com",
"commits": 6
},
{
"github": "beyondkmp",
"name": "beyondkmp",
"email": "beyondkmp@gmail.com",
"commits": 5
}
],
"data_storage": [
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 63
},
{
"github": "Vaayne",
"name": "Vaayne",
"email": "liu.vaayne@gmail.com",
"commits": 21
},
{
"github": "SuYao",
"name": "SuYao",
"email": "sy20010504@gmail.com",
"commits": 20
},
{
"github": "EurFelux",
"name": "Phantom",
"email": "59059173+EurFelux@users.noreply.github.com",
"commits": 17
},
{
"github": "suyao",
"name": "suyao",
"email": "sy20010504@gmail.com",
"commits": 13
}
],
"backup_export": [
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 23
},
{
"github": "beyondkmp",
"name": "beyondkmp",
"email": "beyondkmp@gmail.com",
"commits": 12
},
{
"github": "GeorgeDong32",
"name": "George·Dong",
"email": "98630204+GeorgeDong32@users.noreply.github.com",
"commits": 9
},
{
"github": "one",
"name": "one",
"email": "wangan.cs@gmail.com",
"commits": 5
},
{
"github": "0xfullex",
"name": "fullex",
"email": "106392080+0xfullex@users.noreply.github.com",
"commits": 5
}
],
"minapps": [
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 12
},
{
"github": "beyondkmp",
"name": "beyondkmp",
"email": "beyondkmp@gmail.com",
"commits": 5
},
{
"github": "GeorgeDong32",
"name": "George·Dong",
"email": "98630204+GeorgeDong32@users.noreply.github.com",
"commits": 4
},
{
"github": "EurFelux",
"name": "Phantom",
"email": "59059173+EurFelux@users.noreply.github.com",
"commits": 4
},
{
"github": "0xfullex",
"name": "fullex",
"email": "106392080+0xfullex@users.noreply.github.com",
"commits": 3
}
],
"chat": [
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 189
},
{
"github": "one",
"name": "one",
"email": "wangan.cs@gmail.com",
"commits": 86
},
{
"github": "icarus",
"name": "icarus",
"email": "eurfelux@gmail.com",
"commits": 85
},
{
"github": "EurFelux",
"name": "Phantom",
"email": "59059173+EurFelux@users.noreply.github.com",
"commits": 52
},
{
"github": "SuYao",
"name": "SuYao",
"email": "sy20010504@gmail.com",
"commits": 48
}
],
"draw": [
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 8
},
{
"github": "jin-wang-c",
"name": "Caelan",
"email": "79105826+jin-wang-c@users.noreply.github.com",
"commits": 7
},
{
"github": "DDU1222",
"name": "chenxue",
"email": "DDU1222@users.noreply.github.com",
"commits": 6
},
{
"github": "EurFelux",
"name": "Phantom",
"email": "59059173+EurFelux@users.noreply.github.com",
"commits": 5
},
{
"github": "0xfullex",
"name": "fullex",
"email": "106392080+0xfullex@users.noreply.github.com",
"commits": 4
}
],
"uiux": [
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 109
},
{
"github": "one",
"name": "one",
"email": "wangan.cs@gmail.com",
"commits": 89
},
{
"github": "icarus",
"name": "icarus",
"email": "eurfelux@gmail.com",
"commits": 35
},
{
"github": "EurFelux",
"name": "Phantom",
"email": "59059173+EurFelux@users.noreply.github.com",
"commits": 32
},
{
"github": "0xfullex",
"name": "fullex",
"email": "106392080+0xfullex@users.noreply.github.com",
"commits": 24
}
],
"build-config": [
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 170
},
{
"github": "beyondkmp",
"name": "beyondkmp",
"email": "beyondkmp@gmail.com",
"commits": 65
},
{
"github": "one",
"name": "one",
"email": "wangan.cs@gmail.com",
"commits": 40
},
{
"github": "EurFelux",
"name": "Phantom",
"email": "59059173+EurFelux@users.noreply.github.com",
"commits": 34
},
{
"github": "SuYao",
"name": "SuYao",
"email": "sy20010504@gmail.com",
"commits": 32
}
],
"test": [
{
"github": "one",
"name": "one",
"email": "wangan.cs@gmail.com",
"commits": 45
},
{
"github": "SuYao",
"name": "SuYao",
"email": "sy20010504@gmail.com",
"commits": 27
},
{
"github": "EurFelux",
"name": "Phantom",
"email": "59059173+EurFelux@users.noreply.github.com",
"commits": 20
},
{
"github": "Vaayne",
"name": "Vaayne",
"email": "liu.vaayne@gmail.com",
"commits": 19
},
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 18
}
],
"docs": [
{
"github": "kangfenmao",
"name": "kangfenmao",
"email": "kangfenmao@qq.com",
"commits": 18
},
{
"github": "0xfullex",
"name": "fullex",
"email": "106392080+0xfullex@users.noreply.github.com",
"commits": 7
},
{
"github": "EurFelux",
"name": "Phantom",
"email": "59059173+EurFelux@users.noreply.github.com",
"commits": 6
},
{
"github": "one",
"name": "one",
"email": "wangan.cs@gmail.com",
"commits": 6
},
{
"github": "sunrise0o0",
"name": "牡丹凤凰",
"email": "87239270+sunrise0o0@users.noreply.github.com",
"commits": 4
}
],
"vendor_added": [],
"large_change": []
}
}

View File

@@ -4,7 +4,6 @@ env:
TRANSLATION_API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
TRANSLATION_MODEL: ${{ vars.AUTO_I18N_MODEL || 'deepseek/deepseek-v3.1'}}
TRANSLATION_BASE_URL: ${{ vars.AUTO_I18N_BASE_URL || 'https://api.ppinfra.com/openai'}}
TRANSLATION_BASE_LOCALE: ${{ vars.AUTO_I18N_BASE_LOCALE || 'en-us'}}
on:
pull_request:
@@ -30,7 +29,6 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: 20
package-manager-cache: false
- name: 📦 Install dependencies in isolated directory
run: |

View File

@@ -1,285 +0,0 @@
name: GitHub PR Tracker with Feishu Notification
on:
pull_request:
types: [opened, ready_for_review, review_requested, reopened]
schedule:
# Run every day at 8:30 Beijing Time (00:30 UTC)
- cron: '30 0 * * *'
workflow_dispatch:
jobs:
process-new-pr:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check PR conditions
id: check_pr
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
// Check if PR is draft
if (pr.draft) {
console.log('⏭️ PR is in draft state, skipping notification');
core.setOutput('should_notify', 'false');
core.setOutput('skip_reason', 'draft');
return;
}
// We will notify regardless of whether reviewers/assignees are set
console.log('✅ PR meets notification criteria');
core.setOutput('should_notify', 'true');
// Prepare reviewer and assignee lists
const reviewers = (pr.requested_reviewers || []).map(r => r.login).join(',');
const assignees = (pr.assignees || []).map(a => a.login).join(',');
core.setOutput('reviewers', reviewers);
core.setOutput('assignees', assignees);
- name: Check Beijing Time
if: steps.check_pr.outputs.should_notify == 'true'
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 "⏰ PR 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 "✅ PR created during active hours, will notify immediately"
fi
- name: Add pending label if in quiet hours
if: steps.check_pr.outputs.should_notify == 'true' && steps.check_time.outputs.should_delay == 'true'
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: ['pending-feishu-pr-notification']
});
- name: Setup Node.js
if: steps.check_pr.outputs.should_notify == 'true' && steps.check_time.outputs.should_delay == 'false'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Process PR with Claude
if: steps.check_pr.outputs.should_notify == 'true' && 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 pr:*),Bash(node scripts/feishu-pr-notify.js)"
prompt: |
你是一个GitHub Pull Request自动化处理助手。请完成以下任务
## 当前PR信息
- PR编号#${{ github.event.pull_request.number }}
- 标题:${{ github.event.pull_request.title }}
- 作者:${{ github.event.pull_request.user.login }}
- URL${{ github.event.pull_request.html_url }}
- 内容:${{ github.event.pull_request.body }}
- 标签:${{ join(github.event.pull_request.labels.*.name, ', ') }}
- 改动文件数:${{ github.event.pull_request.changed_files }}
- 增加行数:${{ github.event.pull_request.additions }}
- 删除行数:${{ github.event.pull_request.deletions }}
- Reviewers${{ steps.check_pr.outputs.reviewers }}
- Assignees${{ steps.check_pr.outputs.assignees }}
## 任务步骤
1. **分析PR改动内容**
首先使用以下命令获取PR的文件变更列表
```bash
gh pr view ${{ github.event.pull_request.number }} --json files --jq '.files[].path'
```
2. **分类PR内容**
根据改动的文件路径和PR标题、描述判断PR的类型若命中多个模块则输出 `multiple`,若均未命中则 `other`
- chat对话: src/renderer/src/pages/home/**, src/renderer/src/store/(newMessage|messageBlock|memory).ts
- draw绘图: src/renderer/src/pages/paintings/**, src/renderer/src/store/paintings.ts
- uiuxUI/UX: src/renderer/src/components/**, src/renderer/src/ui/**, src/renderer/src/assets/styles/**, src/renderer/src/windows/**
- knowledge知识库: src/main/knowledge/**, src/renderer/src/pages/knowledge/**, src/renderer/src/store/knowledge.ts, src/renderer/src/queue/KnowledgeQueue.ts
- minapps小程序: src/renderer/src/pages/minapps/**, src/renderer/src/store/minapps.ts, src/renderer/src/config/minapps.ts
- backup_export备份/导出): src/renderer/src/components/*Backup*, src/renderer/src/components/Webdav*, src/renderer/src/components/ObsidianExportDialog.tsx, src/renderer/src/components/S3*, src/renderer/src/store/(backup|nutstore).ts
- data_storage数据与存储: src/renderer/src/databases/**, src/renderer/src/services/db/**, src/main/services/agents/database/**, resources/database/drizzle/**, src/renderer/src/store/migrate.ts, src/renderer/src/databases/upgrades.ts
- ai_coreAI基础设施: packages/aiCore/**, src/renderer/src/aiCore/**
- backend后端/平台): src/main/apiServer/**, src/main/services/**, src/main/*.ts, src/preload/**, src/main/mcpServers/**
- agentAgent: packages/shared/agents/**, resources/data/agents-*.json, src/renderer/src/api/agent.ts, src/renderer/src/types/agent.ts, src/renderer/src/utils/agentSession.ts, src/renderer/src/services/db/AgentMessageDataSource.ts, src/renderer/src/hooks/agents/**, src/renderer/src/components/Popups/agent/**, src/renderer/src/pages/home/**/Agent*.tsx, src/renderer/src/pages/settings/AgentSettings/**, src/main/services/agents/**, src/main/apiServer/routes/agents/**
- providerProvider: src/renderer/src/config/(providers|preprocessProviders|webSearchProviders).ts, src/renderer/src/hooks/useWebSearchProviders.ts, src/renderer/src/providers/**, src/renderer/src/pages/settings/ProviderSettings/**, src/renderer/src/pages/settings/WebSearchSettings/**, src/renderer/src/pages/settings/DocProcessSettings/(OcrProviderSettings|PreprocessProviderSettings).tsx, src/renderer/src/pages/settings/MCPSettings/providers/**, src/renderer/src/assets/images/providers/**, src/main/services/urlschema/handle-providers.ts
- build-config构建/配置): package.json, tsconfig*.json, electron-builder.yml, electron.vite.config.ts, vitest.config.ts, playwright.config.ts, .github/workflows/**, scripts/**
- test测试: tests/**, src/**/__tests__/**, scripts/__tests__/**
- docs文档: docs/**, README*.md, SECURITY.md, CODE_OF_CONDUCT.md, AGENTS.md
2.1 **识别是否“新增供应商”**
满足以下任一条件则视为“新增供应商”并设置变量 PR_VENDOR_ADDED=true否则为 false
- 改动文件包含:
- src/renderer/src/config/providers.ts
- src/renderer/src/providers/**
- packages/aiCore/**/provider/** 或 packages/aiCore/src/provider/**
- resources/data/agents-*.json
- 或 PR 标题/描述包含关键词:"供应商"、"厂商"、"provider"、"新增"、"集成"
3. **总结PR**
用中文简体提供简洁的总结2-3句话包括
- PR的主要改动内容
- 核心功能或修复
- 重要的技术细节或影响范围
4. **发送飞书通知**
使用以下命令发送飞书通知:
```bash
PR_URL="${{ github.event.pull_request.html_url }}" \
PR_NUMBER="${{ github.event.pull_request.number }}" \
PR_TITLE="${{ github.event.pull_request.title }}" \
PR_AUTHOR="${{ github.event.pull_request.user.login }}" \
PR_LABELS="${{ join(github.event.pull_request.labels.*.name, ',') }}" \
PR_SUMMARY="<你生成的中文总结>" \
PR_REVIEWERS="${{ steps.check_pr.outputs.reviewers }}" \
PR_ASSIGNEES="${{ steps.check_pr.outputs.assignees }}" \
PR_CATEGORY="<你判断的PR类型>" \
PR_VENDOR_ADDED="<true 或 false>" \
PR_CHANGED_FILES="${{ github.event.pull_request.changed_files }}" \
PR_ADDITIONS="${{ github.event.pull_request.additions }}" \
PR_DELETIONS="${{ github.event.pull_request.deletions }}" \
node scripts/feishu-pr-notify.js
```
## 注意事项
- 总结必须使用简体中文
- PR_SUMMARY 和其他参数在传递时需要正确转义特殊字符
- PR_CATEGORY 必须是上述定义的类型之一
- 如果PR内容为空也要提供一个简短的说明
请开始执行任务!
env:
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
FEISHU_USER_MAPPING: ${{ secrets.FEISHU_USER_MAPPING }}
process-pending-prs:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
pull-requests: 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 PRs 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 pr:*),Bash(gh api:*),Bash(node scripts/feishu-pr-notify.js)"
prompt: |
你是一个GitHub Pull Request自动化处理助手。请完成以下任务
## 任务说明
处理所有待发送飞书通知的GitHub PRs标记为 `pending-feishu-pr-notification` 的PRs
## 步骤
1. **获取待处理的PRs**
使用以下命令获取所有带 `pending-feishu-pr-notification` 标签的PRs
```bash
gh api repos/${{ github.repository }}/pulls?state=open | jq '.[] | select(.labels[]?.name == "pending-feishu-pr-notification")'
```
2. **验证PR条件**
对于每个PR检查
- 是否仍然不是draft状态
- 是否有reviewers或assignees
- 如果不满足条件,移除标签并跳过
3. **分析和分类PR**
获取PR的文件变更
```bash
gh pr view <PR编号> --json files --jq '.files[].path'
```
根据文件路径判断PR类型chat/draw/uiux/knowledge/minapps/backup_export/data_storage/ai_core/backend/agent/provider/docs/build-config/test/multiple/other
4. **总结每个PR**
用中文提供简洁的总结2-3句话包括
- PR的主要改动内容
- 核心功能或修复
- 重要的技术细节
5. **发送飞书通知**
使用以下命令发送通知:
```bash
PR_URL="<PR的html_url>" \
PR_NUMBER="<PR编号>" \
PR_TITLE="<PR标题>" \
PR_AUTHOR="<PR作者>" \
PR_LABELS="<逗号分隔的标签列表排除pending-feishu-pr-notification>" \
PR_SUMMARY="<你生成的中文总结>" \
PR_REVIEWERS="<reviewers列表逗号分隔>" \
PR_ASSIGNEES="<assignees列表逗号分隔>" \
PR_CATEGORY="<PR类型>" \
PR_VENDOR_ADDED="<true 或 false>" \
PR_CHANGED_FILES="<改动文件数>" \
PR_ADDITIONS="<增加行数>" \
PR_DELETIONS="<删除行数>" \
node scripts/feishu-pr-notify.js
```
6. **移除标签**
成功发送后,移除标签:
```bash
gh api -X DELETE repos/${{ github.repository }}/issues/<PR编号>/labels/pending-feishu-pr-notification
```
## 环境变量
- Repository: ${{ github.repository }}
- Feishu webhook URL和密钥已配置
## 注意事项
- 如果没有待处理的PRs输出提示后结束
- 处理多个PRs时每个之间等待2-3秒
- 某个PR失败不中断整个流程
- 所有总结使用简体中文
请开始执行任务!
env:
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
FEISHU_USER_MAPPING: ${{ secrets.FEISHU_USER_MAPPING }}

View File

@@ -29,10 +29,8 @@ 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 }} 天没有任何活动,将立即关闭。
@@ -48,8 +46,6 @@ 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 }} 天后将自动关闭。

View File

@@ -0,0 +1,13 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId?.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts

View File

@@ -1,26 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 4cc66d83af1cef39f6447dc62e680251e05ddf9f..eb9819cb674c1808845ceb29936196c4bb355172 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts
diff --git a/dist/index.mjs b/dist/index.mjs
index a032505ec54e132dc386dde001dc51f710f84c83..5efada51b9a8b56e3f01b35e734908ebe3c37043 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts

View File

@@ -1,131 +0,0 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index b3f018730a93639aad7c203f15fb1aeb766c73f4..ade2a43d66e9184799d072153df61ef7be4ea110 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -296,7 +296,14 @@ var HuggingFaceResponsesLanguageModel = class {
metadata: huggingfaceOptions == null ? void 0 : huggingfaceOptions.metadata,
instructions: huggingfaceOptions == null ? void 0 : huggingfaceOptions.instructions,
...preparedTools && { tools: preparedTools },
- ...preparedToolChoice && { tool_choice: preparedToolChoice }
+ ...preparedToolChoice && { tool_choice: preparedToolChoice },
+ ...(huggingfaceOptions?.reasoningEffort != null && {
+ reasoning: {
+ ...(huggingfaceOptions?.reasoningEffort != null && {
+ effort: huggingfaceOptions.reasoningEffort,
+ }),
+ },
+ }),
};
return { args: baseArgs, warnings };
}
@@ -365,6 +372,20 @@ var HuggingFaceResponsesLanguageModel = class {
}
break;
}
+ case 'reasoning': {
+ for (const contentPart of part.content) {
+ content.push({
+ type: 'reasoning',
+ text: contentPart.text,
+ providerMetadata: {
+ huggingface: {
+ itemId: part.id,
+ },
+ },
+ });
+ }
+ break;
+ }
case "mcp_call": {
content.push({
type: "tool-call",
@@ -519,6 +540,11 @@ var HuggingFaceResponsesLanguageModel = class {
id: value.item.call_id,
toolName: value.item.name
});
+ } else if (value.item.type === 'reasoning') {
+ controller.enqueue({
+ type: 'reasoning-start',
+ id: value.item.id,
+ });
}
return;
}
@@ -570,6 +596,22 @@ var HuggingFaceResponsesLanguageModel = class {
});
return;
}
+ if (isReasoningDeltaChunk(value)) {
+ controller.enqueue({
+ type: 'reasoning-delta',
+ id: value.item_id,
+ delta: value.delta,
+ });
+ return;
+ }
+
+ if (isReasoningEndChunk(value)) {
+ controller.enqueue({
+ type: 'reasoning-end',
+ id: value.item_id,
+ });
+ return;
+ }
},
flush(controller) {
controller.enqueue({
@@ -593,7 +635,8 @@ var HuggingFaceResponsesLanguageModel = class {
var huggingfaceResponsesProviderOptionsSchema = z2.object({
metadata: z2.record(z2.string(), z2.string()).optional(),
instructions: z2.string().optional(),
- strictJsonSchema: z2.boolean().optional()
+ strictJsonSchema: z2.boolean().optional(),
+ reasoningEffort: z2.string().optional(),
});
var huggingfaceResponsesResponseSchema = z2.object({
id: z2.string(),
@@ -727,12 +770,31 @@ var responseCreatedChunkSchema = z2.object({
model: z2.string()
})
});
+var reasoningTextDeltaChunkSchema = z2.object({
+ type: z2.literal('response.reasoning_text.delta'),
+ item_id: z2.string(),
+ output_index: z2.number(),
+ content_index: z2.number(),
+ delta: z2.string(),
+ sequence_number: z2.number(),
+});
+
+var reasoningTextEndChunkSchema = z2.object({
+ type: z2.literal('response.reasoning_text.done'),
+ item_id: z2.string(),
+ output_index: z2.number(),
+ content_index: z2.number(),
+ text: z2.string(),
+ sequence_number: z2.number(),
+});
var huggingfaceResponsesChunkSchema = z2.union([
responseOutputItemAddedSchema,
responseOutputItemDoneSchema,
textDeltaChunkSchema,
responseCompletedChunkSchema,
responseCreatedChunkSchema,
+ reasoningTextDeltaChunkSchema,
+ reasoningTextEndChunkSchema,
z2.object({ type: z2.string() }).loose()
// fallback for unknown chunks
]);
@@ -751,6 +813,12 @@ function isResponseCompletedChunk(chunk) {
function isResponseCreatedChunk(chunk) {
return chunk.type === "response.created";
}
+function isReasoningDeltaChunk(chunk) {
+ return chunk.type === 'response.reasoning_text.delta';
+}
+function isReasoningEndChunk(chunk) {
+ return chunk.type === 'response.reasoning_text.done';
+}
// src/huggingface-provider.ts
function createHuggingFace(options = {}) {

View File

@@ -1,76 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f318026d9b2 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -274,6 +274,7 @@ var openaiChatResponseSchema = (0, import_provider_utils3.lazyValidator)(
message: import_v42.z.object({
role: import_v42.z.literal("assistant").nullish(),
content: import_v42.z.string().nullish(),
+ reasoning_content: import_v42.z.string().nullish(),
tool_calls: import_v42.z.array(
import_v42.z.object({
id: import_v42.z.string().nullish(),
@@ -340,6 +341,7 @@ var openaiChatChunkSchema = (0, import_provider_utils3.lazyValidator)(
delta: import_v42.z.object({
role: import_v42.z.enum(["assistant"]).nullish(),
content: import_v42.z.string().nullish(),
+ reasoning_content: import_v42.z.string().nullish(),
tool_calls: import_v42.z.array(
import_v42.z.object({
index: import_v42.z.number(),
@@ -785,6 +787,14 @@ var OpenAIChatLanguageModel = class {
if (text != null && text.length > 0) {
content.push({ type: "text", text });
}
+ const reasoning =
+ choice.message.reasoning_content;
+ if (reasoning != null && reasoning.length > 0) {
+ content.push({
+ type: 'reasoning',
+ text: reasoning,
+ });
+ }
for (const toolCall of (_a = choice.message.tool_calls) != null ? _a : []) {
content.push({
type: "tool-call",
@@ -866,6 +876,7 @@ var OpenAIChatLanguageModel = class {
};
let isFirstChunk = true;
let isActiveText = false;
+ let isActiveReasoning = false;
const providerMetadata = { openai: {} };
return {
stream: response.pipeThrough(
@@ -920,6 +931,22 @@ var OpenAIChatLanguageModel = class {
return;
}
const delta = choice.delta;
+ const reasoningContent = delta.reasoning_content;
+ if (reasoningContent) {
+ if (!isActiveReasoning) {
+ controller.enqueue({
+ type: 'reasoning-start',
+ id: 'reasoning-0',
+ });
+ isActiveReasoning = true;
+ }
+
+ controller.enqueue({
+ type: 'reasoning-delta',
+ id: 'reasoning-0',
+ delta: reasoningContent,
+ });
+ }
if (delta.content != null) {
if (!isActiveText) {
controller.enqueue({ type: "text-start", id: "0" });
@@ -1032,6 +1059,9 @@ var OpenAIChatLanguageModel = class {
}
},
flush(controller) {
+ if (isActiveReasoning) {
+ controller.enqueue({ type: 'reasoning-end', id: 'reasoning-0' });
+ }
if (isActiveText) {
controller.enqueue({ type: "text-end", id: "0" });
}

View File

@@ -1,24 +1,24 @@
diff --git a/sdk.mjs b/sdk.mjs
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b478c738dd8 100644
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
@@ -6215,7 +6215,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
}
// ../src/transport/ProcessTransport.ts
-import { spawn } from "child_process";
+import { fork } from "child_process";
import { createInterface } from "readline";
// ../src/utils/fsOperations.ts
@@ -6487,14 +6487,11 @@ class ProcessTransport {
@@ -6473,14 +6473,11 @@ class ProcessTransport {
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
throw new ReferenceError(errorMessage);
}
- const isNative = isNativeBinary(pathToClaudeCodeExecutable);
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
- const spawnArgs = isNative ? [...executableArgs, ...args] : [...executableArgs, pathToClaudeCodeExecutable, ...args];
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${spawnCommand} ${spawnArgs.join(" ")}` : `Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(" ")}`);
- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args];
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`);
+ this.logForDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore";
- this.child = spawn(spawnCommand, spawnArgs, {

View File

@@ -10,9 +10,8 @@ This file provides guidance to AI coding assistants when working with code in th
- **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`.
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
- **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications.
- **Write conventional commits with emoji**: Commit small, focused changes using emoji-prefixed Conventional Commit messages (e.g., `✨ feat:`, `🐛 fix:`, `♻️ refactor:`, `
📝 docs:`).
- **Seek review**: Ask a human developer to review substantial changes before merging.
- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged.
## Development Commands

View File

@@ -65,28 +65,7 @@ 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.
## 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!
- **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).
## Contact Us

View File

@@ -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]
@@ -248,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

View File

@@ -21,11 +21,7 @@
"quoteStyle": "single"
}
},
"files": {
"ignoreUnknown": false,
"includes": ["**"],
"maxSize": 2097152
},
"files": { "ignoreUnknown": false },
"formatter": {
"attributePosition": "auto",
"bracketSameLine": false,

View File

@@ -69,28 +69,7 @@ git commit --signoff -m "Your commit message"
### 其他建议
- **联系开发者**:在提交 PR 之前,您可以先和开发者进行联系,共同探讨或者获取帮助。
## 重要贡献指南与关注点
在提交 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 增强、新组件、小型重构)。✨
感谢您在此重要开发阶段的理解与持续支持。谢谢!
- **成为核心开发者**:如果您能够稳定为项目贡献,恭喜您可以成为项目核心开发者,获取到项目成员身份。请查看我们的[成员指南](https://github.com/CherryHQ/community/blob/main/membership.md)
## 联系我们

305
docs/EXPORT_IMAGES_PLAN.md Normal file
View File

@@ -0,0 +1,305 @@
# 对话图片导出功能设计方案
## 一、需求背景
随着多模态AI模型的普及用户在对话中使用图片的频率增加。当前的导出功能只处理文本内容图片被完全忽略需要增加图片导出能力。
## 二、现状分析
### 2.1 图片存储机制
当前系统中图片有两种存储方式:
1. **用户上传的图片**
- 存储位置:本地文件系统
- 访问方式:通过 `FileMetadata.path` 字段,使用 `file://` 协议
- 数据结构:`ImageMessageBlock.file`
2. **AI生成的图片**
- 存储位置内存中的Base64字符串
- 访问方式:`ImageMessageBlock.metadata.generateImageResponse.images` 数组
- 数据格式Base64编码的图片数据
### 2.2 现有导出功能
当前支持的导出格式:
- Markdown本地文件/指定路径)
- Word文档.docx
- Notion需要API配置
- 语雀需要API配置
- Obsidian带弹窗配置
- Joplin需要API配置
- 思源笔记需要API配置
- 笔记工作区
- 纯文本
- 图片截图
### 2.3 导出菜单问题
1. **设置分散**:导出相关设置分布在多个地方
2. **每次导出可能需要不同配置**:如是否包含推理内容、是否包含引用等
3. **缺乏统一的导出界面**除Obsidian外其他格式直接执行导出
## 三、解决方案
### 3.1 第一阶段:图片导出功能实现
#### 3.1.1 导出模式设计
提供两种图片导出模式供用户选择:
**模式1Base64嵌入模式**
```markdown
![图片描述](data:image/png;base64,iVBORw0KGg...)
```
- 优点:单文件、便于分享、保证完整性
- 缺点:文件体积大、部分编辑器不支持、性能较差
**模式2文件夹模式**
```
导出结构:
conversation_2024-01-21/
├── conversation.md
└── images/
├── user_upload_1.png
├── ai_generated_1.png
└── ...
```
Markdown中使用相对路径
```markdown
![图片描述](./images/user_upload_1.png)
```
- 优点:文件体积小、兼容性好、性能优秀
- 缺点:需要管理多个文件、分享需打包
#### 3.1.2 核心功能实现
1. **新增图片处理工具函数** (`utils/export.ts`)
```typescript
// 处理消息中的所有图片块
export async function processImageBlocks(
message: Message,
mode: 'base64' | 'folder',
outputDir?: string
): Promise<ImageExportResult[]>
// 将file://协议的图片转换为Base64
export async function convertFileToBase64(filePath: string): Promise<string>
// 保存图片到指定文件夹
export async function saveImageToFolder(
image: string | Buffer,
outputDir: string,
fileName: string
): Promise<string>
// 在Markdown中插入图片引用
export function insertImageIntoMarkdown(
markdown: string,
images: ImageExportResult[]
): string
```
2. **更新现有导出函数**
- `messageToMarkdown()`: 增加图片处理参数
- `topicToMarkdown()`: 批量处理话题中的图片
- `exportTopicAsMarkdown()`: 支持图片导出选项
3. **图片元数据保留**
- AI生成图片保存prompt信息
- 用户上传图片:保留原始文件名
- 添加图片索引和时间戳
### 3.2 第二阶段:统一导出弹窗(后续实施)
#### 3.2.1 弹窗设计
创建统一的导出配置弹窗 `UnifiedExportDialog`
```typescript
interface ExportDialogProps {
// 导出内容
content: {
message?: Message
messages?: Message[]
topic?: Topic
rawContent?: string
}
// 导出格式
format: ExportFormat
// 通用配置
options: {
includeReasoning?: boolean // 包含推理内容
excludeCitations?: boolean // 排除引用
imageExportMode?: 'base64' | 'folder' | 'none' // 图片导出模式
imageQuality?: number // 图片质量0-100
maxImageSize?: number // 最大图片尺寸
}
// 格式特定配置
formatOptions?: {
// Markdown特定
markdownPath?: string
// Notion特定
notionDatabase?: string
notionPageName?: string
// Obsidian特定
obsidianVault?: string
obsidianFolder?: string
processingMethod?: string
// 其他格式配置...
}
}
```
#### 3.2.2 交互流程
1. 用户点击导出按钮
2. 弹出统一导出弹窗
3. 用户选择导出格式
4. 根据格式显示相应配置选项
5. 用户调整配置
6. 点击确认执行导出
#### 3.2.3 优势
1. **配置集中管理**:所有导出配置在一个界面完成
2. **动态配置**:每次导出可以调整不同设置
3. **用户体验统一**:所有格式使用相同的交互模式
4. **易于扩展**:新增导出格式只需添加配置项
## 四、实施计划
### Phase 1: 基础图片导出(本次实施)
- [x] 创建设计文档
- [ ] 实现图片处理工具函数
- [ ] 更新Markdown导出支持图片
- [ ] 添加图片导出模式设置
- [ ] 测试不同场景
### Phase 2: 扩展格式支持
- [ ] Word文档图片嵌入
- [ ] Obsidian图片处理
- [ ] Joplin图片上传
- [ ] 思源笔记图片支持
### Phase 3: 统一导出弹窗
- [ ] 设计弹窗UI组件
- [ ] 实现配置管理逻辑
- [ ] 迁移现有导出功能
- [ ] 添加配置持久化
### Phase 4: 高级功能
- [ ] 图片压缩优化
- [ ] 批量导出进度显示
- [ ] 导出历史记录
- [ ] 导出模板系统
## 五、技术细节
### 5.1 图片格式转换
```typescript
// Base64转换示例
async function imageToBase64(imagePath: string): Promise<string> {
if (imagePath.startsWith('file://')) {
const actualPath = imagePath.slice(7)
const buffer = await fs.readFile(actualPath)
const mimeType = getMimeType(actualPath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
}
return imagePath // 已经是Base64
}
```
### 5.2 文件夹结构生成
```typescript
async function createExportFolder(topicName: string): Promise<string> {
const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss')
const folderName = `${sanitizeFileName(topicName)}_${timestamp}`
const exportPath = path.join(getExportDir(), folderName)
await fs.mkdir(path.join(exportPath, 'images'), { recursive: true })
return exportPath
}
```
### 5.3 Markdown图片引用更新
```typescript
function updateMarkdownImages(
markdown: string,
imageMap: Map<string, string>
): string {
let updatedMarkdown = markdown
for (const [originalPath, newPath] of imageMap) {
// 替换图片引用
const regex = new RegExp(`!\\[([^\\]]*)\\]\\(${escapeRegex(originalPath)}\\)`, 'g')
updatedMarkdown = updatedMarkdown.replace(
regex,
`![$1](${newPath})`
)
}
return updatedMarkdown
}
```
## 六、注意事项
1. **性能考虑**
- 大量图片时使用异步处理
- 提供进度反馈
- 实现取消操作
2. **兼容性**
- 检测目标应用对图片格式的支持
- 提供降级方案
3. **安全性**
- 验证文件路径合法性
- 限制图片大小
- 清理临时文件
4. **用户体验**
- 清晰的配置说明
- 合理的默认值
- 错误提示友好
## 七、后续优化
1. **Notion图片支持**(需要调研)
- 研究Notion API的图片上传能力
- 评估 `@notionhq/client` 库的图片处理功能
- 可能需要先上传到图床再引用
2. **智能压缩**
- 根据图片内容自动选择压缩算法
- 保持图片质量的同时减小体积
3. **批量导出**
- 支持多个话题同时导出
- 生成导出报告
4. **云存储集成**
- 支持直接上传到云存储
- 生成分享链接
## 八、参考资料
- [Notion API Documentation](https://developers.notion.com/)
- [Obsidian URI Protocol](https://help.obsidian.md/Extending+Obsidian/Obsidian+URI)
- [Joplin Web Clipper API](https://joplinapp.org/api/references/rest_api/)
- [思源笔记 API](https://github.com/siyuan-note/siyuan/blob/master/API.md)
---
*文档创建日期2025-01-21*
*最后更新2025-01-21*

View File

@@ -64,12 +64,6 @@ asarUnpack:
- resources/**
- "**/*.{metal,exp,lib}"
- "node_modules/@img/sharp-libvips-*/**"
# copy from node_modules/claude-code-plugins/plugins to resources/data/claude-code-pluginso
extraResources:
- from: "./node_modules/claude-code-plugins/plugins/"
to: "claude-code-plugins"
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-${arch}-setup.${ext}

View File

@@ -78,7 +78,7 @@
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch",
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch",
"@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
@@ -86,8 +86,6 @@
"express": "^5.1.0",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"jsdom": "26.1.0",
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
@@ -103,9 +101,8 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.42",
"@ai-sdk/google-vertex": "^3.0.48",
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.4#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch",
"@ai-sdk/amazon-bedrock": "^3.0.35",
"@ai-sdk/google-vertex": "^3.0.40",
"@ai-sdk/mistral": "^2.0.19",
"@ai-sdk/perplexity": "^2.0.13",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
@@ -151,7 +148,7 @@
"@modelcontextprotocol/sdk": "^1.17.5",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@openrouter/ai-sdk-provider": "^1.2.0",
"@openrouter/ai-sdk-provider": "^1.1.2",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
@@ -197,7 +194,6 @@
"@types/fs-extra": "^11",
"@types/he": "^1",
"@types/html-to-text": "^9",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
"@types/md5": "^2.3.5",
@@ -227,7 +223,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.76",
"ai": "^5.0.68",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
@@ -237,7 +233,6 @@
"check-disk-space": "3.4.0",
"cheerio": "^1.1.2",
"chokidar": "^4.0.3",
"claude-code-plugins": "1.0.1",
"cli-progress": "^3.12.0",
"clsx": "^2.1.1",
"code-inspector-plugin": "^0.20.14",
@@ -390,15 +385,13 @@
"undici": "6.21.2",
"vite": "npm:rolldown-vite@7.1.5",
"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.23": "patch:@ai-sdk/google@npm%3A2.0.23#~/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch",
"@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
"@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch",
"@img/sharp-darwin-arm64": "0.34.3",
"@img/sharp-darwin-x64": "0.34.3",
"@img/sharp-linux-arm": "0.34.3",
"@img/sharp-linux-arm64": "0.34.3",
"@img/sharp-linux-x64": "0.34.3",
"@img/sharp-win32-x64": "0.34.3",
"openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0"
"@img/sharp-win32-x64": "0.34.3"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -36,10 +36,10 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.32",
"@ai-sdk/azure": "^2.0.53",
"@ai-sdk/anthropic": "^2.0.27",
"@ai-sdk/azure": "^2.0.49",
"@ai-sdk/deepseek": "^1.0.23",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
"@ai-sdk/openai": "^2.0.48",
"@ai-sdk/openai-compatible": "^1.0.22",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.12",

View File

@@ -7,7 +7,6 @@ import { createAzure } from '@ai-sdk/azure'
import { type AzureOpenAIProviderSettings } from '@ai-sdk/azure'
import { createDeepSeek } from '@ai-sdk/deepseek'
import { createGoogleGenerativeAI } from '@ai-sdk/google'
import { createHuggingFace } from '@ai-sdk/huggingface'
import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { LanguageModelV2 } from '@ai-sdk/provider'
@@ -29,8 +28,7 @@ export const baseProviderIds = [
'azure',
'azure-responses',
'deepseek',
'openrouter',
'huggingface'
'openrouter'
] as const
/**
@@ -134,12 +132,6 @@ export const baseProviders = [
name: 'OpenRouter',
creator: createOpenRouter,
supportsImageGeneration: true
},
{
id: 'huggingface',
name: 'HuggingFace',
creator: createHuggingFace,
supportsImageGeneration: true
}
] as const satisfies BaseProvider[]

View File

@@ -96,10 +96,6 @@ export enum IpcChannel {
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
AgentMessage_GetHistory = 'agent-message:get-history',
AgentToolPermission_Request = 'agent-tool-permission:request',
AgentToolPermission_Response = 'agent-tool-permission:response',
AgentToolPermission_Result = 'agent-tool-permission:result',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
@@ -195,6 +191,11 @@ export enum IpcChannel {
File_StartWatcher = 'file:startWatcher',
File_StopWatcher = 'file:stopWatcher',
File_ShowInFolder = 'file:showInFolder',
// Image export specific channels
File_ReadBinary = 'file:readBinary',
File_WriteBinary = 'file:writeBinary',
File_CopyFile = 'file:copyFile',
File_CreateDirectory = 'file:createDirectory',
// file service
FileService_Upload = 'file-service:upload',
@@ -354,14 +355,5 @@ export enum IpcChannel {
Ovms_StopOVMS = 'ovms:stop-ovms',
// CherryAI
Cherryai_GetSignature = 'cherryai:get-signature',
// Claude Code Plugins
ClaudeCodePlugin_ListAvailable = 'claudeCodePlugin:list-available',
ClaudeCodePlugin_Install = 'claudeCodePlugin:install',
ClaudeCodePlugin_Uninstall = 'claudeCodePlugin:uninstall',
ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed',
ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache',
ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content',
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content'
Cherryai_GetSignature = 'cherryai:get-signature'
}

View File

@@ -56,7 +56,7 @@ Performance Optimization Recommendations:
- For unstable services: MAX_CONCURRENT_TRANSLATIONS=2, TRANSLATION_DELAY_MS=500
Environment Variables:
- TRANSLATION_BASE_LOCALE: Base locale for translation (default: 'en-us')
- BASE_LOCALE: Base locale for translation (default: 'en-us')
- TRANSLATION_BASE_URL: Custom API endpoint URL
- TRANSLATION_MODEL: Custom translation model name
*/
@@ -258,7 +258,7 @@ const main = async () => {
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
const baseLocale = process.env.TRANSLATION_BASE_LOCALE ?? 'en-us'
const baseLocale = process.env.BASE_LOCALE ?? 'en-us'
const baseFileName = `${baseLocale}.json`
const baseLocalePath = path.join(__dirname, '../src/renderer/src/i18n/locales', baseFileName)
if (!fs.existsSync(baseLocalePath)) {
@@ -284,7 +284,6 @@ const main = async () => {
const translateFiles = getFiles(translateDir)
const files = [...localeFiles, ...translateFiles]
console.info(`📂 Base Locale: ${baseLocale}`)
console.info('📂 Files to translate:')
files.forEach((filePath) => {
const filename = path.basename(filePath, '.json')

View File

@@ -1,635 +0,0 @@
/**
* Feishu (Lark) Webhook Notification Script for Pull Requests
* Sends GitHub PR summaries to Feishu with @ mentions for reviewers and assignees
*/
const crypto = require('crypto')
const https = require('https')
const fs = require('fs')
const path = require('path')
/**
* 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()
})
}
/**
* Parse user mapping from environment variable
* Expected format: "github_user1:feishu_id1,github_user2:feishu_id2"
* @param {string} mappingStr - User mapping string
* @returns {Map<string, string>} Map of GitHub username to Feishu user ID
*/
function parseUserMapping(mappingStr) {
const mapping = new Map()
if (!mappingStr) {
return mapping
}
const pairs = mappingStr.split(',')
for (const pair of pairs) {
const [github, feishu] = pair.split(':').map((s) => s.trim())
if (github && feishu) {
mapping.set(github, feishu)
}
}
return mapping
}
/**
* Get PR category display info
* @param {string} category - PR category
* @returns {object} Category display info
*/
function getCategoryInfo(category) {
const categoryMap = {
chat: { emoji: '💬', name: '对话', color: 'blue' },
draw: { emoji: '🖼️', name: '绘图', color: 'blue' },
uiux: { emoji: '🎨', name: 'UI/UX', color: 'blue' },
knowledge: { emoji: '🧠', name: '知识库', color: 'green' },
agent: { emoji: '🕹️', name: 'Agent', color: 'turquoise' },
provider: { emoji: '🔌', name: 'Provider', color: 'turquoise' },
minapps: { emoji: '🧩', name: '小程序', color: 'turquoise' },
backup_export: { emoji: '💾', name: '备份/导出', color: 'purple' },
data_storage: { emoji: '🗄️', name: '数据与存储', color: 'purple' },
ai_core: { emoji: '🤖', name: 'AI基础设施', color: 'purple' },
backend: { emoji: '⚙️', name: '后端/平台', color: 'green' },
docs: { emoji: '📚', name: '文档', color: 'grey' },
'build-config': { emoji: '🔧', name: '构建/配置', color: 'orange' },
test: { emoji: '🧪', name: '测试', color: 'yellow' },
multiple: { emoji: '🔀', name: '多模块', color: 'red' },
other: { emoji: '📝', name: '其他', color: 'blue' }
}
return categoryMap[category] || categoryMap.other
}
/**
* Load GitHub reviewers per category from .github/pr-modules.yml (optional)
* Supports inline array style: github_reviewers: ["user1","user2"] or []
* @returns {Map<string, string[]>}
*/
function loadConfigGithubReviewersByCategory() {
const result = new Map()
result.__rules = { vendor_added: [], large_change: { changed_files_gt: 30, reviewers: [] } }
try {
const candidates = [
path.join(process.cwd(), '.github', 'pr-modules.yml'),
path.join(process.cwd(), '.github', 'pr-modules.yaml')
]
let filePath = null
for (const p of candidates) {
if (fs.existsSync(p)) {
filePath = p
break
}
}
if (!filePath) return result
const content = fs.readFileSync(filePath, 'utf8')
const lines = content.split(/\r?\n/)
let inCategories = false
let inRules = false
let currentCategory = null
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (!inCategories && !inRules) {
if (/^categories:\s*$/.test(line)) {
inCategories = true
continue
}
if (/^rules:\s*$/.test(line)) {
inRules = true
continue
}
continue
}
if (inCategories) {
const catMatch = /^\s{2}([a-zA-Z0-9_-]+):\s*$/.exec(line)
if (catMatch) {
currentCategory = catMatch[1]
continue
}
if (currentCategory) {
const reviewersMatch = /^\s{4}github_reviewers:\s*(.*)$/.exec(line)
if (reviewersMatch) {
let value = (reviewersMatch[1] || '').trim()
let users = []
if (value.startsWith('[') && value.endsWith(']')) {
const inner = value.slice(1, -1).trim()
if (inner.length > 0) {
users = inner
.split(',')
.map((s) => s.trim().replace(/^"|"$/g, '').replace(/^'|'$/g, ''))
.filter(Boolean)
}
} else if (value === '' || value === '[]') {
// try to parse dash list style
const collected = []
let j = i + 1
while (j < lines.length) {
const l = lines[j]
const dash = /^\s{6}-\s*(["']?)([^"']*)\1\s*$/.exec(l)
if (dash) {
const user = dash[2].trim()
if (user) collected.push(user)
j++
continue
}
break
}
users = collected
}
result.set(currentCategory, Array.from(new Set(users)))
}
}
} else if (inRules) {
// vendor_added block
if (/^\s{2}vendor_added:\s*$/.test(line)) {
// parse github_reviewers under vendor_added
let j = i + 1
const reviewers = []
while (j < lines.length) {
const l = lines[j]
const reviewersLine = /^\s{4}github_reviewers:\s*(.*)$/.exec(l)
if (reviewersLine) {
let value = (reviewersLine[1] || '').trim()
if (value.startsWith('[') && value.endsWith(']')) {
const inner = value.slice(1, -1).trim()
if (inner.length > 0) {
inner.split(',').forEach((s) => {
const u = s.trim().replace(/^"|"$/g, '').replace(/^'|'$/g, '')
if (u) reviewers.push(u)
})
}
}
j++
continue
}
const dash = /^\s{6}-\s*(["']?)([^"']*)\1\s*$/.exec(l)
if (dash) {
const u = dash[2].trim()
if (u) reviewers.push(u)
j++
continue
}
if (/^\s{2}[a-zA-Z0-9_-]+:\s*$/.test(l)) break
j++
}
result.__rules.vendor_added = Array.from(new Set(reviewers))
}
// large_change block
if (/^\s{2}large_change:\s*$/.test(line)) {
let j = i + 1
const rule = { changed_files_gt: 30, reviewers: [] }
while (j < lines.length) {
const l = lines[j]
const threshold = /^\s{4}changed_files_gt:\s*(\d+)\s*$/.exec(l)
if (threshold) {
rule.changed_files_gt = parseInt(threshold[1], 10)
j++
continue
}
const reviewersLine = /^\s{4}github_reviewers:\s*(.*)$/.exec(l)
if (reviewersLine) {
let value = (reviewersLine[1] || '').trim()
if (value.startsWith('[') && value.endsWith(']')) {
const inner = value.slice(1, -1).trim()
if (inner.length > 0) {
inner.split(',').forEach((s) => {
const u = s.trim().replace(/^"|"$/g, '').replace(/^'|'$/g, '')
if (u) rule.reviewers.push(u)
})
}
}
j++
continue
}
const dash = /^\s{6}-\s*(["']?)([^"']*)\1\s*$/.exec(l)
if (dash) {
const u = dash[2].trim()
if (u) rule.reviewers.push(u)
j++
continue
}
if (/^\s{2}[a-zA-Z0-9_-]+:\s*$/.test(l)) break
j++
}
rule.reviewers = Array.from(new Set(rule.reviewers))
result.__rules.large_change = rule
}
}
}
} catch (e) {
console.warn('⚠️ Failed to load .github/pr-modules.yml:', e.message)
}
return result
}
/**
* Get recommended reviewers based on PR category
* This is a helper for Claude to suggest appropriate reviewers
* @param {string} category - PR category
* @param {Map<string, string>} userMapping - GitHub to Feishu user mapping
* @returns {string[]} List of Feishu user IDs to notify
*/
function getRecommendedReviewersByCategory(category, userMapping, configGithubReviewersMap) {
// Fallback mapping when config not provided
const fallback = {
backend: ['kangfenmao'],
ai_core: ['kangfenmao'],
'build-config': ['kangfenmao'],
multiple: ['kangfenmao']
}
const configUsers = (configGithubReviewersMap && configGithubReviewersMap.get(category)) || []
const fallbackUsers = fallback[category] || []
const githubUsers = Array.from(new Set([...configUsers, ...fallbackUsers]))
return githubUsers.map((gh) => userMapping.get(gh)).filter(Boolean)
}
/**
* Create Feishu card message from PR data
* @param {object} prData - GitHub PR data
* @param {Map<string, string>} userMapping - GitHub to Feishu user mapping
* @returns {object} Feishu card content
*/
function createPRCard(prData, userMapping, configGithubReviewersMap) {
const {
prUrl,
prNumber,
prTitle,
prSummary,
prAuthor,
labels,
reviewers,
assignees,
category,
changedFiles,
additions,
deletions,
vendorAdded
} = prData
const categoryInfo = getCategoryInfo(category)
// Build labels section
const labelElements =
labels && labels.length > 0
? [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**🏷️ Labels:** ${labels.map((l) => `\`${l}\``).join(' ')}`
}
}
]
: []
// Build stats section
const statsContent = [
`📁 ${changedFiles || 0} files`,
`<font color='green'>+${additions || 0}</font>`,
`<font color='red'>-${deletions || 0}</font>`
].join(' · ')
// Build mention content for reviewers and assignees
const mentions = []
const mentionedUsers = new Set()
// Add reviewers
if (reviewers && reviewers.length > 0) {
reviewers.forEach((reviewer) => {
const feishuId = userMapping.get(reviewer)
if (feishuId && !mentionedUsers.has(feishuId)) {
mentions.push(`<at id="${feishuId}"></at>`)
mentionedUsers.add(feishuId)
}
})
}
// Add assignees
if (assignees && assignees.length > 0) {
assignees.forEach((assignee) => {
const feishuId = userMapping.get(assignee)
if (feishuId && !mentionedUsers.has(feishuId)) {
mentions.push(`<at id="${feishuId}"></at>`)
mentionedUsers.add(feishuId)
}
})
}
// Add category-based experts (if not already mentioned)
const categoryExperts = getRecommendedReviewersByCategory(category, userMapping, configGithubReviewersMap)
categoryExperts.forEach((feishuId) => {
if (feishuId && !mentionedUsers.has(feishuId)) {
mentions.push(`<at id="${feishuId}"></at>`)
mentionedUsers.add(feishuId)
}
})
// Enforce mandatory reviewers based on rules
const mandatoryGithubUsers = []
const rules = configGithubReviewersMap.__rules || {
vendor_added: [],
large_change: { changed_files_gt: 30, reviewers: [] }
}
if (vendorAdded) {
mandatoryGithubUsers.push(...(rules.vendor_added || ['Yinsen-Ho']))
}
const changedFilesNum = Number(changedFiles) || 0
const threshold = (rules.large_change && rules.large_change.changed_files_gt) || 30
if (changedFilesNum > threshold) {
const reviewers = (rules.large_change && rules.large_change.reviewers) || ['kangfenmao']
mandatoryGithubUsers.push(...reviewers)
}
mandatoryGithubUsers.forEach((gh) => {
const feishuId = userMapping.get(gh)
if (feishuId && !mentionedUsers.has(feishuId)) {
mentions.push(`<at id="${feishuId}"></at>`)
mentionedUsers.add(feishuId)
}
})
// Build mentions section
const mentionElements =
mentions.length > 0
? [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**👥 请关注:** ${mentions.join(' ')}`
}
}
]
: []
// Build reviewer and assignee info
const reviewerInfo = []
if (reviewers && reviewers.length > 0) {
reviewerInfo.push({
tag: 'div',
text: {
tag: 'lark_md',
content: `**👀 Reviewers:** ${reviewers.map((r) => `\`${r}\``).join(', ')}`
}
})
}
if (assignees && assignees.length > 0) {
reviewerInfo.push({
tag: 'div',
text: {
tag: 'lark_md',
content: `**👤 Assignees:** ${assignees.map((a) => `\`${a}\``).join(', ')}`
}
})
}
return {
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**🔀 New Pull Request #${prNumber}**`
}
},
{
tag: 'hr'
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**${categoryInfo.emoji} 类型:** ${categoryInfo.name}`
}
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**📝 Title:** ${prTitle}`
}
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**👤 Author:** \`${prAuthor}\``
}
},
...reviewerInfo,
...labelElements,
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**📊 Changes:** ${statsContent}`
}
},
{
tag: 'hr'
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**📋 Summary:**\n${prSummary}`
}
},
...mentionElements,
{
tag: 'hr'
},
{
tag: 'action',
actions: [
{
tag: 'button',
text: {
tag: 'plain_text',
content: '🔗 View PR'
},
type: 'primary',
url: prUrl
}
]
}
],
header: {
template: categoryInfo.color,
title: {
tag: 'plain_text',
content: `${categoryInfo.emoji} Cherry Studio - New PR [${categoryInfo.name}]`
}
}
}
}
/**
* Main function
*/
async function main() {
try {
// Get environment variables
const webhookUrl = process.env.FEISHU_WEBHOOK_URL
const secret = process.env.FEISHU_WEBHOOK_SECRET
const userMappingStr = process.env.FEISHU_USER_MAPPING || ''
const prUrl = process.env.PR_URL
const prNumber = process.env.PR_NUMBER
const prTitle = process.env.PR_TITLE
const prSummary = process.env.PR_SUMMARY
const prAuthor = process.env.PR_AUTHOR
const labelsStr = process.env.PR_LABELS || ''
const reviewersStr = process.env.PR_REVIEWERS || ''
const assigneesStr = process.env.PR_ASSIGNEES || ''
const category = process.env.PR_CATEGORY || 'multiple'
const vendorAdded = String(process.env.PR_VENDOR_ADDED || 'false').toLowerCase() === 'true'
const changedFiles = process.env.PR_CHANGED_FILES || '0'
const additions = process.env.PR_ADDITIONS || '0'
const deletions = process.env.PR_DELETIONS || '0'
// 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 (!prUrl || !prNumber || !prTitle || !prSummary) {
throw new Error('PR data environment variables are required')
}
// Parse data
const userMapping = parseUserMapping(userMappingStr)
const configGithubReviewersMap = loadConfigGithubReviewersByCategory()
const labels = labelsStr
? labelsStr
.split(',')
.map((l) => l.trim())
.filter(Boolean)
: []
const reviewers = reviewersStr
? reviewersStr
.split(',')
.map((r) => r.trim())
.filter(Boolean)
: []
const assignees = assigneesStr
? assigneesStr
.split(',')
.map((a) => a.trim())
.filter(Boolean)
: []
// Create PR data object
const prData = {
prUrl,
prNumber,
prTitle,
prSummary,
prAuthor: prAuthor || 'Unknown',
labels,
reviewers,
assignees,
category,
vendorAdded,
changedFiles,
additions,
deletions
}
console.log('📤 Sending PR notification to Feishu...')
console.log(`PR #${prNumber}: ${prTitle}`)
console.log(`Category: ${category}`)
console.log(`Vendor added: ${vendorAdded}`)
console.log(`Reviewers: ${reviewers.join(', ') || 'None'}`)
console.log(`Assignees: ${assignees.join(', ') || 'None'}`)
console.log(`User mapping entries: ${userMapping.size}`)
// Create card content
const card = createPRCard(prData, userMapping, configGithubReviewersMap)
// Send to Feishu
await sendToFeishu(webhookUrl, secret, card)
console.log('✅ PR notification sent successfully!')
} catch (error) {
console.error('❌ Error:', error.message)
process.exit(1)
}
}
// Run main function
main()

View File

@@ -1,277 +0,0 @@
/**
* Stats major contributors per module based on .github/pr-modules.yml
* Output a markdown summary and write JSON to .github/reviewer-suggestions.json
*
* Usage:
* node scripts/stats-contributors.js [--top 3] [--since 1.year] [--mode auto|shortlog|log|blame] [--blame-sample 30]
*/
const { spawnSync } = require('child_process')
const fs = require('fs')
const path = require('path')
function readText(file) {
try {
return fs.readFileSync(file, 'utf8')
} catch {
return null
}
}
function parseArgs() {
const args = process.argv.slice(2)
const out = { top: 3, since: '', mode: 'auto', blameSample: 30 }
for (let i = 0; i < args.length; i++) {
if (args[i] === '--top' && i + 1 < args.length) {
out.top = parseInt(args[++i], 10) || 3
} else if (args[i] === '--since' && i + 1 < args.length) {
out.since = String(args[++i])
} else if (args[i] === '--mode' && i + 1 < args.length) {
out.mode = String(args[++i])
} else if (args[i] === '--blame-sample' && i + 1 < args.length) {
out.blameSample = parseInt(args[++i], 10) || 30
}
}
return out
}
// Minimal YAML parser for categories/globs in .github/pr-modules.yml
function parseModulesConfig(configPath) {
const text = readText(configPath)
if (!text) throw new Error(`Cannot read ${configPath}`)
const lines = text.split(/\r?\n/)
const categories = []
let inCategories = false
let current = null
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (!inCategories) {
if (/^categories:\s*$/.test(line)) inCategories = true
continue
}
// New category key
const catMatch = /^\s{2}([a-zA-Z0-9_-]+):\s*$/.exec(line)
if (catMatch) {
if (current) categories.push(current)
current = { key: catMatch[1], name: '', globs: [] }
continue
}
if (!current) continue
const nameMatch = /^\s{4}name:\s*"?([^"]+)"?\s*$/.exec(line)
if (nameMatch) {
current.name = nameMatch[1].trim()
continue
}
// Enter globs list, then collect dash items
const globsHeader = /^\s{4}globs:\s*$/.exec(line)
if (globsHeader) {
let j = i + 1
while (j < lines.length) {
const l = lines[j]
const item = /^\s{6}-\s*"?([^"]+)"?\s*$/.exec(l)
if (!item) break
current.globs.push(item[1].trim())
j++
}
continue
}
}
if (current) categories.push(current)
return categories
}
function git(args, cwd) {
const res = spawnSync('git', args, { cwd, encoding: 'utf8' })
if (res.status !== 0) {
const msg = (res.stderr || '').trim() || `git ${args.join(' ')} failed`
throw new Error(msg)
}
return res.stdout
}
function buildPathspecs(globs) {
// Use pathspec magic :(glob)pattern so that ** works and we avoid shell expansion
return globs.map((g) => `:(glob)${g}`)
}
function lsFilesForGlobs(globs, repoRoot) {
const pathspecs = buildPathspecs(globs)
if (pathspecs.length === 0) return []
try {
const stdout = git(['ls-files', '--', ...pathspecs], repoRoot)
return stdout
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
} catch (e) {
// No matched files or pathspec error → treat as empty
return []
}
}
function shortlogFor(globs, repoRoot, since) {
const files = lsFilesForGlobs(globs, repoRoot)
if (files.length === 0) return []
const base = ['shortlog', '-sne']
if (since) base.push(`--since=${since}`)
const stdout = git([...base, '--', ...files], repoRoot)
const lines = stdout
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
const rows = []
for (const l of lines) {
// e.g. " 42 John Doe <john@example.com>"
const m = /^(\d+)\s+(.+?)\s+<([^>]+)>$/.exec(l)
if (!m) continue
const commits = parseInt(m[1], 10)
const name = m[2]
const email = m[3]
const gh = extractGithubUsername(name, email)
rows.push({ commits, name, email, github: gh })
}
rows.sort((a, b) => b.commits - a.commits)
return rows
}
function logAuthorsFor(globs, repoRoot, since) {
const files = lsFilesForGlobs(globs, repoRoot)
if (files.length === 0) return []
const base = ['log', '--format=%an <%ae>']
if (since) base.push(`--since=${since}`)
const stdout = git([...base, '--', ...files], repoRoot)
const lines = stdout
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
const map = new Map()
for (const l of lines) {
const m = /^(.+?)\s+<([^>]+)>$/.exec(l)
if (!m) continue
const name = m[1]
const email = m[2]
const gh = extractGithubUsername(name, email)
const key = `${name} <${email}>`
map.set(key, (map.get(key) || 0) + 1)
}
const out = []
for (const [key, commits] of map.entries()) {
const m = /^(.+?)\s+<([^>]+)>$/.exec(key)
out.push({ commits, name: m[1], email: m[2], github: extractGithubUsername(m[1], m[2]) })
}
out.sort((a, b) => b.commits - a.commits)
return out
}
function blameAuthorsSample(globs, repoRoot, sample) {
const files = lsFilesForGlobs(globs, repoRoot)
if (files.length === 0) return []
const pick = files.slice(0, Math.max(1, sample))
const map = new Map()
for (const f of pick) {
let stdout = ''
try {
stdout = git(['blame', '--line-porcelain', '--', f], repoRoot)
} catch (e) {
continue
}
const lines = stdout.split(/\r?\n/)
for (const line of lines) {
// author and author-mail lines
const am = /^author-mail\s+<([^>]+)>$/.exec(line)
if (am) {
const email = am[1]
// We do not rely on index; we just keep email-based identity
const gh = extractGithubUsername('', email)
const key = `${gh || ''}<${email}>`
map.set(key, (map.get(key) || 0) + 1)
}
}
}
const out = []
for (const [key, commits] of map.entries()) {
const m = /^(.*?)<([^>]+)>$/.exec(key)
const email = m ? m[2] : ''
const gh = extractGithubUsername('', email)
out.push({ commits, name: gh || email, email, github: gh })
}
out.sort((a, b) => b.commits - a.commits)
return out
}
function extractGithubUsername(name, email) {
// Try noreply forms: 12345+user@users.noreply.github.com or user@users.noreply.github.com
const noreply = /^(?:\d+\+)?([A-Za-z0-9-]+)@users\.noreply\.github\.com$/.exec(email)
if (noreply) return noreply[1]
// If name itself looks like a probable GitHub handle
if (/^[A-Za-z0-9-]{3,}$/.test(name)) return name
return ''
}
function main() {
const repoRoot = process.cwd()
const { top, since, mode, blameSample } = parseArgs()
const configPath = path.join(repoRoot, '.github', 'pr-modules.yml')
const categories = parseModulesConfig(configPath)
const suggestions = {}
const markdownLines = []
markdownLines.push('| Module | Top Contributors (commits) |')
markdownLines.push('|---|---|')
for (const cat of categories) {
let rows = []
try {
if (mode === 'shortlog' || mode === 'auto') rows = shortlogFor(cat.globs, repoRoot, since)
if (rows.length === 0 && (mode === 'log' || mode === 'auto')) rows = logAuthorsFor(cat.globs, repoRoot, since)
if (rows.length === 0 && (mode === 'blame' || mode === 'auto'))
rows = blameAuthorsSample(cat.globs, repoRoot, blameSample)
} catch (e) {
// Fallback to next method if one fails
if (mode === 'auto') {
try {
rows = logAuthorsFor(cat.globs, repoRoot, since)
} catch (e2) {
// ignore and continue
}
if (rows.length === 0) {
try {
rows = blameAuthorsSample(cat.globs, repoRoot, blameSample)
} catch (e3) {
// ignore and continue
}
}
} else {
// Non-auto mode: report empty on error
rows = []
}
}
const topRows = rows.slice(0, top)
suggestions[cat.key] = topRows.map((r) => ({
github: r.github,
name: r.name,
email: r.email,
commits: r.commits
}))
const cell = topRows
.map((r) => {
const id = r.github ? `@${r.github}` : r.name
return `${id} (${r.commits})`
})
.join(', ')
markdownLines.push(`| ${cat.key} | ${cell || '-'} |`)
}
const outJsonPath = path.join(repoRoot, '.github', 'reviewer-suggestions.json')
fs.writeFileSync(outJsonPath, JSON.stringify({ generatedAt: new Date().toISOString(), suggestions }, null, 2))
console.log(markdownLines.join('\n'))
console.log(`\nSaved JSON: ${path.relative(repoRoot, outJsonPath)}`)
}
main()

View File

@@ -5,7 +5,7 @@ import { sortedObjectByKeys } from './sort'
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
const baseLocale = process.env.TRANSLATION_BASE_LOCALE ?? 'en-us'
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
const baseFileName = `${baseLocale}.json`
const baseFilePath = path.join(localesDir, baseFileName)
@@ -13,45 +13,45 @@ type I18NValue = string | { [key: string]: I18NValue }
type I18N = { [key: string]: I18NValue }
/**
* 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
* 递归同步 target 对象,使其与 template 对象保持一致
* 1. 如果 template 中存在 target 中缺少的 key则添加'[to be translated]'
* 2. 如果 target 中存在 template 中不存在的 key则删除
* 3. 对于子对象,递归同步
*
* @param target Target object (language object to be updated)
* @param template Base locale object (Chinese)
* @returns Returns whether target was updated
* @param target 目标对象(需要更新的语言对象)
* @param template 主模板对象(中文)
* @returns 返回是否对 target 进行了更新
*/
function syncRecursively(target: I18N, template: I18N): void {
// Add keys that exist in template but missing in target
// 添加 template 中存在但 target 中缺少的 key
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(`Added new property: ${key}`)
console.log(`添加新属性:${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])
}
}
// Remove keys that exist in target but not in template
// 删除 target 中存在但 template 中没有的 key
for (const targetKey in target) {
if (!(targetKey in template)) {
console.log(`Removed excess property: ${targetKey}`)
console.log(`移除多余属性:${targetKey}`)
delete target[targetKey]
}
}
}
/**
* 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)
* 检查 JSON 对象中是否存在重复键,并收集所有重复键
* @param obj 要检查的对象
* @returns 返回重复键的数组(若无重复则返回空数组)
*/
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(`Base locale file ${baseFileName} does not exist, please check path or filename`)
console.error(`主模板文件 ${baseFileName} 不存在,请检查路径或文件名`)
return
}
@@ -92,24 +92,24 @@ function syncTranslations() {
try {
baseJson = JSON.parse(baseContent)
} catch (error) {
console.error(`Error parsing ${baseFileName}. ${error}`)
console.error(`解析 ${baseFileName} 出错。${error}`)
return
}
// Check if base locale has duplicate keys
// 检查主模板是否存在重复键
const duplicateKeys = checkDuplicateKeys(baseJson)
if (duplicateKeys.length > 0) {
throw new Error(`Base locale file ${baseFileName} has the following duplicate keys:\n${duplicateKeys.join('\n')}`)
throw new Error(`主模板文件 ${baseFileName} 存在以下重复键:\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(`Base locale has been sorted`)
console.log(`主模板已排序`)
} catch (error) {
console.error(`Error writing ${baseFilePath}.`, error)
console.error(`写入 ${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(`Error parsing ${filename}, skipping this file.`, error)
console.error(`解析 ${filename} 出错,跳过此文件。`, error)
continue
}
@@ -142,9 +142,9 @@ function syncTranslations() {
try {
fs.writeFileSync(filePath, JSON.stringify(sortedJson, null, 2) + '\n', 'utf-8')
console.log(`File ${filename} has been sorted and synced to match base locale content`)
console.log(`文件 ${filename} 已排序并同步更新为主模板的内容`)
} catch (error) {
console.error(`Error writing ${filename}. ${error}`)
console.error(`写入 ${filename} 出错。${error}`)
}
}
}

View File

@@ -11,7 +11,6 @@ import { handleZoomFactor } from '@main/utils/zoom'
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import type { PluginError } from '@types'
import {
AgentPersistedMessage,
FileMetadata,
@@ -47,7 +46,6 @@ import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import OvmsManager from './services/OvmsManager'
import { PluginService } from './services/PluginService'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
@@ -95,18 +93,6 @@ const vertexAIService = VertexAIService.getInstance()
const memoryService = MemoryService.getInstance()
const dxtService = new DxtService()
const ovmsManager = new OvmsManager()
const pluginService = PluginService.getInstance()
function normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
function extractPluginError(error: unknown): PluginError | null {
if (error && typeof error === 'object' && 'type' in error && typeof (error as { type: unknown }).type === 'string') {
return error as PluginError
}
return null
}
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater()
@@ -545,6 +531,23 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
// Image export specific handlers
ipcMain.handle(IpcChannel.File_ReadBinary, async (_, filePath: string) => {
return fs.promises.readFile(filePath)
})
ipcMain.handle(IpcChannel.File_WriteBinary, async (_, filePath: string, buffer: Buffer) => {
return fs.promises.writeFile(filePath, buffer)
})
ipcMain.handle(IpcChannel.File_CopyFile, async (_, sourcePath: string, destPath: string) => {
return fs.promises.copyFile(sourcePath, destPath)
})
ipcMain.handle(IpcChannel.File_CreateDirectory, async (_, dirPath: string) => {
return fs.promises.mkdir(dirPath, { recursive: true })
})
// file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
const service = FileServiceManager.getInstance().getService(provider)
@@ -904,117 +907,4 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// CherryAI
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
// Claude Code Plugins
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListAvailable, async () => {
try {
const data = await pluginService.listAvailable()
return { success: true, data }
} catch (error) {
const pluginError = extractPluginError(error)
if (pluginError) {
logger.error('Failed to list available plugins', pluginError)
return { success: false, error: pluginError }
}
const err = normalizeError(error)
logger.error('Failed to list available plugins', err)
return {
success: false,
error: {
type: 'TRANSACTION_FAILED',
operation: 'list-available',
reason: err.message
}
}
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_Install, async (_, options) => {
try {
const data = await pluginService.install(options)
return { success: true, data }
} catch (error) {
logger.error('Failed to install plugin', { options, error })
return { success: false, error }
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_Uninstall, async (_, options) => {
try {
await pluginService.uninstall(options)
return { success: true, data: undefined }
} catch (error) {
logger.error('Failed to uninstall plugin', { options, error })
return { success: false, error }
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListInstalled, async (_, agentId: string) => {
try {
const data = await pluginService.listInstalled(agentId)
return { success: true, data }
} catch (error) {
const pluginError = extractPluginError(error)
if (pluginError) {
logger.error('Failed to list installed plugins', { agentId, error: pluginError })
return { success: false, error: pluginError }
}
const err = normalizeError(error)
logger.error('Failed to list installed plugins', { agentId, error: err })
return {
success: false,
error: {
type: 'TRANSACTION_FAILED',
operation: 'list-installed',
reason: err.message
}
}
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_InvalidateCache, async () => {
try {
pluginService.invalidateCache()
return { success: true, data: undefined }
} catch (error) {
const pluginError = extractPluginError(error)
if (pluginError) {
logger.error('Failed to invalidate plugin cache', pluginError)
return { success: false, error: pluginError }
}
const err = normalizeError(error)
logger.error('Failed to invalidate plugin cache', err)
return {
success: false,
error: {
type: 'TRANSACTION_FAILED',
operation: 'invalidate-cache',
reason: err.message
}
}
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ReadContent, async (_, sourcePath: string) => {
try {
const data = await pluginService.readContent(sourcePath)
return { success: true, data }
} catch (error) {
logger.error('Failed to read plugin content', { sourcePath, error })
return { success: false, error }
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_WriteContent, async (_, options) => {
try {
await pluginService.writeContent(options.agentId, options.filename, options.type, options.content)
return { success: true, data: undefined }
} catch (error) {
logger.error('Failed to write plugin content', { options, error })
return { success: false, error }
}
})
}

View File

@@ -1,6 +1,7 @@
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { OllamaEmbeddings } from '@cherrystudio/embedjs-ollama'
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
import { ApiClient } from '@types'
import { VoyageEmbeddings } from './VoyageEmbeddings'
@@ -8,7 +9,7 @@ import { VoyageEmbeddings } from './VoyageEmbeddings'
export default class EmbeddingsFactory {
static create({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }): BaseEmbeddings {
const batchSize = 10
const { model, provider, apiKey, baseURL } = embedApiClient
const { model, provider, apiKey, apiVersion, baseURL } = embedApiClient
if (provider === 'voyageai') {
return new VoyageEmbeddings({
modelName: model,
@@ -37,7 +38,16 @@ export default class EmbeddingsFactory {
}
})
}
// NOTE: Azure OpenAI 也走 OpenAIEmbeddings, baseURL是https://xxxx.openai.azure.com/openai/v1
if (apiVersion !== undefined) {
return new AzureOpenAiEmbeddings({
azureOpenAIApiKey: apiKey,
azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model,
azureOpenAIEndpoint: baseURL,
dimensions,
batchSize
})
}
return new OpenAiEmbeddings({
model,
apiKey,

View File

@@ -1,199 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileMetadata, PreprocessProvider } from '@types'
import AdmZip from 'adm-zip'
import { net } from 'electron'
import FormData from 'form-data'
import BasePreprocessProvider from './BasePreprocessProvider'
const logger = loggerService.withContext('MineruPreprocessProvider')
export default class OpenMineruPreprocessProvider extends BasePreprocessProvider {
constructor(provider: PreprocessProvider, userId?: string) {
super(provider, userId)
}
public async parseFile(
sourceId: string,
file: FileMetadata
): Promise<{ processedFile: FileMetadata; quota: number }> {
try {
const filePath = fileStorage.getFilePathById(file)
logger.info(`Open MinerU preprocess processing started: ${filePath}`)
await this.validateFile(filePath)
// 1. Update progress
await this.sendPreprocessProgress(sourceId, 50)
logger.info(`File ${file.name} is starting processing...`)
// 2. Upload file and extract
const { path: outputPath } = await this.uploadFileAndExtract(file)
// 3. Check quota
const quota = await this.checkQuota()
// 4. Create processed file info
return {
processedFile: this.createProcessedFileInfo(file, outputPath),
quota
}
} catch (error) {
logger.error(`Open MinerU preprocess processing failed for:`, error as Error)
throw error
}
}
public async checkQuota() {
// self-hosted version always has enough quota
return Infinity
}
private async validateFile(filePath: string): Promise<void> {
const pdfBuffer = await fs.promises.readFile(filePath)
const doc = await this.readPdf(pdfBuffer)
// File page count must be less than 600 pages
if (doc.numPages >= 600) {
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
}
// File size must be less than 200MB
if (pdfBuffer.length >= 200 * 1024 * 1024) {
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
}
}
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
// Find the main file after extraction
let finalPath = ''
let finalName = file.origin_name.replace('.pdf', '.md')
// Find the corresponding folder by file name
outputPath = path.join(outputPath, `${file.origin_name.replace('.pdf', '')}`)
try {
const files = fs.readdirSync(outputPath)
const mdFile = files.find((f) => f.endsWith('.md'))
if (mdFile) {
const originalMdPath = path.join(outputPath, mdFile)
const newMdPath = path.join(outputPath, finalName)
// Rename file to original file name
try {
fs.renameSync(originalMdPath, newMdPath)
finalPath = newMdPath
logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`)
} catch (renameError) {
logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`)
// If rename fails, use the original file
finalPath = originalMdPath
finalName = mdFile
}
}
} catch (error) {
logger.warn(`Failed to read output directory ${outputPath}:`, error as Error)
finalPath = path.join(outputPath, `${file.id}.md`)
}
return {
...file,
name: finalName,
path: finalPath,
ext: '.md',
size: fs.existsSync(finalPath) ? fs.statSync(finalPath).size : 0
}
}
private async uploadFileAndExtract(
file: FileMetadata,
maxRetries: number = 5,
intervalMs: number = 5000
): Promise<{ path: string }> {
let retries = 0
const endpoint = `${this.provider.apiHost}/file_parse`
// Get file stream
const filePath = fileStorage.getFilePathById(file)
const fileBuffer = await fs.promises.readFile(filePath)
const formData = new FormData()
formData.append('return_md', 'true')
formData.append('response_format_zip', 'true')
formData.append('files', fileBuffer, {
filename: file.origin_name
})
while (retries < maxRetries) {
let zipPath: string | undefined
try {
const response = await net.fetch(endpoint, {
method: 'POST',
headers: {
token: this.userId ?? '',
...(this.provider.apiKey ? { Authorization: `Bearer ${this.provider.apiKey}` } : {}),
...formData.getHeaders()
},
body: formData.getBuffer()
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
// Check if response header is application/zip
if (response.headers.get('content-type') !== 'application/zip') {
throw new Error(`Downloaded ZIP file has unexpected content-type: ${response.headers.get('content-type')}`)
}
const dirPath = this.storageDir
zipPath = path.join(dirPath, `${file.id}.zip`)
const extractPath = path.join(dirPath, `${file.id}`)
const arrayBuffer = await response.arrayBuffer()
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
logger.info(`Downloaded ZIP file: ${zipPath}`)
// Ensure extraction directory exists
if (!fs.existsSync(extractPath)) {
fs.mkdirSync(extractPath, { recursive: true })
}
// Extract files
const zip = new AdmZip(zipPath)
zip.extractAllTo(extractPath, true)
logger.info(`Extracted files to: ${extractPath}`)
return { path: extractPath }
} catch (error) {
logger.warn(
`Failed to upload and extract file: ${(error as Error).message}, retry ${retries + 1}/${maxRetries}`
)
if (retries === maxRetries - 1) {
throw error
}
} finally {
// Delete temporary ZIP file
if (zipPath && fs.existsSync(zipPath)) {
try {
fs.unlinkSync(zipPath)
logger.info(`Deleted temporary ZIP file: ${zipPath}`)
} catch (deleteError) {
logger.warn(`Failed to delete temporary ZIP file ${zipPath}:`, deleteError as Error)
}
}
}
retries++
await new Promise((resolve) => setTimeout(resolve, intervalMs))
}
throw new Error(`Processing timeout for file: ${file.id}`)
}
}

View File

@@ -5,7 +5,6 @@ import DefaultPreprocessProvider from './DefaultPreprocessProvider'
import Doc2xPreprocessProvider from './Doc2xPreprocessProvider'
import MineruPreprocessProvider from './MineruPreprocessProvider'
import MistralPreprocessProvider from './MistralPreprocessProvider'
import OpenMineruPreprocessProvider from './OpenMineruPreprocessProvider'
export default class PreprocessProviderFactory {
static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider {
switch (provider.id) {
@@ -15,8 +14,6 @@ export default class PreprocessProviderFactory {
return new MistralPreprocessProvider(provider)
case 'mineru':
return new MineruPreprocessProvider(provider, userId)
case 'open-mineru':
return new OpenMineruPreprocessProvider(provider, userId)
default:
return new DefaultPreprocessProvider(provider)
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
import { EventEmitter } from 'node:events'
import { createRequire } from 'node:module'
import { CanUseTool, McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { config as apiConfigService } from '@main/apiServer/config'
import { validateModelId } from '@main/apiServer/utils'
@@ -11,23 +11,10 @@ import { app } from 'electron'
import { GetAgentSessionResponse } from '../..'
import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
import { promptForToolApproval } from './tool-permissions'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
const require_ = createRequire(import.meta.url)
const logger = loggerService.withContext('ClaudeCodeService')
const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
type UserInputMessage = {
type: 'user'
parent_tool_use_id: string | null
session_id: string
message: {
role: 'user'
content: string
}
}
class ClaudeCodeStream extends EventEmitter implements AgentStream {
declare emit: (event: 'data', data: AgentStreamEvent) => boolean
@@ -112,41 +99,6 @@ class ClaudeCodeService implements AgentServiceInterface {
const errorChunks: string[] = []
const sessionAllowedTools = new Set<string>(session.allowed_tools ?? [])
const autoAllowTools = new Set<string>([...DEFAULT_AUTO_ALLOW_TOOLS, ...sessionAllowedTools])
const normalizeToolName = (name: string) => (name.startsWith('builtin_') ? name.slice('builtin_'.length) : name)
const canUseTool: CanUseTool = async (toolName, input, options) => {
logger.info('Handling tool permission check', {
toolName,
suggestionCount: options.suggestions?.length ?? 0
})
if (shouldAutoApproveTools) {
logger.debug('Auto-approving tool due to CHERRY_AUTO_ALLOW_TOOLS flag', { toolName })
return { behavior: 'allow', updatedInput: input }
}
if (options.signal.aborted) {
logger.debug('Permission request signal already aborted; denying tool', { toolName })
return {
behavior: 'deny',
message: 'Tool request was cancelled before prompting the user'
}
}
const normalizedToolName = normalizeToolName(toolName)
if (autoAllowTools.has(toolName) || autoAllowTools.has(normalizedToolName)) {
logger.debug('Auto-allowing tool from allowed list', {
toolName,
normalizedToolName
})
return { behavior: 'allow', updatedInput: input }
}
return promptForToolApproval(toolName, input, options)
}
// Build SDK options from parameters
const options: Options = {
abortController,
@@ -169,8 +121,7 @@ class ClaudeCodeService implements AgentServiceInterface {
includePartialMessages: true,
permissionMode: session.configuration?.permission_mode,
maxTurns: session.configuration?.max_turns,
allowedTools: session.allowed_tools,
canUseTool
allowedTools: session.allowed_tools
}
if (session.accessible_paths.length > 1) {
@@ -209,14 +160,9 @@ class ClaudeCodeService implements AgentServiceInterface {
resume: options.resume
})
const { stream: userInputStream, close: closeUserStream } = this.createUserMessageStream(
prompt,
abortController.signal
)
// Start async processing on the next tick so listeners can subscribe first
setImmediate(() => {
this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
this.processSDKQuery(prompt, options, aiStream, errorChunks).catch((error) => {
logger.error('Unhandled Claude Code stream error', {
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
})
@@ -230,90 +176,17 @@ class ClaudeCodeService implements AgentServiceInterface {
return aiStream
}
private createUserMessageStream(initialPrompt: string, abortSignal: AbortSignal) {
const queue: Array<UserInputMessage | null> = []
const waiters: Array<(value: UserInputMessage | null) => void> = []
let closed = false
const flushWaiters = (value: UserInputMessage | null) => {
const resolve = waiters.shift()
if (resolve) {
resolve(value)
return true
}
return false
}
const enqueue = (value: UserInputMessage | null) => {
if (closed) return
if (value === null) {
closed = true
}
if (!flushWaiters(value)) {
queue.push(value)
}
}
const close = () => {
if (closed) return
enqueue(null)
}
const onAbort = () => {
close()
}
if (abortSignal.aborted) {
close()
} else {
abortSignal.addEventListener('abort', onAbort, { once: true })
}
const iterator = (async function* () {
try {
while (true) {
let value: UserInputMessage | null
if (queue.length > 0) {
value = queue.shift() ?? null
} else if (closed) {
break
} else {
// Wait for next message or close signal
value = await new Promise<UserInputMessage | null>((resolve) => {
waiters.push(resolve)
})
}
if (value === null) {
break
}
yield value
}
} finally {
closed = true
abortSignal.removeEventListener('abort', onAbort)
while (waiters.length > 0) {
const resolve = waiters.shift()
resolve?.(null)
private async *userMessages(prompt: string) {
{
yield {
type: 'user' as const,
parent_tool_use_id: null,
session_id: '',
message: {
role: 'user' as const,
content: prompt
}
}
})()
enqueue({
type: 'user',
parent_tool_use_id: null,
session_id: '',
message: {
role: 'user',
content: initialPrompt
}
})
return {
stream: iterator,
enqueue,
close
}
}
@@ -321,8 +194,7 @@ class ClaudeCodeService implements AgentServiceInterface {
* Process SDK query and emit stream events
*/
private async processSDKQuery(
promptStream: AsyncIterable<UserInputMessage>,
closePromptStream: () => void,
prompt: string,
options: Options,
stream: ClaudeCodeStream,
errorChunks: string[]
@@ -330,10 +202,14 @@ class ClaudeCodeService implements AgentServiceInterface {
const jsonOutput: SDKMessage[] = []
let hasCompleted = false
const startTime = Date.now()
const streamState = new ClaudeStreamState()
const streamState = new ClaudeStreamState()
try {
for await (const message of query({ prompt: promptStream, options })) {
// Process streaming responses using SDK query
for await (const message of query({
prompt: this.userMessages(prompt),
options
})) {
if (hasCompleted) break
jsonOutput.push(message)
@@ -344,10 +220,10 @@ class ClaudeCodeService implements AgentServiceInterface {
content: JSON.stringify(message.message.content)
})
} else if (message.type === 'stream_event') {
// logger.silly('Claude stream event', {
// message,
// event: JSON.stringify(message.event)
// })
logger.silly('Claude stream event', {
message,
event: JSON.stringify(message.event)
})
} else {
logger.silly('Claude response', {
message,
@@ -355,6 +231,7 @@ class ClaudeCodeService implements AgentServiceInterface {
})
}
// Transform SDKMessage to UIMessageChunks
const chunks = transformSDKMessageToStreamParts(message, streamState)
for (const chunk of chunks) {
stream.emit('data', {
@@ -364,6 +241,7 @@ class ClaudeCodeService implements AgentServiceInterface {
}
}
// Successfully completed
hasCompleted = true
const duration = Date.now() - startTime
@@ -372,6 +250,7 @@ class ClaudeCodeService implements AgentServiceInterface {
messageCount: jsonOutput.length
})
// Emit completion event
stream.emit('data', {
type: 'complete'
})
@@ -380,6 +259,8 @@ class ClaudeCodeService implements AgentServiceInterface {
hasCompleted = true
const duration = Date.now() - startTime
// Check if this is an abort error
const errorObj = error as any
const isAborted =
errorObj?.name === 'AbortError' ||
@@ -388,6 +269,7 @@ class ClaudeCodeService implements AgentServiceInterface {
if (isAborted) {
logger.info('SDK query aborted by client disconnect', { duration })
// Simply cleanup and return - don't emit error events
stream.emit('data', {
type: 'cancelled',
error: new Error('Request aborted by client')
@@ -402,13 +284,11 @@ class ClaudeCodeService implements AgentServiceInterface {
error: errorObj instanceof Error ? { name: errorObj.name, message: errorObj.message } : String(errorObj),
stderr: errorChunks
})
// Emit error event
stream.emit('data', {
type: 'error',
error: new Error(errorMessage)
})
} finally {
closePromptStream()
}
}
}

View File

@@ -1,323 +0,0 @@
import { randomUUID } from 'node:crypto'
import { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { IpcChannel } from '@shared/IpcChannel'
import { ipcMain } from 'electron'
import { windowService } from '../../../WindowService'
import { builtinTools } from './tools'
const logger = loggerService.withContext('ClaudeCodeService')
const TOOL_APPROVAL_TIMEOUT_MS = 30_000
const MAX_PREVIEW_LENGTH = 2_000
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
type ToolPermissionBehavior = 'allow' | 'deny'
type ToolPermissionResponsePayload = {
requestId: string
behavior: ToolPermissionBehavior
updatedInput?: unknown
message?: string
updatedPermissions?: PermissionUpdate[]
}
type PendingPermissionRequest = {
fulfill: (update: PermissionResult) => void
timeout: NodeJS.Timeout
signal?: AbortSignal
abortListener?: () => void
originalInput: Record<string, unknown>
toolName: string
}
type RendererPermissionRequestPayload = {
requestId: string
toolName: string
toolId: string
description?: string
requiresPermissions: boolean
input: Record<string, unknown>
inputPreview: string
createdAt: number
expiresAt: number
suggestions: PermissionUpdate[]
}
type RendererPermissionResultPayload = {
requestId: string
behavior: ToolPermissionBehavior
message?: string
reason: 'response' | 'timeout' | 'aborted' | 'no-window'
}
const pendingRequests = new Map<string, PendingPermissionRequest>()
let ipcHandlersInitialized = false
const jsonReplacer = (_key: string, value: unknown) => {
if (typeof value === 'bigint') return value.toString()
if (value instanceof Map) return Object.fromEntries(value.entries())
if (value instanceof Set) return Array.from(value.values())
if (value instanceof Date) return value.toISOString()
if (typeof value === 'function') return undefined
if (value === undefined) return undefined
return value
}
const sanitizeStructuredData = <T>(value: T): T => {
try {
return JSON.parse(JSON.stringify(value, jsonReplacer)) as T
} catch (error) {
logger.warn('Failed to sanitize structured data for tool permission payload', {
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
})
return value
}
}
const buildInputPreview = (value: unknown): string => {
let preview: string
try {
preview = JSON.stringify(value, null, 2)
} catch (error) {
preview = typeof value === 'string' ? value : String(value)
}
if (preview.length > MAX_PREVIEW_LENGTH) {
preview = `${preview.slice(0, MAX_PREVIEW_LENGTH)}...`
}
return preview
}
const broadcastToRenderer = (
channel: IpcChannel,
payload: RendererPermissionRequestPayload | RendererPermissionResultPayload
): boolean => {
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
logger.warn('Unable to send agent tool permission payload main window unavailable', {
channel,
requestId: 'requestId' in payload ? payload.requestId : undefined
})
return false
}
mainWindow.webContents.send(channel, payload)
return true
}
const finalizeRequest = (
requestId: string,
update: PermissionResult,
reason: RendererPermissionResultPayload['reason']
) => {
const pending = pendingRequests.get(requestId)
if (!pending) {
logger.debug('Attempted to finalize unknown tool permission request', { requestId, reason })
return false
}
logger.debug('Finalizing tool permission request', {
requestId,
toolName: pending.toolName,
behavior: update.behavior,
reason
})
pendingRequests.delete(requestId)
clearTimeout(pending.timeout)
if (pending.signal && pending.abortListener) {
pending.signal.removeEventListener('abort', pending.abortListener)
}
pending.fulfill(update)
const resultPayload: RendererPermissionResultPayload = {
requestId,
behavior: update.behavior,
message: update.behavior === 'deny' ? update.message : undefined,
reason
}
const dispatched = broadcastToRenderer(IpcChannel.AgentToolPermission_Result, resultPayload)
logger.debug('Sent tool permission result to renderer', {
requestId,
dispatched
})
return true
}
const ensureIpcHandlersRegistered = () => {
if (ipcHandlersInitialized) return
ipcHandlersInitialized = true
ipcMain.handle(IpcChannel.AgentToolPermission_Response, async (_event, payload: ToolPermissionResponsePayload) => {
logger.debug('main received AgentToolPermission_Response', payload)
const { requestId, behavior, updatedInput, message } = payload
const pending = pendingRequests.get(requestId)
if (!pending) {
logger.warn('Received renderer tool permission response for unknown request', { requestId })
return { success: false, error: 'unknown-request' }
}
logger.debug('Received renderer response for tool permission', {
requestId,
toolName: pending.toolName,
behavior,
hasUpdatedPermissions: Array.isArray(payload.updatedPermissions) && payload.updatedPermissions.length > 0
})
const maybeUpdatedInput =
updatedInput && typeof updatedInput === 'object' && !Array.isArray(updatedInput)
? (updatedInput as Record<string, unknown>)
: pending.originalInput
const sanitizedUpdatedPermissions = Array.isArray(payload.updatedPermissions)
? payload.updatedPermissions.map((perm) => sanitizeStructuredData(perm))
: undefined
const finalUpdate: PermissionResult =
behavior === 'allow'
? {
behavior: 'allow',
updatedInput: sanitizeStructuredData(maybeUpdatedInput),
updatedPermissions: sanitizedUpdatedPermissions
}
: {
behavior: 'deny',
message: message ?? 'User denied permission for this tool'
}
finalizeRequest(requestId, finalUpdate, 'response')
return { success: true }
})
}
export async function promptForToolApproval(
toolName: string,
input: Record<string, unknown>,
options?: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
): Promise<PermissionResult> {
if (shouldAutoApproveTools) {
logger.debug('promptForToolApproval auto-approving tool for test', {
toolName
})
return { behavior: 'allow', updatedInput: input }
}
ensureIpcHandlersRegistered()
if (options?.signal?.aborted) {
logger.info('Skipping tool approval prompt because request signal is already aborted', { toolName })
return { behavior: 'deny', message: 'Tool request was cancelled before prompting the user' }
}
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
logger.warn('Denying tool usage because no renderer window is available to obtain approval', { toolName })
return { behavior: 'deny', message: 'Unable to request approval renderer not ready' }
}
const toolMetadata = builtinTools.find((tool) => tool.name === toolName || tool.id === toolName)
const sanitizedInput = sanitizeStructuredData(input)
const inputPreview = buildInputPreview(sanitizedInput)
const sanitizedSuggestions = (options?.suggestions ?? []).map((suggestion) => sanitizeStructuredData(suggestion))
const requestId = randomUUID()
const createdAt = Date.now()
const expiresAt = createdAt + TOOL_APPROVAL_TIMEOUT_MS
logger.info('Requesting user approval for tool usage', {
requestId,
toolName,
description: toolMetadata?.description
})
const requestPayload: RendererPermissionRequestPayload = {
requestId,
toolName,
toolId: toolMetadata?.id ?? toolName,
description: toolMetadata?.description,
requiresPermissions: toolMetadata?.requirePermissions ?? false,
input: sanitizedInput,
inputPreview,
createdAt,
expiresAt,
suggestions: sanitizedSuggestions
}
const defaultDenyUpdate: PermissionResult = { behavior: 'deny', message: 'Tool request aborted before user decision' }
logger.debug('Registering tool permission request', {
requestId,
toolName,
requiresPermissions: requestPayload.requiresPermissions,
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
suggestionCount: sanitizedSuggestions.length
})
return new Promise<PermissionResult>((resolve) => {
const timeout = setTimeout(() => {
logger.info('User tool permission request timed out', { requestId, toolName })
finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout')
}, TOOL_APPROVAL_TIMEOUT_MS)
const pending: PendingPermissionRequest = {
fulfill: resolve,
timeout,
originalInput: sanitizedInput,
toolName,
signal: options?.signal
}
if (options?.signal) {
const abortListener = () => {
logger.info('Tool permission request aborted before user responded', { requestId, toolName })
finalizeRequest(requestId, defaultDenyUpdate, 'aborted')
}
pending.abortListener = abortListener
options.signal.addEventListener('abort', abortListener, { once: true })
}
pendingRequests.set(requestId, pending)
logger.debug('Pending tool permission request count', {
count: pendingRequests.size
})
const sent = broadcastToRenderer(IpcChannel.AgentToolPermission_Request, requestPayload)
logger.debug('Broadcasted tool permission request to renderer', {
requestId,
toolName,
sent
})
if (!sent) {
finalizeRequest(
requestId,
{
behavior: 'deny',
message: 'Unable to request approval because the renderer window is unavailable'
},
'no-window'
)
}
})
}

View File

@@ -1,223 +0,0 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import { loggerService } from '@logger'
import { isPathInside } from './file'
const logger = loggerService.withContext('Utils:FileOperations')
const MAX_RECURSION_DEPTH = 1000
/**
* Recursively copy a directory and all its contents
* @param source - Source directory path (must be absolute)
* @param destination - Destination directory path (must be absolute)
* @param options - Copy options
* @param depth - Current recursion depth (internal use)
* @throws If copy operation fails or paths are invalid
*/
export async function copyDirectoryRecursive(
source: string,
destination: string,
options?: { allowedBasePath?: string },
depth = 0
): Promise<void> {
// Input validation
if (!source || !destination) {
throw new TypeError('Source and destination paths are required')
}
if (!path.isAbsolute(source) || !path.isAbsolute(destination)) {
throw new Error('Source and destination paths must be absolute')
}
// Depth limit to prevent stack overflow
if (depth > MAX_RECURSION_DEPTH) {
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
}
// Path validation - ensure operations stay within allowed boundaries
if (options?.allowedBasePath) {
if (!isPathInside(source, options.allowedBasePath)) {
throw new Error(`Source path is outside allowed directory: ${source}`)
}
if (!isPathInside(destination, options.allowedBasePath)) {
throw new Error(`Destination path is outside allowed directory: ${destination}`)
}
}
try {
// Verify source exists and is a directory
const sourceStats = await fs.promises.lstat(source)
if (!sourceStats.isDirectory()) {
throw new Error(`Source is not a directory: ${source}`)
}
// Create destination directory
await fs.promises.mkdir(destination, { recursive: true })
logger.debug('Created destination directory', { destination })
// Read source directory
const entries = await fs.promises.readdir(source, { withFileTypes: true })
// Copy each entry
for (const entry of entries) {
const sourcePath = path.join(source, entry.name)
const destPath = path.join(destination, entry.name)
// Use lstat to detect symlinks and prevent following them
const entryStats = await fs.promises.lstat(sourcePath)
if (entryStats.isSymbolicLink()) {
logger.warn('Skipping symlink for security', { path: sourcePath })
continue
}
if (entryStats.isDirectory()) {
// Recursively copy subdirectory
await copyDirectoryRecursive(sourcePath, destPath, options, depth + 1)
} else if (entryStats.isFile()) {
// Copy file with error handling for race conditions
try {
await fs.promises.copyFile(sourcePath, destPath)
// Preserve file permissions
await fs.promises.chmod(destPath, entryStats.mode)
logger.debug('Copied file', { from: sourcePath, to: destPath })
} catch (error) {
// Handle race condition where file was deleted during copy
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.warn('File disappeared during copy', { sourcePath })
continue
}
throw error
}
} else {
// Skip special files (pipes, sockets, devices, etc.)
logger.debug('Skipping special file', { path: sourcePath })
}
}
logger.info('Directory copied successfully', { from: source, to: destination, depth })
} catch (error) {
logger.error('Failed to copy directory', { source, destination, depth, error })
throw error
}
}
/**
* Recursively delete a directory and all its contents
* @param dirPath - Directory path to delete (must be absolute)
* @param options - Delete options
* @throws If deletion fails or path is invalid
*/
export async function deleteDirectoryRecursive(dirPath: string, options?: { allowedBasePath?: string }): Promise<void> {
// Input validation
if (!dirPath) {
throw new TypeError('Directory path is required')
}
if (!path.isAbsolute(dirPath)) {
throw new Error('Directory path must be absolute')
}
// Path validation - ensure operations stay within allowed boundaries
if (options?.allowedBasePath) {
if (!isPathInside(dirPath, options.allowedBasePath)) {
throw new Error(`Path is outside allowed directory: ${dirPath}`)
}
}
try {
// Verify path exists before attempting deletion
try {
const stats = await fs.promises.lstat(dirPath)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${dirPath}`)
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.warn('Directory already deleted', { dirPath })
return
}
throw error
}
// Node.js 14.14+ has fs.rm with recursive option
await fs.promises.rm(dirPath, { recursive: true, force: true })
logger.info('Directory deleted successfully', { dirPath })
} catch (error) {
logger.error('Failed to delete directory', { dirPath, error })
throw error
}
}
/**
* Get total size of a directory (in bytes)
* @param dirPath - Directory path (must be absolute)
* @param options - Size calculation options
* @param depth - Current recursion depth (internal use)
* @returns Total size in bytes
* @throws If size calculation fails or path is invalid
*/
export async function getDirectorySize(
dirPath: string,
options?: { allowedBasePath?: string },
depth = 0
): Promise<number> {
// Input validation
if (!dirPath) {
throw new TypeError('Directory path is required')
}
if (!path.isAbsolute(dirPath)) {
throw new Error('Directory path must be absolute')
}
// Depth limit to prevent stack overflow
if (depth > MAX_RECURSION_DEPTH) {
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
}
// Path validation - ensure operations stay within allowed boundaries
if (options?.allowedBasePath) {
if (!isPathInside(dirPath, options.allowedBasePath)) {
throw new Error(`Path is outside allowed directory: ${dirPath}`)
}
}
let totalSize = 0
try {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name)
// Use lstat to detect symlinks and prevent following them
const entryStats = await fs.promises.lstat(entryPath)
if (entryStats.isSymbolicLink()) {
logger.debug('Skipping symlink in size calculation', { path: entryPath })
continue
}
if (entryStats.isDirectory()) {
// Recursively get size of subdirectory
totalSize += await getDirectorySize(entryPath, options, depth + 1)
} else if (entryStats.isFile()) {
// Get file size from lstat (already have it)
totalSize += entryStats.size
} else {
// Skip special files
logger.debug('Skipping special file in size calculation', { path: entryPath })
}
}
logger.debug('Calculated directory size', { dirPath, size: totalSize, depth })
return totalSize
} catch (error) {
logger.error('Failed to calculate directory size', { dirPath, depth, error })
throw error
}
}

View File

@@ -1,309 +0,0 @@
import { loggerService } from '@logger'
import type { PluginError, PluginMetadata } from '@types'
import * as crypto from 'crypto'
import * as fs from 'fs'
import matter from 'gray-matter'
import * as yaml from 'js-yaml'
import * as path from 'path'
import { getDirectorySize } from './fileOperations'
const logger = loggerService.withContext('Utils:MarkdownParser')
/**
* Parse plugin metadata from a markdown file with frontmatter
* @param filePath Absolute path to the markdown file
* @param sourcePath Relative source path from plugins directory
* @param category Category name derived from parent folder
* @param type Plugin type (agent or command)
* @returns PluginMetadata object with parsed frontmatter and file info
*/
export async function parsePluginMetadata(
filePath: string,
sourcePath: string,
category: string,
type: 'agent' | 'command'
): Promise<PluginMetadata> {
const content = await fs.promises.readFile(filePath, 'utf8')
const stats = await fs.promises.stat(filePath)
// Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks
const { data } = matter(content, {
engines: {
yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object
}
})
// Calculate content hash for integrity checking
const contentHash = crypto.createHash('sha256').update(content).digest('hex')
// Extract filename
const filename = path.basename(filePath)
// Parse allowed_tools - handle both array and comma-separated string
let allowedTools: string[] | undefined
if (data['allowed-tools'] || data.allowed_tools) {
const toolsData = data['allowed-tools'] || data.allowed_tools
if (Array.isArray(toolsData)) {
allowedTools = toolsData
} else if (typeof toolsData === 'string') {
allowedTools = toolsData
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Parse tools - similar handling
let tools: string[] | undefined
if (data.tools) {
if (Array.isArray(data.tools)) {
tools = data.tools
} else if (typeof data.tools === 'string') {
tools = data.tools
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Parse tags
let tags: string[] | undefined
if (data.tags) {
if (Array.isArray(data.tags)) {
tags = data.tags
} else if (typeof data.tags === 'string') {
tags = data.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
return {
sourcePath,
filename,
name: data.name || filename.replace(/\.md$/, ''),
description: data.description,
allowed_tools: allowedTools,
tools,
category,
type,
tags,
version: data.version,
author: data.author,
size: stats.size,
contentHash
}
}
/**
* Recursively find all directories containing SKILL.md
*
* @param dirPath - Directory to search in
* @param basePath - Base path for calculating relative source paths
* @param maxDepth - Maximum depth to search (default: 10 to prevent infinite loops)
* @param currentDepth - Current search depth (used internally)
* @returns Array of objects with absolute folder path and relative source path
*/
export async function findAllSkillDirectories(
dirPath: string,
basePath: string,
maxDepth = 10,
currentDepth = 0
): Promise<Array<{ folderPath: string; sourcePath: string }>> {
const results: Array<{ folderPath: string; sourcePath: string }> = []
// Prevent excessive recursion
if (currentDepth > maxDepth) {
return results
}
// Check if current directory contains SKILL.md
const skillMdPath = path.join(dirPath, 'SKILL.md')
try {
await fs.promises.stat(skillMdPath)
// Found SKILL.md in this directory
const relativePath = path.relative(basePath, dirPath)
results.push({
folderPath: dirPath,
sourcePath: relativePath
})
return results
} catch {
// SKILL.md not in current directory
}
// Only search subdirectories if current directory doesn't have SKILL.md
try {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const subDirPath = path.join(dirPath, entry.name)
const subResults = await findAllSkillDirectories(subDirPath, basePath, maxDepth, currentDepth + 1)
results.push(...subResults)
}
}
} catch (error: any) {
// Ignore errors when reading subdirectories (e.g., permission denied)
logger.debug('Failed to read subdirectory during skill search', {
dirPath,
error: error.message
})
}
return results
}
/**
* Parse metadata from SKILL.md within a skill folder
*
* @param skillFolderPath - Absolute path to skill folder (must be absolute and contain SKILL.md)
* @param sourcePath - Relative path from plugins base (e.g., "skills/my-skill")
* @param category - Category name (typically "skills" for flat structure)
* @returns PluginMetadata with folder name as filename (no extension)
* @throws PluginError if SKILL.md not found or parsing fails
*/
export async function parseSkillMetadata(
skillFolderPath: string,
sourcePath: string,
category: string
): Promise<PluginMetadata> {
// Input validation
if (!skillFolderPath || !path.isAbsolute(skillFolderPath)) {
throw {
type: 'INVALID_METADATA',
reason: 'Skill folder path must be absolute',
path: skillFolderPath
} as PluginError
}
// Look for SKILL.md directly in this folder (no recursion)
const skillMdPath = path.join(skillFolderPath, 'SKILL.md')
// Check if SKILL.md exists
try {
await fs.promises.stat(skillMdPath)
} catch (error: any) {
if (error.code === 'ENOENT') {
logger.error('SKILL.md not found in skill folder', { skillMdPath })
throw {
type: 'FILE_NOT_FOUND',
path: skillMdPath,
message: 'SKILL.md not found in skill folder'
} as PluginError
}
throw error
}
// Read SKILL.md content
let content: string
try {
content = await fs.promises.readFile(skillMdPath, 'utf8')
} catch (error: any) {
logger.error('Failed to read SKILL.md', { skillMdPath, error })
throw {
type: 'READ_FAILED',
path: skillMdPath,
reason: error.message || 'Unknown error'
} as PluginError
}
// Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks
let data: any
try {
const parsed = matter(content, {
engines: {
yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object
}
})
data = parsed.data
} catch (error: any) {
logger.error('Failed to parse SKILL.md frontmatter', { skillMdPath, error })
throw {
type: 'INVALID_METADATA',
reason: `Failed to parse frontmatter: ${error.message}`,
path: skillMdPath
} as PluginError
}
// Calculate hash of SKILL.md only (not entire folder)
// Note: This means changes to other files in the skill won't trigger cache invalidation
// This is intentional - only SKILL.md metadata changes should trigger updates
const contentHash = crypto.createHash('sha256').update(content).digest('hex')
// Get folder name as identifier (NO EXTENSION)
const folderName = path.basename(skillFolderPath)
// Get total folder size
let folderSize: number
try {
folderSize = await getDirectorySize(skillFolderPath)
} catch (error: any) {
logger.error('Failed to calculate skill folder size', { skillFolderPath, error })
// Use 0 as fallback instead of failing completely
folderSize = 0
}
// Parse tools (skills use 'tools', not 'allowed_tools')
let tools: string[] | undefined
if (data.tools) {
if (Array.isArray(data.tools)) {
// Validate all elements are strings
tools = data.tools.filter((t) => typeof t === 'string')
} else if (typeof data.tools === 'string') {
tools = data.tools
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Parse tags
let tags: string[] | undefined
if (data.tags) {
if (Array.isArray(data.tags)) {
// Validate all elements are strings
tags = data.tags.filter((t) => typeof t === 'string')
} else if (typeof data.tags === 'string') {
tags = data.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Validate and sanitize name
const name = typeof data.name === 'string' && data.name.trim() ? data.name.trim() : folderName
// Validate and sanitize description
const description =
typeof data.description === 'string' && data.description.trim() ? data.description.trim() : undefined
// Validate version and author
const version = typeof data.version === 'string' ? data.version : undefined
const author = typeof data.author === 'string' ? data.author : undefined
logger.debug('Successfully parsed skill metadata', {
skillFolderPath,
folderName,
size: folderSize
})
return {
sourcePath, // e.g., "skills/my-skill"
filename: folderName, // e.g., "my-skill" (folder name, NO .md extension)
name,
description,
tools,
category, // "skills" for flat structure
type: 'skill',
tags,
version,
author,
size: folderSize,
contentHash // Hash of SKILL.md content only
}
}

View File

@@ -1,4 +1,3 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { electronAPI } from '@electron-toolkit/preload'
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
@@ -36,15 +35,6 @@ import {
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
import { CreateDirectoryOptions } from 'webdav'
import type {
InstalledPlugin,
InstallPluginOptions,
ListAvailablePluginsResult,
PluginMetadata,
PluginResult,
UninstallPluginOptions,
WritePluginContentOptions
} from '../renderer/src/types/plugin'
import type { ActionItem } from '../renderer/src/types/selectionTypes'
export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) {
@@ -215,7 +205,14 @@ const api = {
ipcRenderer.on('file-change', listener)
return () => ipcRenderer.off('file-change', listener)
},
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path)
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path),
// Image export specific methods
readBinary: (filePath: string): Promise<Buffer> => ipcRenderer.invoke(IpcChannel.File_ReadBinary, filePath),
writeBinary: (filePath: string, buffer: Buffer): Promise<void> =>
ipcRenderer.invoke(IpcChannel.File_WriteBinary, filePath, buffer),
copyFile: (sourcePath: string, destPath: string): Promise<void> =>
ipcRenderer.invoke(IpcChannel.File_CopyFile, sourcePath, destPath),
createDirectory: (dirPath: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_CreateDirectory, dirPath)
},
fs: {
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
@@ -439,15 +436,6 @@ const api = {
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
},
agentTools: {
respondToPermission: (payload: {
requestId: string
behavior: 'allow' | 'deny'
updatedInput?: Record<string, unknown>
message?: string
updatedPermissions?: PermissionUpdate[]
}) => ipcRenderer.invoke(IpcChannel.AgentToolPermission_Response, payload)
},
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text),
setDisableHardwareAcceleration: (isDisable: boolean) =>
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable),
@@ -526,21 +514,6 @@ const api = {
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
},
claudeCodePlugin: {
listAvailable: (): Promise<PluginResult<ListAvailablePluginsResult>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListAvailable),
install: (options: InstallPluginOptions): Promise<PluginResult<PluginMetadata>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Install, options),
uninstall: (options: UninstallPluginOptions): Promise<PluginResult<void>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Uninstall, options),
listInstalled: (agentId: string): Promise<PluginResult<InstalledPlugin[]>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListInstalled, agentId),
invalidateCache: (): Promise<PluginResult<void>> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_InvalidateCache),
readContent: (sourcePath: string): Promise<PluginResult<string>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath),
writeContent: (options: WritePluginContentOptions): Promise<PluginResult<void>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options)
}
}

View File

@@ -6,14 +6,7 @@
import { loggerService } from '@logger'
import { processKnowledgeReferences } from '@renderer/services/KnowledgeService'
import {
BaseTool,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
MCPToolResultContent,
NormalToolResponse
} from '@renderer/types'
import { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import type { ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai'
@@ -261,7 +254,6 @@ export class ToolCallChunkHandler {
type: 'tool-result'
} & TypedToolResult<ToolSet>
): void {
// TODO: 基于AI SDK为供应商内置工具做更好的展示和类型安全处理
const { toolCallId, output, input } = chunk
if (!toolCallId) {
@@ -307,7 +299,12 @@ export class ToolCallChunkHandler {
responses: [toolResponse]
})
const images = extractImagesFromToolOutput(toolResponse.response)
const images: string[] = []
for (const content of toolResponse.response?.content || []) {
if (content.type === 'image' && content.data) {
images.push(`data:${content.mimeType};base64,${content.data}`)
}
}
if (images.length) {
this.onChunk({
@@ -354,41 +351,3 @@ export class ToolCallChunkHandler {
}
export const addActiveToolCall = ToolCallChunkHandler.addActiveToolCall.bind(ToolCallChunkHandler)
function extractImagesFromToolOutput(output: unknown): string[] {
if (!output) {
return []
}
const contents: unknown[] = []
if (isMcpCallToolResponse(output)) {
contents.push(...output.content)
} else if (Array.isArray(output)) {
contents.push(...output)
} else if (hasContentArray(output)) {
contents.push(...output.content)
}
return contents
.filter(isMcpImageContent)
.map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`)
}
function isMcpCallToolResponse(value: unknown): value is MCPCallToolResponse {
return typeof value === 'object' && value !== null && Array.isArray((value as MCPCallToolResponse).content)
}
function hasContentArray(value: unknown): value is { content: unknown[] } {
return typeof value === 'object' && value !== null && Array.isArray((value as { content?: unknown }).content)
}
function isMcpImageContent(content: unknown): content is MCPToolResultContent & { data: string } {
if (typeof content !== 'object' || content === null) {
return false
}
const resultContent = content as MCPToolResultContent
return resultContent.type === 'image' && typeof resultContent.data === 'string'
}

View File

@@ -14,7 +14,6 @@ import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import { SUPPORTED_IMAGE_ENDPOINT_LIST } from '@renderer/utils'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
@@ -78,7 +77,7 @@ export default class ModernAiProvider {
return this.actualProvider
}
public async completions(modelId: string, params: StreamTextParams, providerConfig: ModernAiProviderConfig) {
public async completions(modelId: string, params: StreamTextParams, config: ModernAiProviderConfig) {
// 检查model是否存在
if (!this.model) {
throw new Error('Model is required for completions. Please use constructor with model parameter.')
@@ -86,10 +85,7 @@ export default class ModernAiProvider {
// 每次请求时重新生成配置以确保API key轮换生效
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
logger.debug('Generated provider config for completions', this.config)
if (SUPPORTED_IMAGE_ENDPOINT_LIST.includes(this.config.options.endpoint)) {
providerConfig.isImageGenerationEndpoint = true
}
// 准备特殊配置
await prepareSpecialProviderConfig(this.actualProvider, this.config)
@@ -100,13 +96,12 @@ export default class ModernAiProvider {
// 提前构建中间件
const middlewares = buildAiSdkMiddlewares({
...providerConfig,
provider: this.actualProvider,
assistant: providerConfig.assistant
...config,
provider: this.actualProvider
})
logger.debug('Built middlewares in completions', {
middlewareCount: middlewares.length,
isImageGeneration: providerConfig.isImageGenerationEndpoint
isImageGeneration: config.isImageGenerationEndpoint
})
if (!this.localProvider) {
throw new Error('Local provider not created')
@@ -114,7 +109,7 @@ export default class ModernAiProvider {
// 根据endpoint类型创建对应的模型
let model: AiSdkModel | undefined
if (providerConfig.isImageGenerationEndpoint) {
if (config.isImageGenerationEndpoint) {
model = this.localProvider.imageModel(modelId)
} else {
model = this.localProvider.languageModel(modelId)
@@ -130,15 +125,15 @@ export default class ModernAiProvider {
params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])]
}
if (providerConfig.topicId && getEnableDeveloperMode()) {
if (config.topicId && getEnableDeveloperMode()) {
// TypeScript类型窄化确保topicId是string类型
const traceConfig = {
...providerConfig,
topicId: providerConfig.topicId
...config,
topicId: config.topicId
}
return await this._completionsForTrace(model, params, traceConfig)
} else {
return await this._completionsOrImageGeneration(model, params, providerConfig)
return await this._completionsOrImageGeneration(model, params, config)
}
}

View File

@@ -1,4 +1,5 @@
import { Provider } from '@renderer/types'
import { isOpenAIProvider } from '@renderer/utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AihubmixAPIClient } from '../aihubmix/AihubmixAPIClient'
@@ -201,4 +202,36 @@ describe('ApiClientFactory', () => {
expect(client).toBeDefined()
})
})
describe('isOpenAIProvider', () => {
it('should return true for openai type', () => {
const provider = createTestProvider('openai', 'openai')
expect(isOpenAIProvider(provider)).toBe(true)
})
it('should return true for azure-openai type', () => {
const provider = createTestProvider('azure-openai', 'azure-openai')
expect(isOpenAIProvider(provider)).toBe(true)
})
it('should return true for unknown type (fallback to OpenAI)', () => {
const provider = createTestProvider('unknown', 'unknown')
expect(isOpenAIProvider(provider)).toBe(true)
})
it('should return false for vertexai type', () => {
const provider = createTestProvider('vertex', 'vertexai')
expect(isOpenAIProvider(provider)).toBe(false)
})
it('should return false for anthropic type', () => {
const provider = createTestProvider('anthropic', 'anthropic')
expect(isOpenAIProvider(provider)).toBe(false)
})
it('should return false for gemini type', () => {
const provider = createTestProvider('gemini', 'gemini')
expect(isOpenAIProvider(provider)).toBe(false)
})
})
})

View File

@@ -342,28 +342,29 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
}
switch (message.type) {
case 'function_call_output': {
let str = ''
if (typeof message.output === 'string') {
str = message.output
} else {
for (const part of message.output) {
switch (part.type) {
case 'input_text':
str += part.text
break
case 'input_image':
str += part.image_url || ''
break
case 'input_file':
str += part.file_data || ''
break
case 'function_call_output':
{
let str = ''
if (typeof message.output === 'string') {
str = message.output
} else {
for (const part of message.output) {
switch (part.type) {
case 'input_text':
str += part.text
break
case 'input_image':
str += part.image_url || ''
break
case 'input_file':
str += part.file_data || ''
break
}
}
}
sum += estimateTextTokens(str)
}
sum += estimateTextTokens(str)
break
}
case 'function_call':
sum += estimateTextTokens(message.arguments)
break

View File

@@ -78,12 +78,6 @@ export const ImageGenerationMiddleware: CompletionsMiddleware =
const options = { signal, timeout: defaultTimeout }
if (imageFiles.length > 0) {
const model = assistant.model
const provider = context.apiClientInstance.provider
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/dall-e?tabs=gpt-image-1#call-the-image-edit-api
if (model.id.toLowerCase().includes('gpt-image-1-mini') && provider.type === 'azure-openai') {
throw new Error('Azure OpenAI GPT-Image-1-Mini model does not support image editing.')
}
response = await sdk.images.edit(
{
model: assistant.model.id,

View File

@@ -1,16 +1,10 @@
import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
import { loggerService } from '@logger'
import { isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
import { isSupportEnableThinkingProvider } from '@renderer/config/providers'
import { type Assistant, MCPTool, type Message, type Model, type Provider } from '@renderer/types'
import type { MCPTool, Message, Model, Provider } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai'
import { isEmpty } from 'lodash'
import { isOpenRouterGeminiGenerateImageModel } from '../utils/image'
import { noThinkMiddleware } from './noThinkMiddleware'
import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMiddleware'
import { qwenThinkingMiddleware } from './qwenThinkingMiddleware'
import { toolChoiceMiddleware } from './toolChoiceMiddleware'
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
@@ -23,7 +17,6 @@ export interface AiSdkMiddlewareConfig {
onChunk?: (chunk: Chunk) => void
model?: Model
provider?: Provider
assistant?: Assistant
enableReasoning: boolean
// 是否开启提示词工具调用
isPromptToolUse: boolean
@@ -132,7 +125,7 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo
const builder = new AiSdkMiddlewareBuilder()
// 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库)
if (!isEmpty(config.assistant?.knowledge_bases?.map((base) => base.id)) && config.knowledgeRecognition !== 'on') {
if (config.knowledgeRecognition === 'off') {
builder.add({
name: 'force-knowledge-first',
middleware: toolChoiceMiddleware('builtin_knowledge_search')
@@ -220,31 +213,15 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
/**
* 添加模型特定的中间件
*/
function addModelSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: AiSdkMiddlewareConfig): void {
if (!config.model || !config.provider) return
// Qwen models on providers that don't support enable_thinking parameter (like Ollama, LM Studio, NVIDIA)
// Use /think or /no_think suffix to control thinking mode
if (
config.provider &&
isSupportedThinkingTokenQwenModel(config.model) &&
!isSupportEnableThinkingProvider(config.provider)
) {
const enableThinking = config.assistant?.settings?.reasoning_effort !== undefined
builder.add({
name: 'qwen-thinking-control',
middleware: qwenThinkingMiddleware(enableThinking)
})
logger.debug(`Added Qwen thinking middleware with thinking ${enableThinking ? 'enabled' : 'disabled'}`)
}
function addModelSpecificMiddlewares(_: AiSdkMiddlewareBuilder, config: AiSdkMiddlewareConfig): void {
if (!config.model) return
// 可以根据模型ID或特性添加特定中间件
// 例如:图像生成模型、多模态模型等
if (isOpenRouterGeminiGenerateImageModel(config.model, config.provider)) {
builder.add({
name: 'openrouter-gemini-image-generation',
middleware: openrouterGenerateImageMiddleware()
})
// 示例:某些模型需要特殊处理
if (config.model.id.includes('dalle') || config.model.id.includes('midjourney')) {
// 图像生成相关中间件
}
}

View File

@@ -1,33 +0,0 @@
import { LanguageModelMiddleware } from 'ai'
/**
* Returns a LanguageModelMiddleware that ensures the OpenRouter provider is configured to support both
* image and text modalities.
* https://openrouter.ai/docs/features/multimodal/image-generation
*
* Remarks:
* - The middleware declares middlewareVersion as 'v2'.
* - transformParams asynchronously clones the incoming params and sets
* providerOptions.openrouter.modalities = ['image', 'text'], preserving other providerOptions and
* openrouter fields when present.
* - Intended to ensure the provider can handle image and text generation without altering other
* parameter values.
*
* @returns LanguageModelMiddleware - a middleware that augments providerOptions for OpenRouter to include image and text modalities.
*/
export function openrouterGenerateImageMiddleware(): LanguageModelMiddleware {
return {
middlewareVersion: 'v2',
transformParams: async ({ params }) => {
const transformedParams = { ...params }
transformedParams.providerOptions = {
...transformedParams.providerOptions,
openrouter: { ...transformedParams.providerOptions?.openrouter, modalities: ['image', 'text'] }
}
transformedParams
return transformedParams
}
}
}

View File

@@ -1,39 +0,0 @@
import { LanguageModelMiddleware } from 'ai'
/**
* Qwen Thinking Middleware
* Controls thinking mode for Qwen models on providers that don't support enable_thinking parameter (like Ollama)
* Appends '/think' or '/no_think' suffix to user messages based on reasoning_effort setting
* @param enableThinking - Whether thinking mode is enabled (based on reasoning_effort !== undefined)
* @returns LanguageModelMiddleware
*/
export function qwenThinkingMiddleware(enableThinking: boolean): LanguageModelMiddleware {
const suffix = enableThinking ? ' /think' : ' /no_think'
return {
middlewareVersion: 'v2',
transformParams: async ({ params }) => {
const transformedParams = { ...params }
// Process messages in prompt
if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) {
transformedParams.prompt = transformedParams.prompt.map((message) => {
// Only process user messages
if (message.role === 'user') {
// Process content array
if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part.type === 'text' && !part.text.endsWith('/think') && !part.text.endsWith('/no_think')) {
part.text += suffix
}
}
}
}
return message
})
}
return transformedParams
}
}
}

View File

@@ -1,5 +1,5 @@
import { AiPlugin } from '@cherrystudio/ai-core'
import { createPromptToolUsePlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
import { createPromptToolUsePlugin, googleToolsPlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
import { loggerService } from '@logger'
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
import { Assistant } from '@renderer/types'
@@ -68,9 +68,9 @@ export function buildPlugins(
)
}
// if (middlewareConfig.enableUrlContext && middlewareConfig.) {
// plugins.push(googleToolsPlugin({ urlContext: true }))
// }
if (middlewareConfig.enableUrlContext) {
plugins.push(googleToolsPlugin({ urlContext: true }))
}
logger.debug(
'Final plugin list:',

View File

@@ -49,7 +49,7 @@ class AdapterTracer {
this.cachedParentContext = undefined
}
logger.debug('AdapterTracer created with parent context info', {
logger.info('AdapterTracer created with parent context info', {
topicId,
modelName,
parentTraceId: this.parentSpanContext?.traceId,
@@ -62,7 +62,7 @@ class AdapterTracer {
startActiveSpan<F extends (span: Span) => any>(name: string, options: any, fn: F): ReturnType<F>
startActiveSpan<F extends (span: Span) => any>(name: string, options: any, context: any, fn: F): ReturnType<F>
startActiveSpan<F extends (span: Span) => any>(name: string, arg2?: any, arg3?: any, arg4?: any): ReturnType<F> {
logger.debug('AdapterTracer.startActiveSpan called', {
logger.info('AdapterTracer.startActiveSpan called', {
spanName: name,
topicId: this.topicId,
modelName: this.modelName,
@@ -88,7 +88,7 @@ class AdapterTracer {
// 包装span的end方法
const originalEnd = span.end.bind(span)
span.end = (endTime?: any) => {
logger.debug('AI SDK span.end() called in startActiveSpan - about to convert span', {
logger.info('AI SDK span.end() called in startActiveSpan - about to convert span', {
spanName: name,
spanId: span.spanContext().spanId,
traceId: span.spanContext().traceId,
@@ -101,14 +101,14 @@ class AdapterTracer {
// 转换并保存 span 数据
try {
logger.debug('Converting AI SDK span to SpanEntity (from startActiveSpan)', {
logger.info('Converting AI SDK span to SpanEntity (from startActiveSpan)', {
spanName: name,
spanId: span.spanContext().spanId,
traceId: span.spanContext().traceId,
topicId: this.topicId,
modelName: this.modelName
})
logger.silly('span', span)
logger.info('span', span)
const spanEntity = AiSdkSpanAdapter.convertToSpanEntity({
span,
topicId: this.topicId,
@@ -118,7 +118,7 @@ class AdapterTracer {
// 保存转换后的数据
window.api.trace.saveEntity(spanEntity)
logger.debug('AI SDK span converted and saved successfully (from startActiveSpan)', {
logger.info('AI SDK span converted and saved successfully (from startActiveSpan)', {
spanName: name,
spanId: span.spanContext().spanId,
traceId: span.spanContext().traceId,
@@ -151,7 +151,7 @@ class AdapterTracer {
if (this.parentSpanContext) {
try {
const ctx = trace.setSpanContext(otelContext.active(), this.parentSpanContext)
logger.debug('Created active context with parent SpanContext for startActiveSpan', {
logger.info('Created active context with parent SpanContext for startActiveSpan', {
spanName: name,
parentTraceId: this.parentSpanContext.traceId,
parentSpanId: this.parentSpanContext.spanId,
@@ -218,7 +218,7 @@ export function createTelemetryPlugin(config: TelemetryPluginConfig) {
if (effectiveTopicId) {
try {
// 从 SpanManagerService 获取当前的 span
logger.debug('Attempting to find parent span', {
logger.info('Attempting to find parent span', {
topicId: effectiveTopicId,
requestId: context.requestId,
modelName: modelName,
@@ -230,7 +230,7 @@ export function createTelemetryPlugin(config: TelemetryPluginConfig) {
if (parentSpan) {
// 直接使用父 span 的 SpanContext避免手动拼装字段遗漏
parentSpanContext = parentSpan.spanContext()
logger.debug('Found active parent span for AI SDK', {
logger.info('Found active parent span for AI SDK', {
parentSpanId: parentSpanContext.spanId,
parentTraceId: parentSpanContext.traceId,
topicId: effectiveTopicId,
@@ -302,7 +302,7 @@ export function createTelemetryPlugin(config: TelemetryPluginConfig) {
logger.debug('Updated active context with parent span')
})
logger.debug('Set parent context for AI SDK spans', {
logger.info('Set parent context for AI SDK spans', {
parentSpanId: parentSpanContext?.spanId,
parentTraceId: parentSpanContext?.traceId,
hasActiveContext: !!activeContext,
@@ -313,7 +313,7 @@ export function createTelemetryPlugin(config: TelemetryPluginConfig) {
}
}
logger.debug('Injecting AI SDK telemetry config with adapter', {
logger.info('Injecting AI SDK telemetry config with adapter', {
requestId: context.requestId,
topicId: effectiveTopicId,
modelId: context.modelId,

View File

@@ -114,7 +114,7 @@ export async function handleGeminiFileUpload(file: FileMetadata, model: Model):
}
/**
* 处理OpenAI兼容大文件上传
* 处理OpenAI大文件上传
*/
export async function handleOpenAILargeFileUpload(
file: FileMetadata,

View File

@@ -4,7 +4,7 @@
*/
import { loggerService } from '@logger'
import { isImageEnhancementModel, isVisionModel } from '@renderer/config/models'
import { isVisionModel } from '@renderer/config/models'
import type { Message, Model } from '@renderer/types'
import { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage'
import {
@@ -47,41 +47,6 @@ export async function convertMessageToSdkParam(
}
}
async function convertImageBlockToImagePart(imageBlocks: ImageMessageBlock[]): Promise<Array<ImagePart>> {
const parts: Array<ImagePart> = []
for (const imageBlock of imageBlocks) {
if (imageBlock.file) {
try {
const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext)
parts.push({
type: 'image',
image: image.base64,
mediaType: image.mime
})
} catch (error) {
logger.warn('Failed to load image:', error as Error)
}
} else if (imageBlock.url) {
const isBase64 = imageBlock.url.startsWith('data:')
if (isBase64) {
const base64 = imageBlock.url.match(/^data:[^;]*;base64,(.+)$/)![1]
const mimeMatch = imageBlock.url.match(/^data:([^;]+)/)
parts.push({
type: 'image',
image: base64,
mediaType: mimeMatch ? mimeMatch[1] : 'image/png'
})
} else {
parts.push({
type: 'image',
image: imageBlock.url
})
}
}
}
return parts
}
/**
* 转换为用户模型消息
*/
@@ -99,7 +64,25 @@ async function convertMessageToUserModelMessage(
// 处理图片(仅在支持视觉的模型中)
if (isVisionModel) {
parts.push(...(await convertImageBlockToImagePart(imageBlocks)))
for (const imageBlock of imageBlocks) {
if (imageBlock.file) {
try {
const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext)
parts.push({
type: 'image',
image: image.base64,
mediaType: image.mime
})
} catch (error) {
logger.warn('Failed to load image:', error as Error)
}
} else if (imageBlock.url) {
parts.push({
type: 'image',
image: imageBlock.url
})
}
}
}
// 处理文件
for (const fileBlock of fileBlocks) {
@@ -189,27 +172,7 @@ async function convertMessageToAssistantModelMessage(
}
/**
* Converts an array of messages to SDK-compatible model messages.
*
* This function processes messages and transforms them into the format required by the SDK.
* It handles special cases for vision models and image enhancement models.
*
* @param messages - Array of messages to convert. Must contain at least 2 messages when using image enhancement models.
* @param model - The model configuration that determines conversion behavior
*
* @returns A promise that resolves to an array of SDK-compatible model messages
*
* @remarks
* For image enhancement models with 2+ messages:
* - Expects the second-to-last message (index length-2) to be an assistant message containing image blocks
* - Expects the last message (index length-1) to be a user message
* - Extracts images from the assistant message and appends them to the user message content
* - Returns only the last two processed messages [assistantSdkMessage, userSdkMessage]
*
* For other models:
* - Returns all converted messages in order
*
* The function automatically detects vision model capabilities and adjusts conversion accordingly.
* 转换 Cherry Studio 消息数组为 AI SDK 消息数组
*/
export async function convertMessagesToSdkMessages(messages: Message[], model: Model): Promise<ModelMessage[]> {
const sdkMessages: ModelMessage[] = []
@@ -219,31 +182,6 @@ export async function convertMessagesToSdkMessages(messages: Message[], model: M
const sdkMessage = await convertMessageToSdkParam(message, isVision, model)
sdkMessages.push(...(Array.isArray(sdkMessage) ? sdkMessage : [sdkMessage]))
}
// Special handling for image enhancement models
// Only keep the last two messages and merge images into the user message
// [system?, user, assistant, user]
if (isImageEnhancementModel(model) && messages.length >= 3) {
const needUpdatedMessages = messages.slice(-2)
const needUpdatedSdkMessages = sdkMessages.slice(-2)
const assistantMessage = needUpdatedMessages.filter((m) => m.role === 'assistant')[0]
const assistantSdkMessage = needUpdatedSdkMessages.filter((m) => m.role === 'assistant')[0]
const userSdkMessage = needUpdatedSdkMessages.filter((m) => m.role === 'user')[0]
const systemSdkMessages = sdkMessages.filter((m) => m.role === 'system')
const imageBlocks = findImageBlocks(assistantMessage)
const imageParts = await convertImageBlockToImagePart(imageBlocks)
const parts: Array<TextPart | ImagePart | FilePart> = []
if (typeof userSdkMessage.content === 'string') {
parts.push({ type: 'text', text: userSdkMessage.content })
parts.push(...imageParts)
userSdkMessage.content = parts
} else {
userSdkMessage.content.push(...imageParts)
}
if (systemSdkMessages.length > 0) {
return [systemSdkMessages[0], assistantSdkMessage, userSdkMessage]
}
return [assistantSdkMessage, userSdkMessage]
}
return sdkMessages
}

View File

@@ -4,7 +4,6 @@
*/
import {
isClaude45ReasoningModel,
isClaudeReasoningModel,
isNotSupportTemperatureAndTopP,
isSupportedFlexServiceTier
@@ -20,10 +19,7 @@ export function getTemperature(assistant: Assistant, model: Model): number | und
if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) {
return undefined
}
if (
isNotSupportTemperatureAndTopP(model) ||
(isClaude45ReasoningModel(model) && assistant.settings?.enableTopP && !assistant.settings?.enableTemperature)
) {
if (isNotSupportTemperatureAndTopP(model)) {
return undefined
}
const assistantSettings = getAssistantSettings(assistant)
@@ -37,10 +33,7 @@ export function getTopP(assistant: Assistant, model: Model): number | undefined
if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) {
return undefined
}
if (
isNotSupportTemperatureAndTopP(model) ||
(isClaude45ReasoningModel(model) && assistant.settings?.enableTemperature)
) {
if (isNotSupportTemperatureAndTopP(model)) {
return undefined
}
const assistantSettings = getAssistantSettings(assistant)

View File

@@ -3,8 +3,6 @@
* 构建AI SDK的流式和非流式参数
*/
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge'
import { vertex } from '@ai-sdk/google-vertex/edge'
import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
@@ -99,6 +97,10 @@ export async function buildStreamTextParams(
let tools = setupToolsConfig(mcpTools)
// if (webSearchProviderId) {
// tools['builtin_web_search'] = webSearchTool(webSearchProviderId)
// }
// 构建真正的 providerOptions
const webSearchConfig: CherryWebSearchConfig = {
maxResults: store.getState().websearch.maxResults,
@@ -141,34 +143,12 @@ export async function buildStreamTextParams(
}
}
if (enableUrlContext) {
// google-vertex
if (enableUrlContext && aiSdkProviderId === 'google-vertex') {
if (!tools) {
tools = {}
}
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
switch (aiSdkProviderId) {
case 'google-vertex':
tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool
break
case 'google':
tools.url_context = google.tools.urlContext({}) as ProviderDefinedTool
break
case 'anthropic':
case 'google-vertex-anthropic':
tools.web_fetch = (
aiSdkProviderId === 'anthropic'
? anthropic.tools.webFetch_20250910({
maxUses: webSearchConfig.maxResults,
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
})
: vertexAnthropic.tools.webFetch_20250910({
maxUses: webSearchConfig.maxResults,
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
})
) as ProviderDefinedTool
break
}
tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool
}
// 构建基础参数

View File

@@ -32,8 +32,7 @@ const AIHUBMIX_RULES: RuleSet = {
match: (model) =>
(startsWith('gemini')(model) || startsWith('imagen')(model)) &&
!model.id.endsWith('-nothink') &&
!model.id.endsWith('-search') &&
!model.id.includes('embedding'),
!model.id.endsWith('-search'),
provider: (provider: Provider) => {
return extraProviderConfig({
...provider,

View File

@@ -6,28 +6,26 @@ import {
type ProviderSettingsMap
} from '@cherrystudio/ai-core/provider'
import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models'
import {
isAnthropicProvider,
isAzureOpenAIProvider,
isGeminiProvider,
isNewApiProvider
} from '@renderer/config/providers'
import { isNewApiProvider } from '@renderer/config/providers'
import {
getAwsBedrockAccessKeyId,
getAwsBedrockRegion,
getAwsBedrockSecretAccessKey
} from '@renderer/hooks/useAwsBedrock'
import { createVertexProvider, isVertexAIConfigured, isVertexProvider } from '@renderer/hooks/useVertexAI'
import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useVertexAI'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { loggerService } from '@renderer/services/LoggerService'
import store from '@renderer/store'
import { isSystemProvider, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import { formatApiHost, formatAzureOpenAIApiHost, formatVertexApiHost, routeToEndpoint } from '@renderer/utils/api'
import { cloneDeep } from 'lodash'
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { cloneDeep, trim } from 'lodash'
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
import { COPILOT_DEFAULT_HEADERS } from './constants'
import { getAiSdkProviderId } from './factory'
const logger = loggerService.withContext('ProviderConfigProcessor')
/**
* 获取轮询的API key
* 复用legacy架构的多key轮询逻辑
@@ -58,6 +56,13 @@ function getRotatedApiKey(provider: Provider): string {
* 处理特殊provider的转换逻辑
*/
function handleSpecialProviders(model: Model, provider: Provider): Provider {
// if (provider.type === 'vertexai' && !isVertexProvider(provider)) {
// if (!isVertexAIConfigured()) {
// throw new Error('VertexAI is not configured. Please configure project, location and service account credentials.')
// }
// return createVertexProvider(provider)
// }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
@@ -74,30 +79,43 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
}
/**
* 主要用来对齐AISdk的BaseURL格式
* @param provider
* @returns
* 格式化provider的API Host
*/
function formatAnthropicApiHost(host: string): string {
const trimmedHost = host?.trim()
if (!trimmedHost) {
return ''
}
if (trimmedHost.endsWith('/')) {
return trimmedHost
}
if (trimmedHost.endsWith('/v1')) {
return `${trimmedHost}/`
}
return formatApiHost(trimmedHost)
}
function formatProviderApiHost(provider: Provider): Provider {
const formatted = { ...provider }
if (formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost)
formatted.anthropicApiHost = formatAnthropicApiHost(formatted.anthropicApiHost)
}
if (isAnthropicProvider(provider)) {
if (formatted.type === 'anthropic') {
const baseHost = formatted.anthropicApiHost || formatted.apiHost
formatted.apiHost = formatApiHost(baseHost)
formatted.apiHost = formatAnthropicApiHost(baseHost)
if (!formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatted.apiHost
}
} else if (formatted.id === SystemProviderIds.copilot || formatted.id === SystemProviderIds.github) {
formatted.apiHost = formatApiHost(formatted.apiHost, false)
} else if (isGeminiProvider(formatted)) {
formatted.apiHost = formatApiHost(formatted.apiHost, true, 'v1beta')
} else if (isAzureOpenAIProvider(formatted)) {
formatted.apiHost = formatAzureOpenAIApiHost(formatted.apiHost)
} else if (isVertexProvider(formatted)) {
formatted.apiHost = formatVertexApiHost(formatted)
} else if (formatted.id === 'copilot') {
const trimmed = trim(formatted.apiHost)
formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
} else if (formatted.type === 'gemini') {
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
} else {
formatted.apiHost = formatApiHost(formatted.apiHost)
}
@@ -131,15 +149,15 @@ export function providerToAiSdkConfig(
options: ProviderSettingsMap[keyof ProviderSettingsMap]
} {
const aiSdkProviderId = getAiSdkProviderId(actualProvider)
logger.debug('providerToAiSdkConfig', { aiSdkProviderId })
// 构建基础配置
const { baseURL, endpoint } = routeToEndpoint(actualProvider.apiHost)
const baseConfig = {
baseURL: baseURL,
baseURL: trim(actualProvider.apiHost),
apiKey: getRotatedApiKey(actualProvider)
}
const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot
const isCopilotProvider = actualProvider.id === 'copilot'
if (isCopilotProvider) {
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
@@ -160,7 +178,6 @@ export function providerToAiSdkConfig(
// 处理OpenAI模式
const extraOptions: any = {}
extraOptions.endpoint = endpoint
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
extraOptions.mode = 'responses'
} else if (aiSdkProviderId === 'openai') {
@@ -182,11 +199,13 @@ export function providerToAiSdkConfig(
}
// azure
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
// extraOptions.apiVersion = actualProvider.apiVersion 默认使用v1不使用azure endpoint
extraOptions.apiVersion = actualProvider.apiVersion
baseConfig.baseURL += '/openai'
if (actualProvider.apiVersion === 'preview') {
extraOptions.mode = 'responses'
} else {
extraOptions.mode = 'chat'
extraOptions.useDeploymentBasedUrls = true
}
}
@@ -208,7 +227,22 @@ export function providerToAiSdkConfig(
...googleCredentials,
privateKey: formatPrivateKey(googleCredentials.privateKey)
}
baseConfig.baseURL += aiSdkProviderId === 'google-vertex' ? '/publishers/google' : '/publishers/anthropic/models'
// extraOptions.headers = window.api.vertexAI.getAuthHeaders({
// projectId: project,
// serviceAccount: {
// privateKey: googleCredentials.privateKey,
// clientEmail: googleCredentials.clientEmail
// }
// })
if (baseConfig.baseURL.endsWith('/v1/')) {
baseConfig.baseURL = baseConfig.baseURL.slice(0, -4)
} else if (baseConfig.baseURL.endsWith('/v1')) {
baseConfig.baseURL = baseConfig.baseURL.slice(0, -3)
}
if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) {
baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google`
}
}
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {

View File

@@ -63,14 +63,6 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
creatorFunctionName: 'createMistral',
supportsImageGeneration: false,
aliases: ['mistral']
},
{
id: 'huggingface',
name: 'HuggingFace',
import: () => import('@ai-sdk/huggingface'),
creatorFunctionName: 'createHuggingFace',
supportsImageGeneration: true,
aliases: ['hf', 'hugging-face']
}
] as const

View File

@@ -1,15 +1,5 @@
import { isSystemProvider, Model, Provider, SystemProviderIds } from '@renderer/types'
export function buildGeminiGenerateImageParams(): Record<string, any> {
return {
responseModalities: ['TEXT', 'IMAGE']
}
}
export function isOpenRouterGeminiGenerateImageModel(model: Model, provider: Provider): boolean {
return (
model.id.includes('gemini-2.5-flash-image') &&
isSystemProvider(provider) &&
provider.id === SystemProviderIds.openrouter
)
}

View File

@@ -90,9 +90,7 @@ export function buildProviderOptions(
serviceTier: serviceTierSetting
}
break
case 'huggingface':
providerSpecificOptions = buildOpenAIProviderOptions(assistant, model, capabilities)
break
case 'anthropic':
providerSpecificOptions = buildAnthropicProviderOptions(assistant, model, capabilities)
break

View File

@@ -10,7 +10,6 @@ import {
isGrok4FastReasoningModel,
isGrokReasoningModel,
isOpenAIDeepResearchModel,
isOpenAIModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
isQwenReasoningModel,
@@ -67,8 +66,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
isGrokReasoningModel(model) ||
isOpenAIReasoningModel(model) ||
isQwenAlwaysThinkModel(model) ||
model.id.includes('seed-oss') ||
model.id.includes('minimax-m2')
model.id.includes('seed-oss')
) {
return {}
}
@@ -201,7 +199,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
}
}
// OpenRouter models, use reasoning
// OpenRouter models, use thinking
if (model.provider === SystemProviderIds.openrouter) {
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return {
@@ -320,20 +318,6 @@ export function getOpenAIReasoningParams(assistant: Assistant, model: Model): Re
if (!isReasoningModel(model)) {
return {}
}
let reasoningEffort = assistant?.settings?.reasoning_effort
if (!reasoningEffort) {
return {}
}
// 非OpenAI模型但是Provider类型是responses/azure openai的情况
if (!isOpenAIModel(model)) {
return {
reasoningEffort
}
}
const openAI = getStoreSetting('openAI') as SettingsState['openAI']
const summaryText = openAI?.summaryText || 'off'
@@ -345,10 +329,16 @@ export function getOpenAIReasoningParams(assistant: Assistant, model: Model): Re
reasoningSummary = summaryText
}
let reasoningEffort = assistant?.settings?.reasoning_effort
if (isOpenAIDeepResearchModel(model)) {
reasoningEffort = 'medium'
}
if (!reasoningEffort) {
return {}
}
// OpenAI 推理参数
if (isSupportedReasoningEffortOpenAIModel(model)) {
return {

View File

@@ -1,4 +1,14 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.0006 25.9992C13.8266 25.999 11.7118 25.2901 9.97686 23.9799C8.2419 22.6698 6.98127 20.8298 6.38599 18.7388C5.79071 16.6478 5.89323 14.4198 6.678 12.3923C7.46278 10.3648 8.88705 8.64837 10.735 7.50308C12.5829 6.35779 14.7538 5.84606 16.9187 6.04544C19.0837 6.24481 21.1246 7.14442 22.7323 8.60795C24.34 10.0715 25.4268 12.0192 25.8281 14.1559C26.2293 16.2926 25.9232 18.5019 24.9561 20.449C24.7703 20.8042 24.7223 21.2155 24.8211 21.604L25.4211 23.8316C25.4803 24.0518 25.4805 24.2837 25.4216 24.5039C25.3627 24.7242 25.2468 24.925 25.0856 25.0862C24.9244 25.2474 24.7235 25.3633 24.5033 25.4222C24.283 25.4811 24.0512 25.4809 23.831 25.4217L21.6034 24.8217C21.2172 24.7248 20.809 24.7729 20.4558 24.9567C19.0683 25.6467 17.5457 26.0068 16.0006 26.0068V25.9992Z" fill="black"/>
<path d="M9.62598 16.0013C9.62598 15.3799 10.1294 14.8765 10.7508 14.8765C11.3721 14.8765 11.8756 15.3799 11.8756 16.0013C11.8756 17.0953 12.3102 18.1448 13.0838 18.9184C13.8574 19.692 14.9069 20.1266 16.001 20.1267C17.095 20.1267 18.1445 19.692 18.9181 18.9184C19.6918 18.1448 20.1264 17.0953 20.1264 16.0013C20.1264 15.3799 20.6299 14.8765 21.2512 14.8765C21.8725 14.8765 22.3759 15.3799 22.3759 16.0013C22.3759 17.6921 21.7046 19.3137 20.509 20.5093C19.3134 21.7049 17.6918 22.3762 16.001 22.3762C14.3102 22.3762 12.6885 21.7049 11.4929 20.5093C10.2974 19.3137 9.62598 17.6921 9.62598 16.0013Z" fill="white"/>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none">
<path
fill="#FFD21E"
d="M4 15.55C4 9.72 8.72 5 14.55 5h4.11a9.34 9.34 0 1 1 0 18.68H7.58l-2.89 2.8a.41.41 0 0 1-.69-.3V15.55Z"
/>
<path
fill="#32343D"
d="M19.63 12.48c.37.14.52.9.9.7.71-.38.98-1.27.6-1.98a1.46 1.46 0 0 0-1.98-.61 1.47 1.47 0 0 0-.6 1.99c.17.34.74-.21 1.08-.1ZM12.72 12.48c-.37.14-.52.9-.9.7a1.47 1.47 0 0 1-.6-1.98 1.46 1.46 0 0 1 1.98-.61c.71.38.98 1.27.6 1.99-.18.34-.74-.21-1.08-.1ZM16.24 19.55c2.89 0 3.82-2.58 3.82-3.9 0-1.33-1.71.7-3.82.7-2.1 0-3.8-2.03-3.8-.7 0 1.32.92 3.9 3.8 3.9Z"
/>
<path
fill="#FF323D"
d="M18.56 18.8c-.57.44-1.33.75-2.32.75-.92 0-1.65-.27-2.2-.68.3-.63.87-1.11 1.55-1.32.12-.03.24.17.36.38.12.2.24.4.37.4s.26-.2.39-.4.26-.4.38-.36a2.56 2.56 0 0 1 1.47 1.23Z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -5,16 +5,6 @@ import { describe, expect, it, vi } from 'vitest'
import { DraggableList } from '../'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// mock @hello-pangea/dnd 组件
vi.mock('@hello-pangea/dnd', () => {
return {

View File

@@ -3,16 +3,6 @@ import { describe, expect, it, vi } from 'vitest'
import { DraggableVirtualList } from '../'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// Mock 依赖项
vi.mock('@hello-pangea/dnd', () => ({
__esModule: true,

View File

@@ -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, Space, Table, Tooltip } from 'antd'
import { Button, message, Modal, Table, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -214,26 +214,6 @@ 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')}
@@ -242,7 +222,23 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
width={800}
centered
transitionName="animation-move-down"
footer={footerContent}>
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>
]}>
<Table
rowKey="fileName"
columns={columns}

View File

@@ -16,8 +16,8 @@ import {
import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { permissionModeCards } from '@renderer/config/agent'
import { agentModelFilter, getModelLogoById } from '@renderer/config/models'
import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'

View File

@@ -1,4 +1,4 @@
import { makeSvgSizeAdaptive } from '@renderer/utils/image'
import { makeSvgSizeAdaptive } from '@renderer/utils'
import DOMPurify from 'dompurify'
/**

View File

@@ -16,7 +16,6 @@ interface BaseSelectorProps<V = string | number> {
options: SelectorOption<V>[]
placeholder?: string
placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'top' | 'bottom'
style?: React.CSSProperties
/** 字体大小 */
size?: number
/** 是否禁用 */
@@ -44,7 +43,6 @@ const Selector = <V extends string | number>({
placement = 'bottomRight',
size = 13,
placeholder,
style,
disabled = false,
multiple = false
}: SelectorProps<V>) => {
@@ -137,7 +135,7 @@ const Selector = <V extends string | number>({
placement={placement}
open={open && !disabled}
onOpenChange={handleOpenChange}>
<Label style={style} $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
<Label $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
{label}
<LabelIcon size={size + 3} />
</Label>

View File

@@ -23,16 +23,6 @@ const mocks = vi.hoisted(() => ({
}
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// Mock antd components to prevent flaky snapshot tests
vi.mock('antd', () => {
const MockSpaceCompact: React.FC<React.PropsWithChildren<{ style?: React.CSSProperties }>> = ({

View File

@@ -65,7 +65,7 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
min-width: 100%;
display: flex;
flex-direction: row;
min-height: ${({ $isFullScreen }) => (!$isFullScreen && isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)')};
min-height: ${isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)'};
max-height: var(--navbar-height);
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1 + 2px)' : 0};
padding-left: ${({ $isFullScreen }) =>

View File

@@ -18,15 +18,6 @@ describe('Qwen Model Detection', () => {
vi.mock('@renderer/services/AssistantService', () => ({
getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' })
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
})
test('isQwenReasoningModel', () => {
expect(isQwenReasoningModel({ id: 'qwen3-thinking' } as Model)).toBe(true)

View File

@@ -2,16 +2,6 @@ import { describe, expect, it, vi } from 'vitest'
import { isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel, isLingReasoningModel } from '../models/reasoning'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// FIXME: Idk why it's imported. Maybe circular dependency somewhere
vi.mock('@renderer/services/AssistantService.ts', () => ({
getDefaultAssistant: () => {

View File

@@ -1,6 +1,5 @@
import ClaudeAvatar from '@renderer/assets/images/models/claude.png'
import { AgentBase, AgentType } from '@renderer/types'
import { PermissionModeCard } from '@renderer/types/agent'
// base agent config. no default config for now.
const DEFAULT_AGENT_CONFIG: Omit<AgentBase, 'model'> = {
@@ -20,47 +19,3 @@ export const getAgentTypeAvatar = (type: AgentType): string => {
return ''
}
}
export const permissionModeCards: PermissionModeCard[] = [
{
mode: 'default',
// t('agent.settings.tooling.permissionMode.default.title')
titleKey: 'agent.settings.tooling.permissionMode.default.title',
titleFallback: 'Default (ask before continuing)',
descriptionKey: 'agent.settings.tooling.permissionMode.default.description',
descriptionFallback: 'Read-only tools are pre-approved; everything else still needs permission.',
behaviorKey: 'agent.settings.tooling.permissionMode.default.behavior',
behaviorFallback: 'Read-only tools are pre-approved automatically.'
},
{
mode: 'plan',
// t('agent.settings.tooling.permissionMode.plan.title')
titleKey: 'agent.settings.tooling.permissionMode.plan.title',
titleFallback: 'Planning mode',
descriptionKey: 'agent.settings.tooling.permissionMode.plan.description',
descriptionFallback: 'Shares the default read-only tool set but presents a plan before execution.',
behaviorKey: 'agent.settings.tooling.permissionMode.plan.behavior',
behaviorFallback: 'Read-only defaults are pre-approved while execution remains disabled.'
},
{
mode: 'acceptEdits',
// t('agent.settings.tooling.permissionMode.acceptEdits.title')
titleKey: 'agent.settings.tooling.permissionMode.acceptEdits.title',
titleFallback: 'Auto-accept file edits',
descriptionKey: 'agent.settings.tooling.permissionMode.acceptEdits.description',
descriptionFallback: 'File edits and filesystem operations are automatically approved.',
behaviorKey: 'agent.settings.tooling.permissionMode.acceptEdits.behavior',
behaviorFallback: 'Pre-approves trusted filesystem tools so edits run immediately.'
},
{
mode: 'bypassPermissions',
// t('agent.settings.tooling.permissionMode.bypassPermissions.title')
titleKey: 'agent.settings.tooling.permissionMode.bypassPermissions.title',
titleFallback: 'Bypass permission checks',
descriptionKey: 'agent.settings.tooling.permissionMode.bypassPermissions.description',
descriptionFallback: 'All permission prompts are skipped — use with caution.',
behaviorKey: 'agent.settings.tooling.permissionMode.bypassPermissions.behavior',
behaviorFallback: 'Every tool is pre-approved automatically.',
caution: true
}
]

View File

@@ -22,7 +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 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'
@@ -472,16 +471,6 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
style: {
padding: 6
}
},
{
id: 'huggingchat',
name: 'HuggingChat',
url: 'https://huggingface.co/chat/',
logo: HuggingChatLogo,
bodered: true,
style: {
padding: 6
}
}
]

View File

@@ -1741,7 +1741,6 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
id: 'DeepSeek-R1',
provider: 'cephalon',
name: 'DeepSeek-R1满血版',
capabilities: [{ type: 'reasoning' }],
group: 'DeepSeek'
}
],
@@ -1838,6 +1837,5 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
provider: 'longcat',
group: 'LongCat'
}
],
huggingface: []
]
}

View File

@@ -361,12 +361,6 @@ 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
@@ -461,14 +455,6 @@ export const isStepReasoningModel = (model?: Model): boolean => {
return modelId.includes('step-3') || modelId.includes('step-r1-v-mini')
}
export const isMiniMaxReasoningModel = (model?: Model): boolean => {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id, '/')
return (['minimax-m1', 'minimax-m2'] as const).some((id) => modelId.includes(id))
}
export function isReasoningModel(model?: Model): boolean {
if (!model || isEmbeddingModel(model) || isRerankModel(model) || isTextToImageModel(model)) {
return false
@@ -503,8 +489,8 @@ export function isReasoningModel(model?: Model): boolean {
isStepReasoningModel(model) ||
isDeepSeekHybridInferenceModel(model) ||
isLingReasoningModel(model) ||
isMiniMaxReasoningModel(model) ||
modelId.includes('magistral') ||
modelId.includes('minimax-m1') ||
modelId.includes('pangu-pro-moe') ||
modelId.includes('seed-oss')
) {

View File

@@ -27,9 +27,8 @@ export const FUNCTION_CALLING_MODELS = [
'doubao-seed-1[.-]6(?:-[\\w-]+)?',
'kimi-k2(?:-[\\w-]+)?',
'ling-\\w+(?:-[\\w-]+)?',
'ring-\\w+(?:-[\\w-]+)?',
'minimax-m2'
] as const
'ring-\\w+(?:-[\\w-]+)?'
]
const FUNCTION_CALLING_EXCLUDED_MODELS = [
'aqa(?:-[\\w-]+)?',

View File

@@ -83,7 +83,7 @@ export const IMAGE_ENHANCEMENT_MODELS = [
'grok-2-image(?:-[\\w-]+)?',
'qwen-image-edit',
'gpt-image-1',
'gemini-2.5-flash-image(?:-[\\w-]+)?',
'gemini-2.5-flash-image',
'gemini-2.0-flash-preview-image-generation'
]

View File

@@ -1,8 +1,7 @@
import { getProviderByModel } from '@renderer/services/AssistantService'
import { Model, SystemProviderIds } from '@renderer/types'
import { Model } from '@renderer/types'
import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
import { isGeminiProvider, isNewApiProvider, isOpenAICompatibleProvider, isOpenAIProvider } from '../providers'
import { isEmbeddingModel, isRerankModel } from './embedding'
import { isAnthropicModel } from './utils'
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
@@ -66,16 +65,12 @@ export function isWebSearchModel(model: Model): boolean {
const modelId = getLowerBaseModelName(model.id, '/')
// bedrock和vertex不支持
if (
isAnthropicModel(model) &&
(provider.id === SystemProviderIds['aws-bedrock'] || provider.id === SystemProviderIds.vertexai)
) {
// 不管哪个供应商都判断了
if (isAnthropicModel(model)) {
return CLAUDE_SUPPORTED_WEBSEARCH_REGEX.test(modelId)
}
// TODO: 当其他供应商采用Response端点时这个地方逻辑需要改进
if (isOpenAIProvider(provider)) {
if (provider.type === 'openai-response') {
if (isOpenAIWebSearchModel(model)) {
return true
}
@@ -83,11 +78,11 @@ export function isWebSearchModel(model: Model): boolean {
return false
}
if (provider.id === SystemProviderIds.perplexity) {
if (provider.id === 'perplexity') {
return PERPLEXITY_SEARCH_MODELS.includes(modelId)
}
if (provider.id === SystemProviderIds.aihubmix) {
if (provider.id === 'aihubmix') {
// modelId 不以-search结尾
if (!modelId.endsWith('-search') && GEMINI_SEARCH_REGEX.test(modelId)) {
return true
@@ -100,13 +95,13 @@ export function isWebSearchModel(model: Model): boolean {
return false
}
if (isOpenAICompatibleProvider(provider) || isNewApiProvider(provider)) {
if (provider?.type === 'openai') {
if (GEMINI_SEARCH_REGEX.test(modelId) || isOpenAIWebSearchModel(model)) {
return true
}
}
if (isGeminiProvider(provider) || provider.id === SystemProviderIds.vertexai) {
if (provider.id === 'gemini' || provider?.type === 'gemini' || provider.type === 'vertexai') {
return GEMINI_SEARCH_REGEX.test(modelId)
}

View File

@@ -11,8 +11,6 @@ export function getPreprocessProviderLogo(providerId: PreprocessProviderId) {
return MistralLogo
case 'mineru':
return MinerULogo
case 'open-mineru':
return MinerULogo
default:
return undefined
}
@@ -38,11 +36,5 @@ export const PREPROCESS_PROVIDER_CONFIG: Record<PreprocessProviderId, Preprocess
official: 'https://mineru.net/',
apiKey: 'https://mineru.net/apiManage'
}
},
'open-mineru': {
websites: {
official: 'https://github.com/opendatalab/MinerU/',
apiKey: 'https://github.com/opendatalab/MinerU/'
}
}
}

View File

@@ -22,7 +22,6 @@ import GoogleProviderLogo from '@renderer/assets/images/providers/google.png'
import GPUStackProviderLogo from '@renderer/assets/images/providers/gpustack.svg'
import GrokProviderLogo from '@renderer/assets/images/providers/grok.png'
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
import HuggingfaceProviderLogo from '@renderer/assets/images/providers/huggingface.webp'
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
import InfiniProviderLogo from '@renderer/assets/images/providers/infini.png'
import IntelOvmsLogo from '@renderer/assets/images/providers/intel.png'
@@ -58,7 +57,6 @@ import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
import {
AtLeast,
AzureOpenAIProvider,
isSystemProvider,
OpenAIServiceTiers,
Provider,
@@ -356,7 +354,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
name: 'VertexAI',
type: 'vertexai',
apiKey: '',
apiHost: '',
apiHost: 'https://aiplatform.googleapis.com',
models: SYSTEM_MODELS.vertexai,
isSystem: true,
enabled: false,
@@ -420,7 +418,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
anthropicApiHost: 'https://dashscope.aliyuncs.com/apps/anthropic',
anthropicApiHost: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy',
models: SYSTEM_MODELS.dashscope,
isSystem: true,
enabled: false
@@ -655,16 +653,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
models: SYSTEM_MODELS.longcat,
isSystem: true,
enabled: false
},
huggingface: {
id: 'huggingface',
name: 'Hugging Face',
type: 'openai-response',
apiKey: '',
apiHost: 'https://router.huggingface.co/v1/',
models: [],
isSystem: true,
enabled: false
}
} as const
@@ -729,8 +717,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
'aws-bedrock': AwsProviderLogo,
poe: 'poe', // use svg icon component
aionly: AiOnlyProviderLogo,
longcat: LongCatProviderLogo,
huggingface: HuggingfaceProviderLogo
longcat: LongCatProviderLogo
} as const
export function getProviderLogo(providerId: string) {
@@ -1296,7 +1283,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
},
vertexai: {
api: {
url: ''
url: 'https://console.cloud.google.com/apis/api/aiplatform.googleapis.com/overview'
},
websites: {
official: 'https://cloud.google.com/vertex-ai',
@@ -1357,17 +1344,6 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
docs: 'https://longcat.chat/platform/docs/zh/',
models: 'https://longcat.chat/platform/docs/zh/APIDocs.html'
}
},
huggingface: {
api: {
url: 'https://router.huggingface.co/v1/'
},
websites: {
official: 'https://huggingface.co/',
apiKey: 'https://huggingface.co/settings/tokens',
docs: 'https://huggingface.co/docs',
models: 'https://huggingface.co/models'
}
}
}
@@ -1376,8 +1352,7 @@ const NOT_SUPPORT_ARRAY_CONTENT_PROVIDERS = [
'baichuan',
'minimax',
'xirang',
'poe',
'cephalon'
'poe'
] as const satisfies SystemProviderId[]
/**
@@ -1442,15 +1417,10 @@ export const isSupportServiceTierProvider = (provider: Provider) => {
)
}
const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [
'gemini',
'vertexai',
'anthropic',
'new-api'
] as const satisfies ProviderType[]
const SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES = ['gemini', 'vertexai'] as const satisfies ProviderType[]
export const isSupportUrlContextProvider = (provider: Provider) => {
return SUPPORT_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
return SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
}
const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[]
@@ -1463,37 +1433,3 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
export const isNewApiProvider = (provider: Provider) => {
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
}
/**
* 判断是否为 OpenAI 兼容的提供商
* @param {Provider} provider 提供商对象
* @returns {boolean} 是否为 OpenAI 兼容提供商
*/
export function isOpenAICompatibleProvider(provider: Provider): boolean {
return ['openai', 'new-api', 'mistral'].includes(provider.type)
}
export function isAzureOpenAIProvider(provider: Provider): provider is AzureOpenAIProvider {
return provider.type === 'azure-openai'
}
export function isOpenAIProvider(provider: Provider): boolean {
return provider.type === 'openai-response'
}
export function isAnthropicProvider(provider: Provider): boolean {
return provider.type === 'anthropic'
}
export function isGeminiProvider(provider: Provider): boolean {
return provider.type === 'gemini'
}
const NOT_SUPPORT_API_VERSION_PROVIDERS = ['github', 'copilot'] as const satisfies SystemProviderId[]
export const isSupportAPIVersionProvider = (provider: Provider) => {
if (isSystemProvider(provider)) {
return !NOT_SUPPORT_API_VERSION_PROVIDERS.some((pid) => pid === provider.id)
}
return provider.apiOptions?.isNotSupportAPIVersion !== false
}

View File

@@ -0,0 +1,53 @@
import { PermissionMode } from '@renderer/types'
export type PermissionModeCard = {
mode: PermissionMode
titleKey: string
titleFallback: string
descriptionKey: string
descriptionFallback: string
behaviorKey: string
behaviorFallback: string
caution?: boolean
unsupported?: boolean
}
export const permissionModeCards: PermissionModeCard[] = [
{
mode: 'default',
titleKey: 'agent.settings.tooling.permissionMode.default.title',
titleFallback: 'Default (ask before continuing)',
descriptionKey: 'agent.settings.tooling.permissionMode.default.description',
descriptionFallback: 'Read-only tools are pre-approved; everything else still needs permission.',
behaviorKey: 'agent.settings.tooling.permissionMode.default.behavior',
behaviorFallback: 'Read-only tools are pre-approved automatically.'
},
{
mode: 'plan',
titleKey: 'agent.settings.tooling.permissionMode.plan.title',
titleFallback: 'Planning mode',
descriptionKey: 'agent.settings.tooling.permissionMode.plan.description',
descriptionFallback: 'Shares the default read-only tool set but presents a plan before execution.',
behaviorKey: 'agent.settings.tooling.permissionMode.plan.behavior',
behaviorFallback: 'Read-only defaults are pre-approved while execution remains disabled.'
},
{
mode: 'acceptEdits',
titleKey: 'agent.settings.tooling.permissionMode.acceptEdits.title',
titleFallback: 'Auto-accept file edits',
descriptionKey: 'agent.settings.tooling.permissionMode.acceptEdits.description',
descriptionFallback: 'File edits and filesystem operations are automatically approved.',
behaviorKey: 'agent.settings.tooling.permissionMode.acceptEdits.behavior',
behaviorFallback: 'Pre-approves trusted filesystem tools so edits run immediately.'
},
{
mode: 'bypassPermissions',
titleKey: 'agent.settings.tooling.permissionMode.bypassPermissions.title',
titleFallback: 'Bypass permission checks',
descriptionKey: 'agent.settings.tooling.permissionMode.bypassPermissions.description',
descriptionFallback: 'All permission prompts are skipped — use with caution.',
behaviorKey: 'agent.settings.tooling.permissionMode.bypassPermissions.behavior',
behaviorFallback: 'Every tool is pre-approved automatically.',
caution: true
}
]

View File

@@ -1,6 +1,5 @@
/// <reference types="vite/client" />
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast'
import type KeyvStorage from '@kangfenmao/keyv-storage'
import { HookAPI } from 'antd/es/modal/useModal'
@@ -35,14 +34,5 @@ declare global {
info: typeof info
loading: typeof loading
}
agentTools: {
respondToPermission: (payload: {
requestId: string
behavior: 'allow' | 'deny'
updatedInput?: Record<string, unknown>
message?: string
updatedPermissions?: PermissionUpdate[]
}) => Promise<{ success: boolean }>
}
}
}

View File

@@ -1,49 +0,0 @@
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useAppDispatch } from '@renderer/store'
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { CreateSessionForm } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
/**
* Returns a stable callback that creates a default agent session and updates UI state.
*/
export const useCreateDefaultSession = (agentId: string | null) => {
const { agent } = useAgent(agentId)
const { createSession } = useSessions(agentId)
const dispatch = useAppDispatch()
const { t } = useTranslation()
const [creatingSession, setCreatingSession] = useState(false)
const createDefaultSession = useCallback(async () => {
if (!agentId || !agent || creatingSession) {
return null
}
setCreatingSession(true)
try {
const session = {
...agent,
id: undefined,
name: t('common.unnamed')
} satisfies CreateSessionForm
const created = await createSession(session)
if (created) {
dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id }))
dispatch(setActiveTopicOrSessionAction('session'))
}
return created
} finally {
setCreatingSession(false)
}
}, [agentId, agent, createSession, creatingSession, dispatch, t])
return {
createDefaultSession,
creatingSession
}
}

View File

@@ -11,18 +11,12 @@ import { useAppSelector } from '@renderer/store'
import { handleSaveData } from '@renderer/store'
import { selectMemoryConfig } from '@renderer/store/memory'
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
import {
type ToolPermissionRequestPayload,
type ToolPermissionResultPayload,
toolPermissionsActions
} from '@renderer/store/toolPermissions'
import { delay, runAsyncFunction } from '@renderer/utils'
import { checkDataLimit } from '@renderer/utils'
import { defaultLanguage } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useDefaultModel } from './useAssistant'
import useFullScreenNotice from './useFullScreenNotice'
@@ -33,7 +27,6 @@ import useUpdateHandler from './useUpdateHandler'
const logger = loggerService.withContext('useAppInit')
export function useAppInit() {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const {
proxyUrl,
@@ -155,64 +148,6 @@ export function useAppInit() {
}
}, [customCss])
useEffect(() => {
if (!window.electron?.ipcRenderer) return
const requestListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionRequestPayload) => {
logger.debug('Renderer received tool permission request', {
requestId: payload.requestId,
toolName: payload.toolName,
expiresAt: payload.expiresAt,
suggestionCount: payload.suggestions.length
})
dispatch(toolPermissionsActions.requestReceived(payload))
}
const resultListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionResultPayload) => {
logger.debug('Renderer received tool permission result', {
requestId: payload.requestId,
behavior: payload.behavior,
reason: payload.reason
})
dispatch(toolPermissionsActions.requestResolved(payload))
if (payload.behavior === 'deny') {
const message =
payload.reason === 'timeout'
? (payload.message ?? t('agent.toolPermission.toast.timeout'))
: (payload.message ?? t('agent.toolPermission.toast.denied'))
if (payload.reason === 'no-window') {
logger.debug('Displaying deny toast for tool permission', {
requestId: payload.requestId,
behavior: payload.behavior,
reason: payload.reason
})
window.toast?.error?.(message)
} else if (payload.reason === 'timeout') {
logger.debug('Displaying timeout toast for tool permission', {
requestId: payload.requestId
})
window.toast?.warning?.(message)
} else {
logger.debug('Displaying info toast for tool permission deny', {
requestId: payload.requestId,
reason: payload.reason
})
window.toast?.info?.(message)
}
}
}
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Request, requestListener)
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Result, resultListener)
return () => {
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Request, requestListener)
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Result, resultListener)
}
}, [dispatch, t])
useEffect(() => {
// TODO: init data collection
}, [enableDataCollection])

View File

@@ -1,4 +1,3 @@
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addAssistantPreset,
@@ -9,22 +8,8 @@ import {
} from '@renderer/store/assistants'
import { AssistantPreset, AssistantSettings } from '@renderer/types'
const logger = loggerService.withContext('useAssistantPresets')
function ensurePresetsArray(storedPresets: unknown): AssistantPreset[] {
if (Array.isArray(storedPresets)) {
return storedPresets
}
logger.warn('Unexpected data type from state.assistants.presets, falling back to empty list.', {
type: typeof storedPresets,
value: storedPresets
})
return []
}
export function useAssistantPresets() {
const storedPresets = useAppSelector((state) => state.assistants.presets)
const presets = ensurePresetsArray(storedPresets)
const presets = useAppSelector((state) => state.assistants.presets)
const dispatch = useAppDispatch()
return {
@@ -36,23 +21,14 @@ export function useAssistantPresets() {
}
export function useAssistantPreset(id: string) {
const storedPresets = useAppSelector((state) => state.assistants.presets)
const presets = ensurePresetsArray(storedPresets)
const preset = presets.find((a) => a.id === id)
// FIXME: undefined is not handled
const preset = useAppSelector((state) => state.assistants.presets.find((a) => a.id === id) as AssistantPreset)
const dispatch = useAppDispatch()
if (!preset) {
logger.warn(`Assistant preset with id ${id} not found in state.`)
}
return {
preset: preset,
preset,
updateAssistantPreset: (preset: AssistantPreset) => dispatch(updateAssistantPreset(preset)),
updateAssistantPresetSettings: (settings: Partial<AssistantSettings>) => {
if (!preset) {
logger.warn(`Failed to update assistant preset settings because preset with id ${id} is missing.`)
return
}
dispatch(updateAssistantPresetSettings({ assistantId: preset.id, settings }))
}
}

View File

@@ -88,7 +88,7 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
if (e.key === 'Enter') {
e.preventDefault()
saveEdit()
} else if (e.key === 'Escape') {

View File

@@ -57,7 +57,7 @@ export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
label: t('settings.tool.preprocess.provider'),
title: t('settings.tool.preprocess.provider'),
options: preprocessProviders
.filter((p) => p.apiKey !== '' || ['mineru', 'open-mineru'].includes(p.id))
.filter((p) => p.apiKey !== '' || p.id === 'mineru')
.map((p) => ({ value: p.id, label: p.name }))
}
return [preprocessOptions]

View File

@@ -1,163 +0,0 @@
import type { InstalledPlugin, PluginError, PluginMetadata } from '@renderer/types/plugin'
import { useCallback, useEffect, useState } from 'react'
/**
* Helper to extract error message from PluginError union type
*/
function getPluginErrorMessage(error: PluginError, defaultMessage: string): string {
if ('message' in error && error.message) return error.message
if ('reason' in error) return error.reason
if ('path' in error) return `Error with file: ${error.path}`
return defaultMessage
}
/**
* Hook to fetch and cache available plugins from the resources directory
* @returns Object containing available agents, commands, skills, loading state, and error
*/
export function useAvailablePlugins() {
const [agents, setAgents] = useState<PluginMetadata[]>([])
const [commands, setCommands] = useState<PluginMetadata[]>([])
const [skills, setSkills] = useState<PluginMetadata[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchAvailablePlugins = async () => {
setLoading(true)
setError(null)
try {
const result = await window.api.claudeCodePlugin.listAvailable()
if (result.success) {
setAgents(result.data.agents)
setCommands(result.data.commands)
setSkills(result.data.skills)
} else {
setError(getPluginErrorMessage(result.error, 'Failed to load available plugins'))
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred')
} finally {
setLoading(false)
}
}
fetchAvailablePlugins()
}, [])
return { agents, commands, skills, loading, error }
}
/**
* Hook to fetch installed plugins for a specific agent
* @param agentId - The ID of the agent to fetch plugins for
* @returns Object containing installed plugins, loading state, error, and refresh function
*/
export function useInstalledPlugins(agentId: string | undefined) {
const [plugins, setPlugins] = useState<InstalledPlugin[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
if (!agentId) {
setPlugins([])
setLoading(false)
setError(null)
return
}
setLoading(true)
setError(null)
try {
const result = await window.api.claudeCodePlugin.listInstalled(agentId)
if (result.success) {
setPlugins(result.data)
} else {
setError(getPluginErrorMessage(result.error, 'Failed to load installed plugins'))
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred')
} finally {
setLoading(false)
}
}, [agentId])
useEffect(() => {
refresh()
}, [refresh])
return { plugins, loading, error, refresh }
}
/**
* Hook to provide install and uninstall actions for plugins
* @param agentId - The ID of the agent to perform actions for
* @param onSuccess - Optional callback to be called on successful operations
* @returns Object containing install, uninstall functions and their loading states
*/
export function usePluginActions(agentId: string, onSuccess?: () => void) {
const [installing, setInstalling] = useState<boolean>(false)
const [uninstalling, setUninstalling] = useState<boolean>(false)
const install = useCallback(
async (sourcePath: string, type: 'agent' | 'command' | 'skill') => {
setInstalling(true)
try {
const result = await window.api.claudeCodePlugin.install({
agentId,
sourcePath,
type
})
if (result.success) {
onSuccess?.()
return { success: true as const, data: result.data }
} else {
const errorMessage = getPluginErrorMessage(result.error, 'Failed to install plugin')
return { success: false as const, error: errorMessage }
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
return { success: false as const, error: errorMessage }
} finally {
setInstalling(false)
}
},
[agentId, onSuccess]
)
const uninstall = useCallback(
async (filename: string, type: 'agent' | 'command' | 'skill') => {
setUninstalling(true)
try {
const result = await window.api.claudeCodePlugin.uninstall({
agentId,
filename,
type
})
if (result.success) {
onSuccess?.()
return { success: true as const }
} else {
const errorMessage = getPluginErrorMessage(result.error, 'Failed to uninstall plugin')
return { success: false as const, error: errorMessage }
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
return { success: false as const, error: errorMessage }
} finally {
setUninstalling(false)
}
},
[agentId, onSuccess]
)
return { install, uninstall, installing, uninstalling }
}

View File

@@ -42,7 +42,7 @@ export function getVertexAIServiceAccount() {
* 类型守卫:检查 Provider 是否为 VertexProvider
*/
export function isVertexProvider(provider: Provider): provider is VertexProvider {
return provider.type === 'vertexai'
return provider.type === 'vertexai' && 'googleCredentials' in provider
}
/**

View File

@@ -88,9 +88,7 @@ const providerKeyMap = {
zhinao: 'provider.zhinao',
zhipu: 'provider.zhipu',
poe: 'provider.poe',
aionly: 'provider.aionly',
longcat: 'provider.longcat',
huggingface: 'provider.huggingface'
aionly: 'provider.aionly'
} as const
/**
@@ -165,21 +163,9 @@ export const getThemeModeLabel = (key: string): string => {
return getLabel(themeModeKeyMap, key)
}
// const sidebarIconKeyMap = {
// assistants: t('assistants.title'),
// store: t('assistants.presets.title'),
// paintings: t('paintings.title'),
// translate: t('translate.title'),
// minapp: t('minapp.title'),
// knowledge: t('knowledge.title'),
// files: t('files.title'),
// code_tools: t('code.title'),
// notes: t('notes.title')
// } as const
const sidebarIconKeyMap = {
assistants: 'assistants.title',
store: 'assistants.presets.title',
agents: 'agents.title',
paintings: 'paintings.title',
translate: 'translate.title',
minapp: 'minapp.title',

View File

@@ -107,50 +107,6 @@
"title": "Advanced Settings"
},
"essential": "Essential Settings",
"plugins": {
"available": {
"title": "Available Plugins"
},
"confirm": {
"uninstall": "Are you sure you want to uninstall this plugin?"
},
"empty": {
"available": "No plugins found matching your filters. Try adjusting your search or category filters."
},
"error": {
"install": "Failed to install plugin",
"load": "Failed to load plugins",
"uninstall": "Failed to uninstall plugin"
},
"filter": {
"all": "All Categories"
},
"install": "Install",
"installed": {
"empty": "No plugins installed yet. Browse available plugins to get started.",
"title": "Installed Plugins"
},
"installing": "Installing...",
"results": "{{count}} plugin(s) found",
"search": {
"placeholder": "Search plugins..."
},
"success": {
"install": "Plugin installed successfully",
"uninstall": "Plugin uninstalled successfully"
},
"tab": "Plugins",
"type": {
"agent": "Agent",
"agents": "Agents",
"all": "All",
"command": "Command",
"commands": "Commands",
"skills": "Skills"
},
"uninstall": "Uninstall",
"uninstalling": "Uninstalling..."
},
"prompt": "Prompt Settings",
"tooling": {
"mcp": {
@@ -235,39 +191,6 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Allow tool request",
"denyRequest": "Deny tool request",
"hideDetails": "Hide tool details",
"runWithOptions": "Run with additional options",
"showDetails": "Show tool details"
},
"button": {
"cancel": "Cancel",
"run": "Run"
},
"confirmation": "Are you sure you want to run this Claude tool?",
"defaultDenyMessage": "User denied permission for this tool.",
"defaultDescription": "Executes code or system actions in your environment. Make sure the command looks safe before running it.",
"error": {
"sendFailed": "Failed to send your decision. Please try again."
},
"expired": "Expired",
"inputPreview": "Tool input preview",
"pending": "Pending ({{seconds}}s)",
"permissionExpired": "Permission request expired. Waiting for new instructions...",
"requiresElevatedPermissions": "This tool requires elevated permissions.",
"suggestion": {
"permissionUpdateMultiple": "Approving may update multiple session permissions if you chose to always allow this tool.",
"permissionUpdateSingle": "Approving may update your session permissions if you chose to always allow this tool."
},
"toast": {
"denied": "Tool request was denied.",
"timeout": "Tool request timed out before receiving approval."
},
"waiting": "Waiting for tool permission decision..."
},
"type": {
"label": "Agent Type",
"unknown": "Unknown Type"
@@ -2376,32 +2299,6 @@
"seed_tip": "Controls upscaling randomness"
}
},
"plugins": {
"actions": "Actions",
"agents": "Agents",
"all_categories": "All Categories",
"all_types": "All",
"category": "Category",
"commands": "Commands",
"confirm_uninstall": "Are you sure you want to uninstall {{name}}?",
"install": "Install",
"install_plugins_from_browser": "Browse available plugins to get started",
"installing": "Installing...",
"name": "Name",
"no_description": "No description available",
"no_installed_plugins": "No plugins installed yet",
"no_results": "No plugins found",
"search_placeholder": "Search plugins...",
"showing_results": "Showing {{count}} plugin",
"showing_results_one": "Showing {{count}} plugin",
"showing_results_other": "Showing {{count}} plugins",
"showing_results_plural": "Showing {{count}} plugins",
"skills": "Skills",
"try_different_search": "Try adjusting your search or category filters",
"type": "Type",
"uninstall": "Uninstall",
"uninstalling": "Uninstalling..."
},
"preview": {
"copy": {
"image": "Copy as image"
@@ -2448,14 +2345,12 @@
"gpustack": "GPUStack",
"grok": "Grok",
"groq": "Groq",
"huggingface": "Hugging Face",
"hunyuan": "Tencent Hunyuan",
"hyperbolic": "Hyperbolic",
"infini": "Infini",
"jina": "Jina",
"lanyun": "LANYUN",
"lmstudio": "LM Studio",
"longcat": "LongCat AI",
"minimax": "MiniMax",
"mistral": "Mistral",
"modelscope": "ModelScope",
@@ -4148,6 +4043,7 @@
},
"anthropic_api_host": "Anthropic API Host",
"anthropic_api_host_preview": "Anthropic preview: {{url}}",
"anthropic_api_host_tip": "Only configure this when your provider exposes an Anthropic-compatible endpoint. Ending with / ignores v1, ending with # forces use of input address.",
"anthropic_api_host_tooltip": "Use only when the provider offers a Claude-compatible base URL.",
"api": {
"key": {
@@ -4192,11 +4088,10 @@
"url": {
"preview": "Preview: {{url}}",
"reset": "Reset",
"tip": "ending with # forces use of input address"
"tip": "Ending with / ignores v1, ending with # forces use of input address"
}
},
"api_host": "API Host",
"api_host_no_valid": "API address is invalid",
"api_host_preview": "Preview: {{url}}",
"api_host_tooltip": "Override only when your provider requires a custom OpenAI-compatible endpoint.",
"api_key": {

View File

@@ -107,50 +107,6 @@
"title": "高级设置"
},
"essential": "基础设置",
"plugins": {
"available": {
"title": "可用插件"
},
"confirm": {
"uninstall": "确定要卸载此插件吗?"
},
"empty": {
"available": "未找到匹配的插件。请尝试调整搜索或类别筛选。"
},
"error": {
"install": "安装插件失败",
"load": "加载插件失败",
"uninstall": "卸载插件失败"
},
"filter": {
"all": "所有类别"
},
"install": "安装",
"installed": {
"empty": "尚未安装任何插件。浏览可用插件以开始使用。",
"title": "已安装插件"
},
"installing": "安装中...",
"results": "找到 {{count}} 个插件",
"search": {
"placeholder": "搜索插件..."
},
"success": {
"install": "插件安装成功",
"uninstall": "插件卸载成功"
},
"tab": "插件",
"type": {
"agent": "代理",
"agents": "代理",
"all": "全部",
"command": "命令",
"commands": "命令",
"skills": "技能"
},
"uninstall": "卸载",
"uninstalling": "卸载中..."
},
"prompt": "提示词设置",
"tooling": {
"mcp": {
@@ -235,39 +191,6 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "允许工具请求",
"denyRequest": "拒绝工具请求",
"hideDetails": "隐藏工具详情",
"runWithOptions": "带选项运行",
"showDetails": "显示工具详情"
},
"button": {
"cancel": "取消",
"run": "运行"
},
"confirmation": "确定要运行此 Claude 工具吗?",
"defaultDenyMessage": "用户拒绝了该工具的权限。",
"defaultDescription": "在您的环境中执行代码或系统操作。运行前请确保命令安全。",
"error": {
"sendFailed": "发送您的决定失败,请重试。"
},
"expired": "已过期",
"inputPreview": "工具输入预览",
"pending": "等待中 ({{seconds}}秒)",
"permissionExpired": "权限请求已过期。等待新指令...",
"requiresElevatedPermissions": "此工具需要更高权限。",
"suggestion": {
"permissionUpdateMultiple": "如果您选择总是允许此工具,批准可能会更新多个会话权限。",
"permissionUpdateSingle": "如果您选择总是允许此工具,批准可能会更新您的会话权限。"
},
"toast": {
"denied": "工具请求已被拒绝。",
"timeout": "工具请求在收到批准前超时。"
},
"waiting": "等待工具权限决定..."
},
"type": {
"label": "智能体类型",
"unknown": "未知类型"
@@ -2376,32 +2299,6 @@
"seed_tip": "控制放大结果的随机性"
}
},
"plugins": {
"actions": "操作",
"agents": "代理",
"all_categories": "所有类别",
"all_types": "全部",
"category": "类别",
"commands": "命令",
"confirm_uninstall": "确定要卸载 {{name}} 吗?",
"install": "安装",
"install_plugins_from_browser": "浏览可用插件以开始使用",
"installing": "安装中...",
"name": "名称",
"no_description": "无描述",
"no_installed_plugins": "尚未安装任何插件",
"no_results": "未找到插件",
"search_placeholder": "搜索插件...",
"showing_results": "显示 {{count}} 个插件",
"showing_results_one": "显示 {{count}} 个插件",
"showing_results_other": "显示 {{count}} 个插件",
"showing_results_plural": "显示 {{count}} 个插件",
"skills": "技能",
"try_different_search": "请尝试调整搜索或类别筛选",
"type": "类型",
"uninstall": "卸载",
"uninstalling": "卸载中..."
},
"preview": {
"copy": {
"image": "复制为图片"
@@ -2448,14 +2345,12 @@
"gpustack": "GPUStack",
"grok": "Grok",
"groq": "Groq",
"huggingface": "Hugging Face",
"hunyuan": "腾讯混元",
"hyperbolic": "Hyperbolic",
"infini": "无问芯穹",
"jina": "Jina",
"lanyun": "蓝耘科技",
"lmstudio": "LM Studio",
"longcat": "龙猫",
"minimax": "MiniMax",
"mistral": "Mistral",
"modelscope": "ModelScope 魔搭",
@@ -4148,6 +4043,7 @@
},
"anthropic_api_host": "Anthropic API 地址",
"anthropic_api_host_preview": "Anthropic 预览:{{url}}",
"anthropic_api_host_tip": "仅在服务商提供兼容 Anthropic 的地址时填写。以 / 结尾会忽略自动追加的 v1以 # 结尾则强制使用原始地址。",
"anthropic_api_host_tooltip": "仅当服务商提供 Claude 兼容的基础地址时填写。",
"api": {
"key": {
@@ -4192,11 +4088,10 @@
"url": {
"preview": "预览: {{url}}",
"reset": "重置",
"tip": "# 结尾强制使用输入地址"
"tip": "/ 结尾忽略 v1 版本,# 结尾强制使用输入地址"
}
},
"api_host": "API 地址",
"api_host_no_valid": "API 地址不合法",
"api_host_preview": "预览:{{url}}",
"api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。",
"api_key": {

View File

@@ -107,50 +107,6 @@
"title": "進階設定"
},
"essential": "必要設定",
"plugins": {
"available": {
"title": "可用外掛"
},
"confirm": {
"uninstall": "確定要解除安裝此外掛嗎?"
},
"empty": {
"available": "未找到符合的外掛。請嘗試調整搜尋或類別篩選。"
},
"error": {
"install": "安裝外掛失敗",
"load": "載入外掛失敗",
"uninstall": "解除安裝外掛失敗"
},
"filter": {
"all": "所有類別"
},
"install": "安裝",
"installed": {
"empty": "尚未安裝任何外掛。瀏覽可用外掛以開始使用。",
"title": "已安裝外掛"
},
"installing": "安裝中...",
"results": "找到 {{count}} 個外掛",
"search": {
"placeholder": "搜尋外掛..."
},
"success": {
"install": "外掛安裝成功",
"uninstall": "外掛解除安裝成功"
},
"tab": "外掛",
"type": {
"agent": "代理",
"agents": "代理",
"all": "全部",
"command": "指令",
"commands": "指令",
"skills": "技能"
},
"uninstall": "解除安裝",
"uninstalling": "解除安裝中..."
},
"prompt": "提示設定",
"tooling": {
"mcp": {
@@ -235,39 +191,6 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "允許工具請求",
"denyRequest": "拒絕工具請求",
"hideDetails": "隱藏工具詳情",
"runWithOptions": "帶選項執行",
"showDetails": "顯示工具詳情"
},
"button": {
"cancel": "取消",
"run": "執行"
},
"confirmation": "確定要執行此 Claude 工具嗎?",
"defaultDenyMessage": "使用者拒絕了該工具的權限。",
"defaultDescription": "在您的環境中執行程式碼或系統操作。執行前請確保指令安全。",
"error": {
"sendFailed": "傳送您的決定失敗,請重試。"
},
"expired": "已過期",
"inputPreview": "工具輸入預覽",
"pending": "等待中 ({{seconds}}秒)",
"permissionExpired": "權限請求已過期。等待新指令...",
"requiresElevatedPermissions": "此工具需要提升的權限。",
"suggestion": {
"permissionUpdateMultiple": "如果您選擇總是允許此工具,核准可能會更新多個工作階段權限。",
"permissionUpdateSingle": "如果您選擇總是允許此工具,核准可能會更新您的工作階段權限。"
},
"toast": {
"denied": "工具請求已被拒絕。",
"timeout": "工具請求在收到核准前逾時。"
},
"waiting": "等待工具權限決定..."
},
"type": {
"label": "代理類型",
"unknown": "未知類型"
@@ -2376,32 +2299,6 @@
"seed_tip": "控制放大結果的隨機性"
}
},
"plugins": {
"actions": "操作",
"agents": "代理",
"all_categories": "所有類別",
"all_types": "全部",
"category": "類別",
"commands": "指令",
"confirm_uninstall": "確定要解除安裝 {{name}} 嗎?",
"install": "安裝",
"install_plugins_from_browser": "瀏覽可用外掛以開始使用",
"installing": "安裝中...",
"name": "名稱",
"no_description": "無描述",
"no_installed_plugins": "尚未安裝任何外掛",
"no_results": "未找到外掛",
"search_placeholder": "搜尋外掛...",
"showing_results": "顯示 {{count}} 個外掛",
"showing_results_one": "顯示 {{count}} 個外掛",
"showing_results_other": "顯示 {{count}} 個外掛",
"showing_results_plural": "顯示 {{count}} 個外掛",
"skills": "技能",
"try_different_search": "請嘗試調整搜尋或類別篩選",
"type": "類型",
"uninstall": "解除安裝",
"uninstalling": "解除安裝中..."
},
"preview": {
"copy": {
"image": "複製為圖片"
@@ -2448,14 +2345,12 @@
"gpustack": "GPUStack",
"grok": "Grok",
"groq": "Groq",
"huggingface": "Hugging Face",
"hunyuan": "騰訊混元",
"hyperbolic": "Hyperbolic",
"infini": "無問芯穹",
"jina": "Jina",
"lanyun": "藍耘",
"lmstudio": "LM Studio",
"longcat": "龍貓",
"minimax": "MiniMax",
"mistral": "Mistral",
"modelscope": "ModelScope 魔搭",
@@ -4148,6 +4043,7 @@
},
"anthropic_api_host": "Anthropic API 主機地址",
"anthropic_api_host_preview": "Anthropic 預覽:{{url}}",
"anthropic_api_host_tip": "僅在服務商提供與 Anthropic 相容的網址時設定。以 / 結尾會忽略自動附加的 v1以 # 結尾則強制使用原始地址。",
"anthropic_api_host_tooltip": "僅在服務商提供 Claude 相容的基礎網址時設定。",
"api": {
"key": {
@@ -4192,11 +4088,10 @@
"url": {
"preview": "預覽:{{url}}",
"reset": "重設",
"tip": "# 結尾強制使用輸入位址"
"tip": "/ 結尾忽略 v1 版本,# 結尾強制使用輸入位址"
}
},
"api_host": "API 主機地址",
"api_host_no_valid": "API 位址不合法",
"api_host_preview": "預覽:{{url}}",
"api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。",
"api_key": {

View File

@@ -107,50 +107,6 @@
"title": "Erweiterte Einstellungen"
},
"essential": "Grundeinstellungen",
"plugins": {
"available": {
"title": "Verfügbare Plugins"
},
"confirm": {
"uninstall": "Sind Sie sicher, dass Sie dieses Plugin deinstallieren möchten?"
},
"empty": {
"available": "Keine Plugins gefunden, die deinen Filtern entsprechen. Versuche, deine Such- oder Kategoriefilter anzupassen."
},
"error": {
"install": "Fehler beim Installieren des Plugins",
"load": "Fehler beim Laden der Plugins",
"uninstall": "Fehler beim Deinstallieren des Plugins"
},
"filter": {
"all": "Alle Kategorien"
},
"install": "Installieren",
"installed": {
"empty": "Noch keine Plugins installiert. Durchsuche verfügbare Plugins, um loszulegen.",
"title": "Installierte Plugins"
},
"installing": "Wird installiert...",
"results": "{{count}} Plugin(s) gefunden",
"search": {
"placeholder": "Such-Plugins..."
},
"success": {
"install": "Plugin erfolgreich installiert",
"uninstall": "Plugin erfolgreich deinstalliert"
},
"tab": "Plugins",
"type": {
"agent": "Agent",
"agents": "Agenten",
"all": "Alle",
"command": "Befehl",
"commands": "Befehle",
"skills": "Fähigkeiten"
},
"uninstall": "Deinstallieren",
"uninstalling": "Deinstallation läuft..."
},
"prompt": "Prompt-Einstellungen",
"tooling": {
"mcp": {
@@ -235,39 +191,6 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Werkzeuganfrage zulassen",
"denyRequest": "Werkzeuganfrage ablehnen",
"hideDetails": "Werkzeugdetails ausblenden",
"runWithOptions": "Mit zusätzlichen Optionen ausführen",
"showDetails": "Zeige Werkzeugdetails"
},
"button": {
"cancel": "Abbrechen",
"run": "Laufen"
},
"confirmation": "Bist du sicher, dass du dieses Claude-Tool ausführen möchtest?",
"defaultDenyMessage": "Der Benutzer hat die Berechtigung für dieses Tool verweigert.",
"defaultDescription": "Führt Code oder Systemaktionen in Ihrer Umgebung aus. Vergewissern Sie sich, dass der Befehl sicher aussieht, bevor Sie ihn ausführen.",
"error": {
"sendFailed": "Ihre Entscheidung konnte nicht gesendet werden. Bitte versuchen Sie es erneut."
},
"expired": "Abgelaufen",
"inputPreview": "Vorschau der Werkzeugeingabe",
"pending": "Ausstehend ({{seconds}}s)",
"permissionExpired": "Berechtigungsanfrage abgelaufen. Warte auf neue Anweisungen...",
"requiresElevatedPermissions": "Dieses Tool erfordert erhöhte Berechtigungen.",
"suggestion": {
"permissionUpdateMultiple": "Das Genehmigen kann mehrere Sitzungsberechtigungen aktualisieren, wenn Sie sich entschieden haben, dieses Tool immer zuzulassen.",
"permissionUpdateSingle": "Das Genehmigen kann Ihre Sitzungsberechtigungen aktualisieren, wenn Sie sich entschieden haben, dieses Tool immer zuzulassen."
},
"toast": {
"denied": "Tool-Anfrage wurde abgelehnt.",
"timeout": "Tool-Anfrage ist abgelaufen, bevor eine Genehmigung eingegangen ist."
},
"waiting": "Warten auf Entscheidung über Tool-Berechtigung..."
},
"type": {
"label": "Agent-Typ",
"unknown": "Unbekannter Typ"
@@ -2376,32 +2299,6 @@
"seed_tip": "Kontrolle der Zufälligkeit des Upscale-Ergebnisses"
}
},
"plugins": {
"actions": "Aktionen",
"agents": "Agenten",
"all_categories": "Alle Kategorien",
"all_types": "Alle",
"category": "Kategorie",
"commands": "Befehle",
"confirm_uninstall": "Sind Sie sicher, dass Sie {{name}} deinstallieren möchten?",
"install": "Installieren",
"install_plugins_from_browser": "Durchsuche verfügbare Plugins, um loszulegen",
"installing": "Installiere…",
"name": "Name",
"no_description": "Keine Beschreibung verfügbar",
"no_installed_plugins": "Noch keine Plugins installiert",
"no_results": "Keine Plugins gefunden",
"search_placeholder": "Such-Plugins...",
"showing_results": "{{count}} Plugin anzeigen",
"showing_results_one": "{{count}} Plugin anzeigen",
"showing_results_other": "Zeige {{count}} Plugins",
"showing_results_plural": "{{count}} Plugins anzeigen",
"skills": "Fähigkeiten",
"try_different_search": "Versuchen Sie, Ihre Suche oder die Kategoriefilter anzupassen.",
"type": "Typ",
"uninstall": "Deinstallieren",
"uninstalling": "Deinstallation läuft..."
},
"preview": {
"copy": {
"image": "Als Bild kopieren"
@@ -2448,14 +2345,12 @@
"gpustack": "GPUStack",
"grok": "Grok",
"groq": "Groq",
"huggingface": "Hugging Face",
"hunyuan": "Tencent Hunyuan",
"hyperbolic": "Hyperbolic",
"infini": "Infini-AI",
"jina": "Jina",
"lanyun": "Lanyun Technologie",
"lmstudio": "LM Studio",
"longcat": "Meißner Riesenhamster",
"minimax": "MiniMax",
"mistral": "Mistral",
"modelscope": "ModelScope",
@@ -4148,6 +4043,7 @@
},
"anthropic_api_host": "Anthropic API-Adresse",
"anthropic_api_host_preview": "Anthropic-Vorschau: {{url}}",
"anthropic_api_host_tip": "Nur bei Anbietern, die ein Anthropic-kompatibles Endpunkt anbieten. Eine / am Ende ignoriert automatisch hinzugefügtes v1, ein # am Ende erzwingt die Verwendung der ursprünglichen Adresse.",
"anthropic_api_host_tooltip": "Nur bei Anbietern, die ein Claude-kompatibles Basis-Endpunkt anbieten.",
"api": {
"key": {
@@ -4192,11 +4088,10 @@
"url": {
"preview": "Vorschau: {{url}}",
"reset": "Zurücksetzen",
"tip": "# am Ende erzwingt die Verwendung der Eingabe-Adresse"
"tip": "/ am Ende ignorieren v1-Version, # am Ende erzwingt die Verwendung der Eingabe-Adresse"
}
},
"api_host": "API-Adresse",
"api_host_no_valid": "API-Adresse ist ungültig",
"api_host_preview": "Vorschau: {{url}}",
"api_host_tooltip": "Nur bei Anbietern, die ein OpenAI-kompatibles Endpunkt anbieten. Eine / am Ende ignoriert automatisch hinzugefügtes v1, ein # am Ende erzwingt die Verwendung der ursprünglichen Adresse.",
"api_key": {

View File

@@ -107,50 +107,6 @@
"title": "Ρυθμίσεις για προχωρημένους"
},
"essential": "Βασικές Ρυθμίσεις",
"plugins": {
"available": {
"title": "Διαθέσιμα πρόσθετα"
},
"confirm": {
"uninstall": "Είστε βέβαιοι ότι θέλετε να απεγκαταστήσετε αυτό το πρόσθετο;"
},
"empty": {
"available": "Δεν βρέθηκε συμβατό πρόσθετο. Δοκιμάστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών."
},
"error": {
"install": "Η εγκατάσταση του πρόσθετου απέτυχε",
"load": "Η φόρτωση του πρόσθετου απέτυχε",
"uninstall": "Η απεγκατάσταση του πρόσθετου απέτυχε"
},
"filter": {
"all": "Όλες οι κατηγορίες"
},
"install": "εγκατάσταση",
"installed": {
"empty": "Δεν έχει εγκατασταθεί κανένα πρόσθετο. Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε.",
"title": "Έχει εγκατασταθεί το πρόσθετο"
},
"installing": "Εγκατάσταση...",
"results": "Βρέθηκαν {{count}} πρόσθετα",
"search": {
"placeholder": "Αναζήτηση πρόσθετου..."
},
"success": {
"install": "Η εγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία",
"uninstall": "Η απεγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία"
},
"tab": "Πρόσθετο",
"type": {
"agent": "αντιπρόσωπος",
"agents": "αντιπρόσωπος",
"all": "όλα",
"command": "εντολή",
"commands": "εντολή",
"skills": "δεξιότητα"
},
"uninstall": "απεγκατάσταση",
"uninstalling": "Απεγκατάσταση..."
},
"prompt": "Ρυθμίσεις Προτροπής",
"tooling": {
"mcp": {
@@ -235,39 +191,6 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Επίτρεψη αίτησης εργαλείου",
"denyRequest": "Απόρριψη αιτήματος εργαλείου",
"hideDetails": "Απόκρυψη λεπτομερειών εργαλείου",
"runWithOptions": "Εκτέλεση με επιπλέον επιλογές",
"showDetails": "Εμφάνιση λεπτομερειών εργαλείου"
},
"button": {
"cancel": "Ακύρωση",
"run": "Τρέξε"
},
"confirmation": "Είσαι σίγουρος ότι θέλεις να εκτελέσεις αυτό το εργαλείο Claude;",
"defaultDenyMessage": "Ο χρήστης αρνήθηκε την άδεια για αυτό το εργαλείο.",
"defaultDescription": "Εκτελεί κώδικα ή ενέργειες συστήματος στο περιβάλλον σας. Βεβαιωθείτε ότι η εντολή φαίνεται ασφαλής πριν την εκτελέσετε.",
"error": {
"sendFailed": "Αποτυχία αποστολής της απόφασής σας. Προσπαθήστε ξανά."
},
"expired": "Ληγμένο",
"inputPreview": "Προεπισκόπηση εισόδου εργαλείου",
"pending": "Εκκρεμεί ({{seconds}}δ)",
"permissionExpired": "Το αίτημα άδειας έληξε. Αναμονή για νέες οδηγίες...",
"requiresElevatedPermissions": "Αυτό το εργαλείο απαιτεί αυξημένα δικαιώματα.",
"suggestion": {
"permissionUpdateMultiple": "Η έγκριση μπορεί να ενημερώσει πολλές άδειες συνεδρίας αν επιλέξατε να επιτρέπετε πάντα αυτό το εργαλείο.",
"permissionUpdateSingle": "Η έγκριση ενδέχεται να ενημερώσει τα δικαιώματα περιόδου σύνδεσής σας, εάν επιλέξατε να επιτρέπετε πάντα αυτό το εργαλείο."
},
"toast": {
"denied": "Το αίτημα για εργαλείο απορρίφθηκε.",
"timeout": "Το αίτημα για το εργαλείο έληξε πριν λάβει έγκριση."
},
"waiting": "Αναμονή για απόφαση άδειας εργαλείου..."
},
"type": {
"label": "Τύπος Πράκτορα",
"unknown": "Άγνωστος Τύπος"
@@ -2376,32 +2299,6 @@
"seed_tip": "Ελέγχει την τυχαιότητα του αποτελέσματος μεγέθυνσης"
}
},
"plugins": {
"actions": "Λειτουργία",
"agents": "αντιπρόσωπος",
"all_categories": "Όλες οι κατηγορίες",
"all_types": "ολόκληρο",
"category": "Κατηγορία",
"commands": "εντολή",
"confirm_uninstall": "Είστε σίγουροι ότι θέλετε να απεγκαταστήσετε το {{name}};",
"install": "εγκατάσταση",
"install_plugins_from_browser": "Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε",
"installing": "Εγκατάσταση...",
"name": "Όνομα",
"no_description": "Χωρίς περιγραφή",
"no_installed_plugins": "Δεν έχει εγκατασταθεί κανένα πρόσθετο",
"no_results": "Δεν βρέθηκε πρόσθετο",
"search_placeholder": "Πρόσθετο αναζήτησης...",
"showing_results": "Εμφάνιση {{count}} προσθέτων",
"showing_results_one": "Εμφάνιση {{count}} προσθέτων",
"showing_results_other": "Εμφάνιση {{count}} προσθέτων",
"showing_results_plural": "Εμφάνιση {{count}} πρόσθετων",
"skills": "δεξιότητα",
"try_different_search": "Προσπαθήστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών",
"type": "τύπος",
"uninstall": "κατάργηση εγκατάστασης",
"uninstalling": "Απεγκατάσταση..."
},
"preview": {
"copy": {
"image": "Αντιγραφή ως εικόνα"
@@ -2448,14 +2345,12 @@
"gpustack": "GPUStack",
"grok": "Grok",
"groq": "Groq",
"huggingface": "Hugging Face",
"hunyuan": "Tencent Hunyuan",
"hyperbolic": "Υπερβολικός",
"infini": "Χωρίς Ερώτημα Xin Qiong",
"jina": "Jina",
"lanyun": "Λανιούν Τεχνολογία",
"lmstudio": "LM Studio",
"longcat": "Τσίρο",
"minimax": "MiniMax",
"mistral": "Mistral",
"modelscope": "ModelScope Magpie",
@@ -4148,6 +4043,7 @@
},
"anthropic_api_host": "Διεύθυνση API Anthropic",
"anthropic_api_host_preview": "Προεπισκόπηση Anthropic: {{url}}",
"anthropic_api_host_tip": "Συμπληρώστε μόνο εάν ο πάροχος προσφέρει συμβατή με Anthropic διεύθυνση. Η λήξη με / αγνοεί το v1 που προστίθεται αυτόματα, η λήξη με # επιβάλλει τη χρήση της αρχικής διεύθυνσης.",
"anthropic_api_host_tooltip": "Συμπληρώστε μόνο όταν ο πάροχος παρέχει βασική διεύθυνση συμβατή με Claude.",
"api": {
"key": {
@@ -4192,11 +4088,10 @@
"url": {
"preview": "Προεπισκόπηση: {{url}}",
"reset": "Επαναφορά",
"tip": "#τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
"tip": "/τέλος αγνόηση v1 έκδοσης, #τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
}
},
"api_host": "Διεύθυνση API",
"api_host_no_valid": "Η διεύθυνση API δεν είναι έγκυρη",
"api_host_preview": "Προεπισκόπηση: {{url}}",
"api_host_tooltip": "Αντικατάσταση μόνο όταν ο πάροχος απαιτεί προσαρμοσμένη διεύθυνση συμβατή με OpenAI.",
"api_key": {

View File

@@ -107,50 +107,6 @@
"title": "Configuración avanzada"
},
"essential": "Configuraciones esenciales",
"plugins": {
"available": {
"title": "Complementos disponibles"
},
"confirm": {
"uninstall": "¿Estás seguro de que quieres desinstalar este complemento?"
},
"empty": {
"available": "No se encontró ningún complemento que coincida. Intenta ajustar la búsqueda o los filtros de categoría."
},
"error": {
"install": "Error al instalar el complemento",
"load": "Error al cargar el complemento",
"uninstall": "Error al desinstalar el complemento"
},
"filter": {
"all": "Todas las categorías"
},
"install": "instalación",
"installed": {
"empty": "Aún no se ha instalado ningún complemento. Explora los complementos disponibles para comenzar.",
"title": "Complemento instalado"
},
"installing": "Instalando...",
"results": "Encontrados {{count}} complementos",
"search": {
"placeholder": "Buscar complemento..."
},
"success": {
"install": "Complemento instalado con éxito",
"uninstall": "Complemento desinstalado correctamente"
},
"tab": "complemento",
"type": {
"agent": "agente",
"agents": "Agente",
"all": "todo",
"command": "comando",
"commands": "comando",
"skills": "habilidad"
},
"uninstall": "Desinstalar",
"uninstalling": "Desinstalando..."
},
"prompt": "Configuración de indicaciones",
"tooling": {
"mcp": {
@@ -235,39 +191,6 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Permitir solicitud de herramienta",
"denyRequest": "Denegar solicitud de herramienta",
"hideDetails": "Ocultar detalles de la herramienta",
"runWithOptions": "Ejecutar con opciones adicionales",
"showDetails": "Mostrar detalles de la herramienta"
},
"button": {
"cancel": "Cancelar",
"run": "Correr"
},
"confirmation": "¿Estás seguro de que quieres ejecutar esta herramienta de Claude?",
"defaultDenyMessage": "El usuario denegó el permiso para esta herramienta.",
"defaultDescription": "Ejecuta código o acciones del sistema en tu entorno. Asegúrate de que el comando parezca seguro antes de ejecutarlo.",
"error": {
"sendFailed": "No se pudo enviar tu decisión. Por favor, inténtalo de nuevo."
},
"expired": "Caducado",
"inputPreview": "Vista previa de entrada de herramienta",
"pending": "Pendiente ({{seconds}}s)",
"permissionExpired": "Solicitud de permiso expirada. Esperando nuevas instrucciones...",
"requiresElevatedPermissions": "Esta herramienta requiere permisos elevados.",
"suggestion": {
"permissionUpdateMultiple": "Aprobar puede actualizar varios permisos de sesión si elegiste permitir siempre esta herramienta.",
"permissionUpdateSingle": "Aprobar puede actualizar los permisos de tu sesión si elegiste permitir siempre esta herramienta."
},
"toast": {
"denied": "La solicitud de herramienta fue denegada.",
"timeout": "La solicitud de herramienta expiró antes de recibir la aprobación."
},
"waiting": "Esperando la decisión de permiso de la herramienta..."
},
"type": {
"label": "Tipo de Agente",
"unknown": "Tipo desconocido"
@@ -2376,32 +2299,6 @@
"seed_tip": "Controla la aleatoriedad del resultado de la ampliación"
}
},
"plugins": {
"actions": "Operación",
"agents": "Agente",
"all_categories": "Todas las categorías",
"all_types": "todo",
"category": "Categoría",
"commands": "comando",
"confirm_uninstall": "¿Estás seguro de que quieres desinstalar {{name}}?",
"install": "instalación",
"install_plugins_from_browser": "Explora los complementos disponibles para empezar a usar",
"installing": "Instalando...",
"name": "Nombre",
"no_description": "Sin descripción",
"no_installed_plugins": "Aún no se ha instalado ningún complemento",
"no_results": "No se encontró el complemento",
"search_placeholder": "Buscar complemento...",
"showing_results": "Mostrar {{count}} complementos",
"showing_results_one": "Mostrar {{count}} complementos",
"showing_results_other": "Mostrar {{count}} complementos",
"showing_results_plural": "Mostrar {{count}} complementos",
"skills": "habilidad",
"try_different_search": "Por favor, intenta ajustar la búsqueda o los filtros de categoría.",
"type": "tipo",
"uninstall": "Desinstalar",
"uninstalling": "Desinstalando..."
},
"preview": {
"copy": {
"image": "Copiar como imagen"
@@ -2448,14 +2345,12 @@
"gpustack": "GPUStack",
"grok": "Grok",
"groq": "Groq",
"huggingface": "Hugging Face",
"hunyuan": "Tencent Hùnyuán",
"hyperbolic": "Hiperbólico",
"infini": "Infini",
"jina": "Jina",
"lanyun": "Tecnología Lanyun",
"lmstudio": "Estudio LM",
"longcat": "Totoro",
"minimax": "Minimax",
"mistral": "Mistral",
"modelscope": "ModelScope Módulo",
@@ -4148,6 +4043,7 @@
},
"anthropic_api_host": "Dirección API de Anthropic",
"anthropic_api_host_preview": "Vista previa de Anthropic: {{url}}",
"anthropic_api_host_tip": "Rellenar solo si el proveedor ofrece una dirección compatible con Anthropic. Terminar con / ignora el v1 añadido automáticamente, terminar con # fuerza el uso de la dirección original.",
"anthropic_api_host_tooltip": "Rellenar solo cuando el proveedor proporcione una dirección base compatible con Claude.",
"api": {
"key": {
@@ -4192,11 +4088,10 @@
"url": {
"preview": "Vista previa: {{url}}",
"reset": "Restablecer",
"tip": "forzar uso de dirección de entrada con # al final"
"tip": "Ignorar v1 al final con /, forzar uso de dirección de entrada con # al final"
}
},
"api_host": "Dirección API",
"api_host_no_valid": "La dirección de la API no es válida",
"api_host_preview": "Vista previa: {{url}}",
"api_host_tooltip": "Sobrescribir solo cuando el proveedor necesite una dirección compatible con OpenAI personalizada.",
"api_key": {

View File

@@ -107,50 +107,6 @@
"title": "Paramètres avancés"
},
"essential": "Paramètres essentiels",
"plugins": {
"available": {
"title": "Plugins disponibles"
},
"confirm": {
"uninstall": "Êtes-vous sûr de vouloir désinstaller ce plugin ?"
},
"empty": {
"available": "Aucun plugin correspondant trouvé. Veuillez essayer dajuster la recherche ou les filtres de catégorie."
},
"error": {
"install": "Échec de l'installation du plugin",
"load": "Échec du chargement du plugin",
"uninstall": "Échec de la désinstallation du plugin"
},
"filter": {
"all": "Toutes les catégories"
},
"install": "Installation",
"installed": {
"empty": "Aucun plugin n'est encore installé. Parcourez les plugins disponibles pour commencer.",
"title": "Extension installée"
},
"installing": "Installation en cours...",
"results": "{{count}} modules complémentaires trouvés",
"search": {
"placeholder": "Recherche de plug-ins..."
},
"success": {
"install": "Installation du plugin réussie",
"uninstall": "Désinstallation du plugin réussie"
},
"tab": "Module d'extension",
"type": {
"agent": "mandataire",
"agents": "mandataire",
"all": "Tout",
"command": "commande",
"commands": "commande",
"skills": "compétence"
},
"uninstall": "Désinstaller",
"uninstalling": "Désinstallation en cours..."
},
"prompt": "Paramètres de l'invite",
"tooling": {
"mcp": {
@@ -235,39 +191,6 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Autoriser la demande d'outil",
"denyRequest": "Refuser la demande d'outil",
"hideDetails": "Masquer les détails de l'outil",
"runWithOptions": "Exécuter avec des options supplémentaires",
"showDetails": "Afficher les détails de l'outil"
},
"button": {
"cancel": "Annuler",
"run": "Courir"
},
"confirmation": "Êtes-vous sûr de vouloir exécuter cet outil Claude ?",
"defaultDenyMessage": "L'utilisateur a refusé l'autorisation pour cet outil.",
"defaultDescription": "Exécute du code ou des actions système dans votre environnement. Assurez-vous que la commande semble sûre avant de lexécuter.",
"error": {
"sendFailed": "Échec de l'envoi de votre décision. Veuillez réessayer."
},
"expired": "Expiré",
"inputPreview": "Aperçu de l'entrée de l'outil",
"pending": "En attente ({{seconds}}s)",
"permissionExpired": "Demande de permission expirée. En attente de nouvelles instructions...",
"requiresElevatedPermissions": "Cet outil nécessite des autorisations élevées.",
"suggestion": {
"permissionUpdateMultiple": "Approuver peut mettre à jour plusieurs autorisations de session si vous avez choisi de toujours autoriser cet outil.",
"permissionUpdateSingle": "Approuver peut mettre à jour vos permissions de session si vous avez choisi de toujours autoriser cet outil."
},
"toast": {
"denied": "La demande d'outil a été refusée.",
"timeout": "La demande d'outil a expiré avant d'obtenir l'approbation."
},
"waiting": "En attente de la décision d'autorisation de l'outil..."
},
"type": {
"label": "Type d'agent",
"unknown": "Type inconnu"
@@ -2376,32 +2299,6 @@
"seed_tip": "Contrôle la randomisation du résultat d'agrandissement"
}
},
"plugins": {
"actions": "Opération",
"agents": "mandataire",
"all_categories": "Toutes les catégories",
"all_types": "Tout",
"category": "Catégorie",
"commands": "commande",
"confirm_uninstall": "Êtes-vous sûr de vouloir désinstaller {{name}} ?",
"install": "Installation",
"install_plugins_from_browser": "Parcourir les plugins disponibles pour commencer",
"installing": "Installation en cours...",
"name": "Nom",
"no_description": "Sans description",
"no_installed_plugins": "Aucun plugin nest encore installé",
"no_results": "Aucun plugin trouvé",
"search_placeholder": "Rechercher des modules d'extension...",
"showing_results": "Afficher {{count}} extensions",
"showing_results_one": "Afficher {{count}} modules dextension",
"showing_results_other": "Afficher {{count}} modules d'extension",
"showing_results_plural": "Afficher {{count}} modules d'extension",
"skills": "compétence",
"try_different_search": "Veuillez essayer dajuster la recherche ou le filtre de catégorie.",
"type": "type",
"uninstall": "Désinstaller",
"uninstalling": "Désinstallation en cours..."
},
"preview": {
"copy": {
"image": "Copier en tant qu'image"
@@ -2448,14 +2345,12 @@
"gpustack": "GPUStack",
"grok": "Grok",
"groq": "Groq",
"huggingface": "Hugging Face",
"hunyuan": "Tencent HunYuan",
"hyperbolic": "Hyperbolique",
"infini": "Sans Frontières Céleste",
"jina": "Jina",
"lanyun": "Technologie Lan Yun",
"lmstudio": "Studio LM",
"longcat": "Mon voisin Totoro",
"minimax": "MiniMax",
"mistral": "Mistral",
"modelscope": "ModelScope MoDa",
@@ -4148,6 +4043,7 @@
},
"anthropic_api_host": "Adresse API Anthropic",
"anthropic_api_host_preview": "Aperçu Anthropic : {{url}}",
"anthropic_api_host_tip": "Remplir seulement si le fournisseur propose une adresse compatible Anthropic. Se terminant par / ignore le v1 ajouté automatiquement, se terminant par # force l'utilisation de l'adresse originale.",
"anthropic_api_host_tooltip": "Remplir seulement lorsque le fournisseur propose une adresse de base compatible Claude.",
"api": {
"key": {
@@ -4192,11 +4088,10 @@
"url": {
"preview": "Aperçu : {{url}}",
"reset": "Réinitialiser",
"tip": "forcer l'utilisation de l'adresse d'entrée si terminé par #"
"tip": "Ignorer la version v1 si terminé par /, forcer l'utilisation de l'adresse d'entrée si terminé par #"
}
},
"api_host": "Adresse API",
"api_host_no_valid": "Adresse API invalide",
"api_host_preview": "Aperçu : {{url}}",
"api_host_tooltip": "Remplacer seulement lorsque le fournisseur nécessite une adresse compatible OpenAI personnalisée.",
"api_key": {

View File

@@ -107,50 +107,6 @@
"title": "高級設定"
},
"essential": "必須設定",
"plugins": {
"available": {
"title": "利用可能なプラグイン"
},
"confirm": {
"uninstall": "このプラグインをアンインストールしてもよろしいですか?"
},
"empty": {
"available": "一致するプラグインが見つかりませんでした。検索キーワードやカテゴリフィルターを調整してみてください。"
},
"error": {
"install": "プラグインのインストールに失敗しました",
"load": "プラグインの読み込みに失敗しました",
"uninstall": "プラグインのアンインストールに失敗しました"
},
"filter": {
"all": "すべてのカテゴリー"
},
"install": "インストール",
"installed": {
"empty": "まだプラグインがインストールされていません。利用可能なプラグインを見てみましょう。",
"title": "インストール済みプラグイン"
},
"installing": "インストール中...",
"results": "{{count}} 個のプラグインが見つかりました",
"search": {
"placeholder": "検索プラグイン..."
},
"success": {
"install": "プラグインのインストールが成功しました",
"uninstall": "プラグインのアンインストールが成功しました"
},
"tab": "プラグイン",
"type": {
"agent": "代理",
"agents": "代理",
"all": "全部",
"command": "命令",
"commands": "命令",
"skills": "技能"
},
"uninstall": "アンインストール",
"uninstalling": "アンインストール中..."
},
"prompt": "プロンプト設定",
"tooling": {
"mcp": {
@@ -235,39 +191,6 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "ツールリクエストを許可",
"denyRequest": "ツールリクエストを拒否",
"hideDetails": "ツールの詳細を非表示",
"runWithOptions": "追加オプションで実行",
"showDetails": "ツールの詳細を表示"
},
"button": {
"cancel": "キャンセル",
"run": "走る"
},
"confirmation": "このClaudeツールを実行してもよろしいですか",
"defaultDenyMessage": "ユーザーはこのツールの使用を拒否しました。",
"defaultDescription": "環境内でコードまたはシステムアクションを実行します。実行前にコマンドが安全であることを確認してください。",
"error": {
"sendFailed": "決定の送信に失敗しました。もう一度お試しください。"
},
"expired": "期限切れ",
"inputPreview": "ツール入力プレビュー",
"pending": "保留中({{seconds}}秒)",
"permissionExpired": "許可リクエストの期限が切れました。新しい指示を待っています...",
"requiresElevatedPermissions": "このツールは昇格した権限が必要です。",
"suggestion": {
"permissionUpdateMultiple": "承認すると、このツールを常に許可することを選択した場合、複数のセッション権限が更新されることがあります。",
"permissionUpdateSingle": "承認すると、このツールを常に許可することを選択した場合、セッションの権限が更新されることがあります。"
},
"toast": {
"denied": "ツールリクエストは拒否されました。",
"timeout": "ツールリクエストは承認を受ける前にタイムアウトしました。"
},
"waiting": "ツールの許可決定を待っています..."
},
"type": {
"label": "エージェントタイプ",
"unknown": "不明なタイプ"
@@ -2376,32 +2299,6 @@
"seed_tip": "拡大結果のランダム性を制御します"
}
},
"plugins": {
"actions": "操作",
"agents": "代理",
"all_categories": "すべてのカテゴリー",
"all_types": "全部",
"category": "カテゴリー",
"commands": "命令",
"confirm_uninstall": "{{name}}をアンインストールしてもよろしいですか?",
"install": "インストール",
"install_plugins_from_browser": "利用可能なプラグインを閲覧して、使用を開始してください",
"installing": "インストール中...",
"name": "名称",
"no_description": "説明なし",
"no_installed_plugins": "まだプラグインがインストールされていません",
"no_results": "プラグインが見つかりません",
"search_placeholder": "検索プラグイン...",
"showing_results": "{{count}} 個のプラグインを表示",
"showing_results_one": "{{count}} 個のプラグインを表示",
"showing_results_other": "{{count}} 個のプラグインを表示",
"showing_results_plural": "{{count}} 個のプラグインを表示",
"skills": "スキル",
"try_different_search": "検索またはカテゴリフィルターを調整してみてください",
"type": "タイプ",
"uninstall": "アンインストール",
"uninstalling": "アンインストール中..."
},
"preview": {
"copy": {
"image": "画像としてコピー"
@@ -2448,14 +2345,12 @@
"gpustack": "GPUStack",
"grok": "Grok",
"groq": "Groq",
"huggingface": "ハギングフェイス",
"hunyuan": "腾讯混元",
"hyperbolic": "Hyperbolic",
"infini": "Infini",
"jina": "Jina",
"lanyun": "LANYUN",
"lmstudio": "LM Studio",
"longcat": "トトロ",
"minimax": "MiniMax",
"mistral": "Mistral",
"modelscope": "ModelScope",
@@ -4148,6 +4043,7 @@
},
"anthropic_api_host": "Anthropic APIアドレス",
"anthropic_api_host_preview": "Anthropic プレビュー:{{url}}",
"anthropic_api_host_tip": "サービスプロバイダーがAnthropic互換のアドレスを提供する場合のみ入力してください。/で終わる場合は自動追加されるv1を無視し、#で終わる場合は元のアドレスを強制的に使用します。",
"anthropic_api_host_tooltip": "サービスプロバイダーがClaude互換のベースアドレスを提供する場合のみ入力してください。",
"api": {
"key": {
@@ -4192,11 +4088,10 @@
"url": {
"preview": "プレビュー: {{url}}",
"reset": "リセット",
"tip": "#で終わる場合、入力されたアドレスを強制的に使用します"
"tip": "/で終わる場合、v1を無視します。#で終わる場合、入力されたアドレスを強制的に使用します"
}
},
"api_host": "APIホスト",
"api_host_no_valid": "APIアドレスが無効です",
"api_host_preview": "プレビュー:{{url}}",
"api_host_tooltip": "サービスプロバイダーがカスタムOpenAI互換アドレスを必要とする場合のみ上書きしてください。",
"api_key": {

View File

@@ -107,50 +107,6 @@
"title": "Configurações avançadas"
},
"essential": "Configurações Essenciais",
"plugins": {
"available": {
"title": "Plugins disponíveis"
},
"confirm": {
"uninstall": "Tem certeza de que deseja desinstalar este plugin?"
},
"empty": {
"available": "Nenhum plugin correspondente encontrado. Tente ajustar a pesquisa ou os filtros de categoria."
},
"error": {
"install": "Falha na instalação do plugin",
"load": "Falha ao carregar o plugin",
"uninstall": "Falha ao desinstalar o plug-in"
},
"filter": {
"all": "Todas as categorias"
},
"install": "Instalação",
"installed": {
"empty": "Nenhum plugin foi instalado ainda. Explore os plugins disponíveis para começar.",
"title": "Plugin instalado"
},
"installing": "Instalando...",
"results": "Encontrados {{count}} plugins",
"search": {
"placeholder": "Pesquisar extensão..."
},
"success": {
"install": "Plugin instalado com sucesso",
"uninstall": "插件 desinstalado com sucesso"
},
"tab": "plug-in",
"type": {
"agent": "agente",
"agents": "agente",
"all": "tudo",
"command": "comando",
"commands": "comando",
"skills": "habilidade"
},
"uninstall": "desinstalar",
"uninstalling": "Desinstalando..."
},
"prompt": "Configurações de Prompt",
"tooling": {
"mcp": {
@@ -235,39 +191,6 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Permitir solicitação de ferramenta",
"denyRequest": "Negar solicitação de ferramenta",
"hideDetails": "Ocultar detalhes da ferramenta",
"runWithOptions": "Executar com opções adicionais",
"showDetails": "Mostrar detalhes da ferramenta"
},
"button": {
"cancel": "Cancelar",
"run": "Correr"
},
"confirmation": "Tem certeza de que quer executar esta ferramenta Claude?",
"defaultDenyMessage": "Usuário negou permissão para esta ferramenta.",
"defaultDescription": "Executa código ou ações do sistema no seu ambiente. Certifique-se de que o comando parece seguro antes de executá-lo.",
"error": {
"sendFailed": "Falha ao enviar sua decisão. Por favor, tente novamente."
},
"expired": "Expirado",
"inputPreview": "Pré-visualização da entrada da ferramenta",
"pending": "Pendente ({{seconds}}s)",
"permissionExpired": "Solicitação de permissão expirou. Aguardando novas instruções...",
"requiresElevatedPermissions": "Esta ferramenta requer permissões elevadas.",
"suggestion": {
"permissionUpdateMultiple": "Aprovar pode atualizar várias permissões de sessão se você escolheu sempre permitir esta ferramenta.",
"permissionUpdateSingle": "Aprovar pode atualizar as permissões da sua sessão se você escolheu sempre permitir esta ferramenta."
},
"toast": {
"denied": "Solicitação de ferramenta foi negada.",
"timeout": "A solicitação da ferramenta expirou antes de receber aprovação."
},
"waiting": "Aguardando decisão de permissão da ferramenta..."
},
"type": {
"label": "Tipo de Agente",
"unknown": "Tipo Desconhecido"
@@ -2376,32 +2299,6 @@
"seed_tip": "Controla a aleatoriedade do resultado de ampliação"
}
},
"plugins": {
"actions": "Operação",
"agents": "agente",
"all_categories": "Todas as categorias",
"all_types": "Tudo",
"category": "categoria",
"commands": "comando",
"confirm_uninstall": "Tem certeza de que deseja desinstalar {{name}}?",
"install": "Instalação",
"install_plugins_from_browser": "Navegue pelos plugins disponíveis para começar a usar",
"installing": "Instalando...",
"name": "Nome",
"no_description": "Sem descrição",
"no_installed_plugins": "Nenhum plugin foi instalado ainda",
"no_results": "Plugin não encontrado",
"search_placeholder": "Pesquisar plugin...",
"showing_results": "Exibir {{count}} extensões",
"showing_results_one": "Mostrar {{count}} extensões",
"showing_results_other": "Exibir {{count}} extensões",
"showing_results_plural": "Exibir {{count}} extensões",
"skills": "habilidade",
"try_different_search": "Por favor, tente ajustar a pesquisa ou os filtros de categoria.",
"type": "tipo",
"uninstall": "Desinstalar",
"uninstalling": "Desinstalando..."
},
"preview": {
"copy": {
"image": "Copiar como imagem"
@@ -2448,14 +2345,12 @@
"gpustack": "GPUStack",
"grok": "Compreender",
"groq": "Groq",
"huggingface": "Hugging Face",
"hunyuan": "Tencent Hún Yuán",
"hyperbolic": "Hiperbólico",
"infini": "Infinito",
"jina": "Jina",
"lanyun": "Lanyun Tecnologia",
"lmstudio": "Estúdio LM",
"longcat": "Totoro",
"minimax": "Minimax",
"mistral": "Mistral",
"modelscope": "ModelScope MôDá",
@@ -4148,6 +4043,7 @@
},
"anthropic_api_host": "Endereço da API Anthropic",
"anthropic_api_host_preview": "Pré-visualização Anthropic: {{url}}",
"anthropic_api_host_tip": "Preencher apenas se o fornecedor oferecer um endereço compatível com Anthropic. Terminar com / ignora o v1 adicionado automaticamente, terminar com # força o uso do endereço original.",
"anthropic_api_host_tooltip": "Preencher apenas quando o fornecedor fornece um endereço base compatível com Claude.",
"api": {
"key": {
@@ -4192,11 +4088,10 @@
"url": {
"preview": "Pré-visualização: {{url}}",
"reset": "Redefinir",
"tip": "e forçar o uso do endereço original quando terminar com '#'"
"tip": "Ignorar v1 na versão finalizada com /, usar endereço de entrada forçado se terminar com #"
}
},
"api_host": "Endereço API",
"api_host_no_valid": "O endereço da API é inválido",
"api_host_preview": "Pré-visualização: {{url}}",
"api_host_tooltip": "Substituir apenas quando o fornecedor necessita de um endereço compatível com OpenAI personalizado.",
"api_key": {

View File

@@ -107,50 +107,6 @@
"title": "Расширенные настройки"
},
"essential": "Основные настройки",
"plugins": {
"available": {
"title": "Доступные плагины"
},
"confirm": {
"uninstall": "Вы уверены, что хотите удалить этот плагин?"
},
"empty": {
"available": "Совпадающие плагины не найдены. Попробуйте изменить поиск или фильтр категорий."
},
"error": {
"install": "Ошибка установки плагина",
"load": "Ошибка загрузки плагина",
"uninstall": "Не удалось удалить плагин"
},
"filter": {
"all": "Все категории"
},
"install": "установка",
"installed": {
"empty": "Плагины ещё не установлены. Просмотрите доступные плагины, чтобы начать.",
"title": "Установленный плагин"
},
"installing": "Установка...",
"results": "Найдено {{count}} плагинов",
"search": {
"placeholder": "Поиск плагинов..."
},
"success": {
"install": "Плагин успешно установлен",
"uninstall": "Плагин успешно удалён"
},
"tab": "плагин",
"type": {
"agent": "агент",
"agents": "Прокси",
"all": "всё",
"command": "команда",
"commands": "команда",
"skills": "навык"
},
"uninstall": "Удаление",
"uninstalling": "Удаление..."
},
"prompt": "Настройки подсказки",
"tooling": {
"mcp": {
@@ -235,39 +191,6 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Разрешить запрос инструмента",
"denyRequest": "Отклонить запрос на инструмент",
"hideDetails": "Скрыть сведения об инструменте",
"runWithOptions": "Запустить с дополнительными параметрами",
"showDetails": "Показать сведения об инструменте"
},
"button": {
"cancel": "Отмена",
"run": "Беги"
},
"confirmation": "Вы уверены, что хотите запустить этот инструмент Claude?",
"defaultDenyMessage": "Пользователь отказал в разрешении на использование этого инструмента.",
"defaultDescription": "Выполняет код или системные действия в вашей среде. Убедитесь, что команда выглядит безопасно, прежде чем запускать её.",
"error": {
"sendFailed": "Не удалось отправить ваше решение. Попробуйте ещё раз."
},
"expired": "Истёк",
"inputPreview": "Предварительный просмотр ввода инструмента",
"pending": "Ожидание ({{seconds}}с)",
"permissionExpired": "Срок действия запроса на разрешение истёк. Ожидание новых инструкций...",
"requiresElevatedPermissions": "Этому инструменту требуются повышенные разрешения.",
"suggestion": {
"permissionUpdateMultiple": "Одобрение может обновить разрешения для нескольких сеансов, если вы выбрали всегда разрешать использование этого инструмента.",
"permissionUpdateSingle": "Одобрение может обновить разрешения вашей сессии, если вы выбрали всегда разрешать использование этого инструмента."
},
"toast": {
"denied": "Запрос на инструмент был отклонён.",
"timeout": "Запрос на инструмент превысил время ожидания до получения подтверждения."
},
"waiting": "Ожидание решения о разрешении на использование инструмента..."
},
"type": {
"label": "Тип агента",
"unknown": "Неизвестный тип"
@@ -2376,32 +2299,6 @@
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов"
}
},
"plugins": {
"actions": "Операция",
"agents": "агент",
"all_categories": "Все категории",
"all_types": "всё",
"category": "категория",
"commands": "команда",
"confirm_uninstall": "Вы уверены, что хотите удалить {{name}}?",
"install": "установка",
"install_plugins_from_browser": "Просмотрите доступные плагины, чтобы начать работу",
"installing": "Установка...",
"name": "название",
"no_description": "Без описания",
"no_installed_plugins": "Плагины ещё не установлены",
"no_results": "Плагин не найден",
"search_placeholder": "Поиск плагинов...",
"showing_results": "Отображено {{count}} плагинов",
"showing_results_one": "Отображено {{count}} плагинов",
"showing_results_other": "Отображено {{count}} плагинов",
"showing_results_plural": "Отображение {{count}} плагинов",
"skills": "навык",
"try_different_search": "Пожалуйста, попробуйте изменить поиск или фильтры категорий",
"type": "тип",
"uninstall": "Удаление",
"uninstalling": "Удаление..."
},
"preview": {
"copy": {
"image": "Скопировать как изображение"
@@ -2448,14 +2345,12 @@
"gpustack": "GPUStack",
"grok": "Grok",
"groq": "Groq",
"huggingface": "Hugging Face",
"hunyuan": "Tencent Hunyuan",
"hyperbolic": "Hyperbolic",
"infini": "Infini",
"jina": "Jina",
"lanyun": "LANYUN",
"lmstudio": "LM Studio",
"longcat": "Тоторо",
"minimax": "MiniMax",
"mistral": "Mistral",
"modelscope": "ModelScope",
@@ -4148,6 +4043,7 @@
},
"anthropic_api_host": "Адрес API Anthropic",
"anthropic_api_host_preview": "Предпросмотр Anthropic: {{url}}",
"anthropic_api_host_tip": "Заполняйте только если провайдер предоставляет совместимый с Anthropic адрес. Окончание на / игнорирует автоматически добавляемое v1, окончание на # принудительно использует оригинальный адрес.",
"anthropic_api_host_tooltip": "Заполняйте только когда провайдер предоставляет базовый адрес, совместимый с Claude.",
"api": {
"key": {
@@ -4192,11 +4088,10 @@
"url": {
"preview": "Предпросмотр: {{url}}",
"reset": "Сброс",
"tip": "заканчивая на # принудительно использует введенный адрес"
"tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес"
}
},
"api_host": "Хост API",
"api_host_no_valid": "Недопустимый адрес API",
"api_host_preview": "Предпросмотр: {{url}}",
"api_host_tooltip": "Переопределяйте только когда провайдер требует пользовательский адрес, совместимый с OpenAI.",
"api_key": {

View File

@@ -72,7 +72,7 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
},
dashscope: {
anthropic: {
api_base_url: 'https://dashscope.aliyuncs.com/apps/anthropic'
api_base_url: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy'
}
},
modelscope: {

View File

@@ -5,7 +5,6 @@ import { HStack } from '@renderer/components/Layout'
import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useRuntime } from '@renderer/hooks/useRuntime'
@@ -53,8 +52,6 @@ const Chat: FC<Props> = (props) => {
const { activeTopicOrSession, activeAgentId, activeSessionIdMap } = chat
const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null
const { apiServer } = useSettings()
const sessionAgentId = activeTopicOrSession === 'session' ? activeAgentId : null
const { createDefaultSession } = useCreateDefaultSession(sessionAgentId)
const mainRef = React.useRef<HTMLDivElement>(null)
const contentSearchRef = React.useRef<ContentSearchRef>(null)
@@ -93,21 +90,6 @@ const Chat: FC<Props> = (props) => {
}
})
useShortcut(
'new_topic',
() => {
if (activeTopicOrSession !== 'session' || !activeAgentId) {
return
}
void createDefaultSession()
},
{
enabled: activeTopicOrSession === 'session',
preventDefault: true,
enableOnFormTags: true
}
)
const contentSearchFilter: NodeFilter = {
acceptNode(node) {
const container = node.parentElement?.closest('.message-content-container')

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