diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml index d4bf43dc26..541f174d33 100644 --- a/.github/workflows/auto-i18n.yml +++ b/.github/workflows/auto-i18n.yml @@ -90,3 +90,30 @@ jobs: - name: 📢 Notify if no changes if: steps.git_status.outputs.has_changes != 'true' run: echo "Bot script ran, but no changes were detected. No PR created." + + - name: Send failure notification to Feishu + if: always() && (failure() || cancelled()) + shell: bash + env: + FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} + FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + JOB_STATUS: ${{ job.status }} + run: | + # Determine status and color + if [ "$JOB_STATUS" = "cancelled" ]; then + STATUS_TEXT="已取消" + COLOR="orange" + else + STATUS_TEXT="失败" + COLOR="red" + fi + + # Build description using printf + DESCRIPTION=$(printf "**状态:** %s\n\n**工作流:** [查看详情](%s)" "$STATUS_TEXT" "$RUN_URL") + + # Send notification + pnpm tsx scripts/feishu-notify.ts send \ + -t "自动国际化${STATUS_TEXT}" \ + -d "$DESCRIPTION" \ + -c "${COLOR}" diff --git a/.github/workflows/github-issue-tracker.yml b/.github/workflows/github-issue-tracker.yml index a628f9f13c..5a2455a232 100644 --- a/.github/workflows/github-issue-tracker.yml +++ b/.github/workflows/github-issue-tracker.yml @@ -58,14 +58,34 @@ jobs: with: node-version: 22 + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT + + - name: Cache pnpm dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Install dependencies + if: steps.check_time.outputs.should_delay == 'false' + run: pnpm install + - name: Process issue with Claude if: steps.check_time.outputs.should_delay == 'false' - uses: anthropics/claude-code-action@main + uses: anthropics/claude-code-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} allowed_non_write_users: "*" anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }} - claude_args: "--allowed-tools Bash(gh issue:*),Bash(node scripts/feishu-notify.js)" + claude_args: "--allowed-tools Bash(gh issue:*),Bash(pnpm tsx scripts/feishu-notify.ts*)" prompt: | 你是一个GitHub Issue自动化处理助手。请完成以下任务: @@ -74,9 +94,14 @@ jobs: - 标题:${{ github.event.issue.title }} - 作者:${{ github.event.issue.user.login }} - URL:${{ github.event.issue.html_url }} - - 内容:${{ github.event.issue.body }} - 标签:${{ join(github.event.issue.labels.*.name, ', ') }} + ### Issue body + + `````md + ${{ github.event.issue.body }} + ````` + ## 任务步骤 1. **分析并总结issue** @@ -86,20 +111,20 @@ jobs: - 重要的技术细节 2. **发送飞书通知** - 使用以下命令发送飞书通知(注意:ISSUE_SUMMARY需要用引号包裹): + 使用CLI工具发送飞书通知,参考以下示例: ```bash - ISSUE_URL="${{ github.event.issue.html_url }}" \ - ISSUE_NUMBER="${{ github.event.issue.number }}" \ - ISSUE_TITLE="${{ github.event.issue.title }}" \ - ISSUE_AUTHOR="${{ github.event.issue.user.login }}" \ - ISSUE_LABELS="${{ join(github.event.issue.labels.*.name, ',') }}" \ - ISSUE_SUMMARY="<你生成的中文总结>" \ - node scripts/feishu-notify.js + pnpm tsx scripts/feishu-notify.ts issue \ + -u "${{ github.event.issue.html_url }}" \ + -n "${{ github.event.issue.number }}" \ + -t "${{ github.event.issue.title }}" \ + -a "${{ github.event.issue.user.login }}" \ + -l "${{ join(github.event.issue.labels.*.name, ',') }}" \ + -m "<你生成的中文总结>" ``` ## 注意事项 - 总结必须使用简体中文 - - ISSUE_SUMMARY 在传递给 node 命令时需要正确转义特殊字符 + - 命令行参数需要正确转义特殊字符 - 如果issue内容为空,也要提供一个简短的说明 请开始执行任务! @@ -125,13 +150,32 @@ jobs: with: node-version: 22 + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT + + - name: Cache pnpm dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install + - name: Process pending issues with Claude - uses: anthropics/claude-code-action@main + uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }} allowed_non_write_users: "*" github_token: ${{ secrets.GITHUB_TOKEN }} - claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:*),Bash(node scripts/feishu-notify.js)" + claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:*),Bash(pnpm tsx scripts/feishu-notify.ts*)" prompt: | 你是一个GitHub Issue自动化处理助手。请完成以下任务: @@ -153,15 +197,15 @@ jobs: - 重要的技术细节 3. **发送飞书通知** - 对于每个issue,使用以下命令发送飞书通知: + 使用CLI工具发送飞书通知,参考以下示例: ```bash - ISSUE_URL="" \ - ISSUE_NUMBER="" \ - ISSUE_TITLE="" \ - ISSUE_AUTHOR="" \ - ISSUE_LABELS="<逗号分隔的标签列表,排除pending-feishu-notification>" \ - ISSUE_SUMMARY="<你生成的中文总结>" \ - node scripts/feishu-notify.js + pnpm tsx scripts/feishu-notify.ts issue \ + -u "" \ + -n "" \ + -t "" \ + -a "" \ + -l "<逗号分隔的标签列表,排除pending-feishu-notification>" \ + -m "<你生成的中文总结>" ``` 4. **移除标签** diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index a3ed2f85d5..1a056dce00 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -59,5 +59,8 @@ jobs: - name: i18n Check run: pnpm i18n:check + - name: Hardcoded Strings Check + run: pnpm i18n:hardcoded:strict + - name: Test run: pnpm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c35ccb5b66..b22effb11c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,16 @@ on: description: "Release tag (e.g. v1.0.0)" required: true default: "v1.0.0" + platform: + description: "Build platform" + required: true + default: "all" + type: choice + options: + - all + - windows + - mac + - linux push: tags: - v*.*.* @@ -20,7 +30,14 @@ jobs: strategy: matrix: - os: [macos-latest, windows-latest, ubuntu-latest] + os: ${{ fromJSON( + github.event_name == 'push' && '["macos-latest", "windows-latest", "ubuntu-latest"]' || + github.event.inputs.platform == 'all' && '["macos-latest", "windows-latest", "ubuntu-latest"]' || + github.event.inputs.platform == 'linux' && '["ubuntu-latest"]' || + github.event.inputs.platform == 'windows' && '["windows-latest"]' || + github.event.inputs.platform == 'mac' && '["macos-latest"]' || + '["macos-latest", "windows-latest", "ubuntu-latest"]' + ) }} fail-fast: false steps: diff --git a/.github/workflows/sync-to-gitcode.yml b/.github/workflows/sync-to-gitcode.yml index 42952fbbfb..bfc544d405 100644 --- a/.github/workflows/sync-to-gitcode.yml +++ b/.github/workflows/sync-to-gitcode.yml @@ -79,7 +79,7 @@ jobs: shell: bash run: | echo "Built Windows artifacts:" - ls -la dist/*.exe dist/*.blockmap dist/latest*.yml + ls -la dist/*.exe dist/latest*.yml - name: Download GitHub release assets shell: bash @@ -112,12 +112,10 @@ jobs: fi # Remove unsigned Windows files from downloaded assets - # *.exe, *.exe.blockmap, latest.yml (Windows only) - rm -f release-assets/*.exe release-assets/*.exe.blockmap release-assets/latest.yml 2>/dev/null || true + rm -f release-assets/*.exe release-assets/latest.yml 2>/dev/null || true # Copy signed Windows files with error checking cp dist/*.exe release-assets/ || { echo "ERROR: Failed to copy .exe files"; exit 1; } - cp dist/*.exe.blockmap release-assets/ || { echo "ERROR: Failed to copy .blockmap files"; exit 1; } cp dist/latest.yml release-assets/ || { echo "ERROR: Failed to copy latest.yml"; exit 1; } echo "Final release assets:" @@ -302,3 +300,31 @@ jobs: run: | rm -f /tmp/release_payload.json /tmp/upload_headers.txt release_body.txt rm -rf release-assets/ + + - name: Send failure notification to Feishu + if: always() && (failure() || cancelled()) + shell: bash + env: + FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} + FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }} + TAG_NAME: ${{ steps.get-tag.outputs.tag }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + JOB_STATUS: ${{ job.status }} + run: | + # Determine status and color + if [ "$JOB_STATUS" = "cancelled" ]; then + STATUS_TEXT="已取消" + COLOR="orange" + else + STATUS_TEXT="失败" + COLOR="red" + fi + + # Build description using printf + DESCRIPTION=$(printf "**标签:** %s\n\n**状态:** %s\n\n**工作流:** [查看详情](%s)" "$TAG_NAME" "$STATUS_TEXT" "$RUN_URL") + + # Send notification + pnpm tsx scripts/feishu-notify.ts send \ + -t "GitCode 同步${STATUS_TEXT}" \ + -d "$DESCRIPTION" \ + -c "${COLOR}" diff --git a/.github/workflows/update-app-upgrade-config.yml b/.github/workflows/update-app-upgrade-config.yml index 088fa4260a..49f3e64bb4 100644 --- a/.github/workflows/update-app-upgrade-config.yml +++ b/.github/workflows/update-app-upgrade-config.yml @@ -154,9 +154,10 @@ jobs: with: node-version: 22 - - name: Install pnpm + - name: Enable corepack if: steps.check.outputs.should_run == 'true' - uses: pnpm/action-setup@v4 + working-directory: main + run: corepack enable pnpm - name: Install dependencies if: steps.check.outputs.should_run == 'true' diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index cb2c84d5c3..0000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -pnpm lint-staged diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..222e61bb54 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: + - repo: local + hooks: + - id: biome-format-js + name: Biome format (JS/TS) + language: system + entry: pnpm biome format --write --no-errors-on-unmatched + files: '\.(js|jsx|ts|tsx|cjs|mjs|cts|mts)$' + pass_filenames: true + + - id: eslint-fix + name: ESLint fix + language: system + entry: pnpm eslint --fix + files: '\.(js|jsx|ts|tsx|cjs|mjs|cts|mts)$' + pass_filenames: true + + - id: biome-format-other + name: Biome format (JSON/YAML/CSS/HTML) + language: system + entry: pnpm biome format --write --no-errors-on-unmatched + files: '\.(json|yml|yaml|css|html)$' + pass_filenames: true \ No newline at end of file diff --git a/docs/en/references/feishu-notify.md b/docs/en/references/feishu-notify.md new file mode 100644 index 0000000000..26249a0c74 --- /dev/null +++ b/docs/en/references/feishu-notify.md @@ -0,0 +1,155 @@ +# Feishu Notification Script + +`scripts/feishu-notify.ts` is a CLI tool for sending notifications to Feishu (Lark) Webhook. This script is primarily used in GitHub Actions workflows to enable automatic notifications. + +## Features + +- Subcommand-based CLI structure for different notification types +- HMAC-SHA256 signature verification +- Sends Feishu interactive card messages +- Full TypeScript type support +- Credentials via environment variables for security + +## Usage + +### Prerequisites + +```bash +pnpm install +``` + +### CLI Structure + +```bash +pnpm tsx scripts/feishu-notify.ts [command] [options] +``` + +### Environment Variables (Required) + +| Variable | Description | +|----------|-------------| +| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | +| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret | + +## Commands + +### `send` - Send Simple Notification + +Send a generic notification without business-specific logic. + +```bash +pnpm tsx scripts/feishu-notify.ts send [options] +``` + +| Option | Short | Description | Required | +|--------|-------|-------------|----------| +| `--title` | `-t` | Card title | Yes | +| `--description` | `-d` | Card description (supports markdown) | Yes | +| `--color` | `-c` | Header color template | No (default: turquoise) | + +**Available colors:** `blue`, `wathet`, `turquoise`, `green`, `yellow`, `orange`, `red`, `carmine`, `violet`, `purple`, `indigo`, `grey`, `default` + +#### Example + +```bash +# Use $'...' syntax for proper newlines +pnpm tsx scripts/feishu-notify.ts send \ + -t "Deployment Completed" \ + -d $'**Status:** Success\n\n**Environment:** Production\n\n**Version:** v1.2.3' \ + -c green +``` + +```bash +# Send an error alert (red color) +pnpm tsx scripts/feishu-notify.ts send \ + -t "Error Alert" \ + -d $'**Error Type:** Connection failed\n\n**Severity:** High\n\nPlease check the system status' \ + -c red +``` + +**Note:** For proper newlines in the description, use bash's `$'...'` syntax. Do not use literal `\n` in double quotes, as it will be displayed as-is in the Feishu card. + +### `issue` - Send GitHub Issue Notification + +```bash +pnpm tsx scripts/feishu-notify.ts issue [options] +``` + +| Option | Short | Description | Required | +|--------|-------|-------------|----------| +| `--url` | `-u` | GitHub issue URL | Yes | +| `--number` | `-n` | Issue number | Yes | +| `--title` | `-t` | Issue title | Yes | +| `--summary` | `-m` | Issue summary | Yes | +| `--author` | `-a` | Issue author | No (default: "Unknown") | +| `--labels` | `-l` | Issue labels (comma-separated) | No | + +#### Example + +```bash +pnpm tsx scripts/feishu-notify.ts issue \ + -u "https://github.com/owner/repo/issues/123" \ + -n "123" \ + -t "Bug: Something is broken" \ + -m "This is a bug report about a feature" \ + -a "username" \ + -l "bug,high-priority" +``` + +## Usage in GitHub Actions + +This script is primarily used in `.github/workflows/github-issue-tracker.yml`: + +```yaml +- name: Install dependencies + run: pnpm install + +- name: Send notification + run: | + pnpm tsx scripts/feishu-notify.ts issue \ + -u "${{ github.event.issue.html_url }}" \ + -n "${{ github.event.issue.number }}" \ + -t "${{ github.event.issue.title }}" \ + -a "${{ github.event.issue.user.login }}" \ + -l "${{ join(github.event.issue.labels.*.name, ',') }}" \ + -m "Issue summary content" + env: + FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} + FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }} +``` + +## Feishu Card Message Format + +The `issue` command sends an interactive card containing: + +- **Header**: `# - ` +- **Author**: Issue creator +- **Labels**: Issue labels (if any) +- **Summary**: Issue content summary +- **Action Button**: "View Issue" button linking to the GitHub Issue page + +## Configuring Feishu Webhook + +1. Add a custom bot to your Feishu group +2. Obtain the Webhook URL and signing secret +3. Configure them in GitHub Secrets: + - `FEISHU_WEBHOOK_URL`: Webhook address + - `FEISHU_WEBHOOK_SECRET`: Signing secret + +## Error Handling + +The script exits with a non-zero code when: + +- Required environment variables are missing (`FEISHU_WEBHOOK_URL`, `FEISHU_WEBHOOK_SECRET`) +- Required command options are missing +- Feishu API returns a non-2xx status code +- Network request fails + +## Extending with New Commands + +The CLI is designed to support multiple notification types. To add a new command: + +1. Define the command options interface +2. Create a card builder function +3. Add a new command handler +4. Register the command with `program.command()` diff --git a/docs/zh/references/feishu-notify.md b/docs/zh/references/feishu-notify.md new file mode 100644 index 0000000000..412b12815a --- /dev/null +++ b/docs/zh/references/feishu-notify.md @@ -0,0 +1,155 @@ +# 飞书通知脚本 + +`scripts/feishu-notify.ts` 是一个 CLI 工具,用于向飞书 Webhook 发送通知。该脚本主要在 GitHub Actions 工作流中使用,实现自动通知功能。 + +## 功能特性 + +- 基于子命令的 CLI 结构,支持不同类型的通知 +- 使用 HMAC-SHA256 签名验证 +- 发送飞书交互式卡片消息 +- 完整的 TypeScript 类型支持 +- 通过环境变量传递凭证,确保安全性 + +## 使用方式 + +### 前置依赖 + +```bash +pnpm install +``` + +### CLI 结构 + +```bash +pnpm tsx scripts/feishu-notify.ts [command] [options] +``` + +### 环境变量(必需) + +| 变量 | 说明 | +|------|------| +| `FEISHU_WEBHOOK_URL` | 飞书 Webhook URL | +| `FEISHU_WEBHOOK_SECRET` | 飞书 Webhook 签名密钥 | + +## 命令 + +### `send` - 发送简单通知 + +发送通用通知,不涉及具体业务逻辑。 + +```bash +pnpm tsx scripts/feishu-notify.ts send [options] +``` + +| 参数 | 短选项 | 说明 | 必需 | +|------|--------|------|------| +| `--title` | `-t` | 卡片标题 | 是 | +| `--description` | `-d` | 卡片描述(支持 markdown) | 是 | +| `--color` | `-c` | 标题栏颜色模板 | 否(默认:turquoise) | + +**可用颜色:** `blue`(蓝色), `wathet`(浅蓝), `turquoise`(青绿), `green`(绿色), `yellow`(黄色), `orange`(橙色), `red`(红色), `carmine`(深红), `violet`(紫罗兰), `purple`(紫色), `indigo`(靛蓝), `grey`(灰色), `default`(默认) + +#### 示例 + +```bash +# 使用 $'...' 语法实现正确的换行 +pnpm tsx scripts/feishu-notify.ts send \ + -t "部署完成" \ + -d $'**状态:** 成功\n\n**环境:** 生产环境\n\n**版本:** v1.2.3' \ + -c green +``` + +```bash +# 发送错误警报(红色) +pnpm tsx scripts/feishu-notify.ts send \ + -t "错误警报" \ + -d $'**错误类型:** 连接失败\n\n**严重程度:** 高\n\n请及时检查系统状态' \ + -c red +``` + +**注意:** 如需在描述中换行,请使用 bash 的 `$'...'` 语法。不要在双引号中使用字面量 `\n`,否则会原样显示在飞书卡片中。 + +### `issue` - 发送 GitHub Issue 通知 + +```bash +pnpm tsx scripts/feishu-notify.ts issue [options] +``` + +| 参数 | 短选项 | 说明 | 必需 | +|------|--------|------|------| +| `--url` | `-u` | GitHub Issue URL | 是 | +| `--number` | `-n` | Issue 编号 | 是 | +| `--title` | `-t` | Issue 标题 | 是 | +| `--summary` | `-m` | Issue 摘要 | 是 | +| `--author` | `-a` | Issue 作者 | 否(默认:"Unknown") | +| `--labels` | `-l` | Issue 标签(逗号分隔) | 否 | + +#### 示例 + +```bash +pnpm tsx scripts/feishu-notify.ts issue \ + -u "https://github.com/owner/repo/issues/123" \ + -n "123" \ + -t "Bug: Something is broken" \ + -m "这是一个关于某功能的 bug 报告" \ + -a "username" \ + -l "bug,high-priority" +``` + +## 在 GitHub Actions 中使用 + +该脚本主要在 `.github/workflows/github-issue-tracker.yml` 工作流中使用: + +```yaml +- name: Install dependencies + run: pnpm install + +- name: Send notification + run: | + pnpm tsx scripts/feishu-notify.ts issue \ + -u "${{ github.event.issue.html_url }}" \ + -n "${{ github.event.issue.number }}" \ + -t "${{ github.event.issue.title }}" \ + -a "${{ github.event.issue.user.login }}" \ + -l "${{ join(github.event.issue.labels.*.name, ',') }}" \ + -m "Issue 摘要内容" + env: + FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} + FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }} +``` + +## 飞书卡片消息格式 + +`issue` 命令发送的交互式卡片包含以下内容: + +- **标题**: `# - ` +- **作者**: Issue 创建者 +- **标签**: Issue 标签列表(如有) +- **摘要**: Issue 内容摘要 +- **操作按钮**: "View Issue" 按钮,点击跳转到 GitHub Issue 页面 + +## 配置飞书 Webhook + +1. 在飞书群组中添加自定义机器人 +2. 获取 Webhook URL 和签名密钥 +3. 将 URL 和密钥配置到 GitHub Secrets: + - `FEISHU_WEBHOOK_URL`: Webhook 地址 + - `FEISHU_WEBHOOK_SECRET`: 签名密钥 + +## 错误处理 + +脚本在以下情况会返回非零退出码: + +- 缺少必需的环境变量(`FEISHU_WEBHOOK_URL`、`FEISHU_WEBHOOK_SECRET`) +- 缺少必需的命令参数 +- 飞书 API 返回非 2xx 状态码 +- 网络请求失败 + +## 扩展新命令 + +CLI 设计支持多种通知类型。添加新命令的步骤: + +1. 定义命令选项接口 +2. 创建卡片构建函数 +3. 添加新的命令处理函数 +4. 使用 `program.command()` 注册命令 diff --git a/electron-builder.yml b/electron-builder.yml index af1774c941..79be52902a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -96,6 +96,7 @@ nsis: oneClick: false include: build/nsis-installer.nsh buildUniversalInstaller: false + differentialPackage: false portable: artifactName: ${productName}-${version}-${arch}-portable.${ext} buildUniversalInstaller: false @@ -111,8 +112,11 @@ mac: target: - target: dmg - target: zip +dmg: + writeUpdateInfo: false linux: artifactName: ${productName}-${version}-${arch}.${ext} + executableName: CherryStudio target: - target: AppImage - target: deb @@ -121,6 +125,7 @@ linux: category: Utility desktop: entry: + Name: Cherry Studio StartupWMClass: CherryStudio mimeTypes: - x-scheme-handler/cherrystudio @@ -140,44 +145,60 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - Cherry Studio 1.7.9 - New Features & Bug Fixes + Cherry Studio 1.7.14 - New Features & Improvements ✨ New Features - - [Agent] Add 302.AI provider support - - [Browser] Browser data now persists and supports multiple tabs - - [Language] Add Romanian language support - - [Search] Add fuzzy search for file list - - [Models] Add latest Zhipu models - - [Image] Improve text-to-image functionality + - [Notes] Add export to Word document functionality + - [Code Tools] Add Kimi CLI support with auto-configuration + - [Code Tools] Support custom providers + - [Settings] Support viewing detailed error messages when model detection fails + - [Topics] Display year in topic timestamps (YYYY/MM/DD format) + - [Linux] Add system title bar setting option + - [Models] Add Baichuan m3/m3-plus models + - [Models] Add Qwen text-embedding models + + 🎨 Improvements + - [Translate] Simplify translation with single target language selector + - [Topics] Unpinned topics now move to top with auto-scroll + - [Minapps] Add locale-based filtering support + - [i18n] Update Romanian localization 🐛 Bug Fixes - - [Mac] Fix mini window unexpected closing issue - - [Preview] Fix HTML preview controls not working in fullscreen - - [Translate] Fix translation duplicate execution issue - - [Zoom] Fix page zoom reset issue during navigation - - [Agent] Fix crash when switching between agent and assistant - - [Agent] Fix navigation in agent mode - - [Copy] Fix markdown copy button issue - - [Windows] Fix compatibility issues on non-Windows systems + - [Linux] Fix icon display and deb/rpm installation issues + - [Linux] Fix window not coming to front when clicking tray + - [Linux] Add Alpine Linux (musl) support + - [Code Tools] Fix Windows Terminal issues + - [Azure] Fix API preview link for completion mode + - [Images] Fix trailing slashes in API URLs for image generation + - [OpenRouter] Fix MCP tools support + - [Chat] Fix image enhancement model conversation history - Cherry Studio 1.7.9 - 新功能与问题修复 + Cherry Studio 1.7.14 - 新功能与改进 ✨ 新功能 - - [Agent] 新增 302.AI 服务商支持 - - [浏览器] 浏览器数据现在可以保存,支持多标签页 - - [语言] 新增罗马尼亚语支持 - - [搜索] 文件列表新增模糊搜索功能 - - [模型] 新增最新智谱模型 - - [图片] 优化文生图功能 + - [笔记] 支持导出为 Word 文档 + - [代码工具] 新增 Kimi CLI 支持,自动配置环境 + - [代码工具] 支持自定义服务商 + - [设置] 模型检测失败时可查看详细错误信息 + - [话题] 时间戳显示年份(YYYY/MM/DD 格式) + - [Linux] 新增系统标题栏设置选项 + - [模型] 新增百川 m3/m3-plus 模型 + - [模型] 新增通义 Embedding 模型 + + 🎨 改进 + - [翻译] 简化翻译操作,使用单一目标语言选择 + - [话题] 取消置顶的话题移动到顶部并自动滚动 + - [小程序] 支持按语言筛选小程序 + - [国际化] 更新罗马尼亚语翻译 🐛 问题修复 - - [Mac] 修复迷你窗口意外关闭的问题 - - [预览] 修复全屏模式下 HTML 预览控件无法使用的问题 - - [翻译] 修复翻译重复执行的问题 - - [缩放] 修复页面导航时缩放被重置的问题 - - [智能体] 修复在智能体和助手间切换时崩溃的问题 - - [智能体] 修复智能体模式下的导航问题 - - [复制] 修复 Markdown 复制按钮问题 - - [兼容性] 修复非 Windows 系统的兼容性问题 + - [Linux] 修复图标显示和 deb/rpm 安装问题 + - [Linux] 修复点击托盘后窗口无法置顶的问题 + - [Linux] 新增 Alpine Linux (musl) 支持 + - [代码工具] 修复 Windows 终端问题 + - [Azure] 修复完成模式下 API 预览链接 + - [图片生成] 修复 API 地址结尾斜杠问题 + - [OpenRouter] 修复 MCP 工具支持 + - [对话] 修复图片增强模型对话历史丢失问题 diff --git a/eslint.config.mjs b/eslint.config.mjs index 9eb20d1238..0667541aeb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -84,7 +84,7 @@ export default defineConfig([ { selector: 'CallExpression[callee.object.name="console"]', message: - '❗CherryStudio uses unified LoggerService: 📖 docs/technical/how-to-use-logger-en.md\n❗CherryStudio 使用统一的日志服务:📖 docs/technical/how-to-use-logger-zh.md\n\n' + '❗CherryStudio uses unified LoggerService: 📖 docs/en/guides/logging.md\n❗CherryStudio 使用统一的日志服务:📖 docs/zh/guides/logging.md\n\n' } ] } diff --git a/package.json b/package.json index a096b91322..7bd6296ef9 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "CherryStudio", - "version": "1.7.9", + "version": "1.7.15", "private": true, "description": "A powerful AI assistant for producer.", + "desktopName": "CherryStudio.desktop", "main": "./out/main/index.js", "author": "support@cherry-ai.com", "homepage": "https://github.com/CherryHQ/cherry-studio", @@ -33,16 +34,17 @@ "agents:push": "NODE_ENV='development' drizzle-kit push --config src/main/services/agents/drizzle.config.ts", "agents:studio": "NODE_ENV='development' drizzle-kit studio --config src/main/services/agents/drizzle.config.ts", "agents:drop": "NODE_ENV='development' drizzle-kit drop --config src/main/services/agents/drizzle.config.ts", - "generate:icons": "electron-icon-builder --input=./build/logo.png --output=build", "analyze:renderer": "VISUALIZER_RENDERER=true pnpm build", "analyze:main": "VISUALIZER_MAIN=true pnpm build", "typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"", "typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false", "i18n:check": "dotenv -e .env -- tsx scripts/check-i18n.ts", + "i18n:hardcoded": "tsx scripts/check-hardcoded-strings.ts", + "i18n:hardcoded:strict": "I18N_STRICT=true tsx scripts/check-hardcoded-strings.ts", "i18n:sync": "dotenv -e .env -- tsx scripts/sync-i18n.ts", "i18n:translate": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts", - "i18n:all": "pnpm i18n:check && pnpm i18n:sync && pnpm i18n:translate", + "i18n:all": "pnpm i18n:sync && pnpm i18n:translate", "update:languages": "tsx scripts/update-languages.ts", "update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts", "test": "vitest run --silent", @@ -59,7 +61,7 @@ "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && pnpm typecheck && pnpm i18n:check && pnpm format:check", "format": "biome format --write && biome lint --write", "format:check": "biome format && biome lint", - "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", + "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && prek install", "claude": "dotenv -e .env -- claude", "release:aicore:alpha": "pnpm --filter @cherrystudio/ai-core version prerelease --preid alpha && pnpm --filter @cherrystudio/ai-core build && pnpm --filter @cherrystudio/ai-core publish --tag alpha --access public", "release:aicore:beta": "pnpm --filter @cherrystudio/ai-core version prerelease --preid beta && pnpm --filter @cherrystudio/ai-core build && pnpm --filter @cherrystudio/ai-core publish --tag beta --access public", @@ -67,7 +69,7 @@ "release:ai-sdk-provider": "pnpm --filter @cherrystudio/ai-sdk-provider version patch && pnpm --filter @cherrystudio/ai-sdk-provider build && pnpm --filter @cherrystudio/ai-sdk-provider publish --access public" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.1.62", + "@anthropic-ai/claude-agent-sdk": "0.1.76", "@libsql/client": "0.14.0", "@napi-rs/system-ocr": "1.0.2", "@paymoapp/electron-shutdown-handler": "1.1.2", @@ -87,9 +89,6 @@ "turndown": "7.2.0" }, "devDependencies": { - "js-yaml": "4.1.0", - "bonjour-service": "1.3.0", - "emoji-picker-element-data": "1", "@agentic/exa": "^7.3.3", "@agentic/searxng": "^7.3.3", "@agentic/tavily": "^7.3.3", @@ -146,6 +145,7 @@ "@floating-ui/dom": "1.7.3", "@google/genai": "1.0.1", "@hello-pangea/dnd": "^18.0.1", + "@j178/prek": "^0.2.28", "@kangfenmao/keyv-storage": "^0.1.3", "@langchain/community": "^1.0.0", "@langchain/core": "1.0.2", @@ -247,6 +247,7 @@ "archiver": "^7.0.1", "async-mutex": "^0.5.0", "axios": "^1.7.3", + "bonjour-service": "1.3.0", "browser-image-compression": "^2.0.2", "builder-util-runtime": "9.5.0", "chalk": "4.1.2", @@ -260,6 +261,7 @@ "code-inspector-plugin": "^0.20.14", "codemirror-lang-mermaid": "0.5.0", "color": "^5.0.0", + "commander": "^14.0.2", "concurrently": "^9.2.1", "cors": "2.8.5", "country-flag-emoji-polyfill": "0.1.8", @@ -283,6 +285,7 @@ "electron-window-state": "^5.0.3", "emittery": "^1.0.3", "emoji-picker-element": "^1.22.1", + "emoji-picker-element-data": "1", "epub": "1.3.0", "eslint": "^9.22.0", "eslint-plugin-import-zod": "^1.2.0", @@ -304,7 +307,6 @@ "html-to-image": "^1.11.13", "html-to-text": "^9.0.5", "htmlparser2": "^10.0.0", - "husky": "^9.1.7", "i18next": "^23.11.5", "iconv-lite": "^0.6.3", "ipaddr.js": "^2.2.0", @@ -312,11 +314,11 @@ "jaison": "^2.0.2", "jest-styled-components": "^7.2.0", "js-base64": "3.7.7", + "js-yaml": "4.1.0", "json-schema": "0.4.0", "katex": "0.16.22", "ky": "1.8.1", "linguist-languages": "^8.1.0", - "lint-staged": "^15.5.0", "lodash": "^4.17.21", "lru-cache": "^11.1.0", "lucide-react": "^0.525.0", @@ -336,6 +338,7 @@ "oxlint-tsgolint": "^0.2.0", "p-queue": "^8.1.0", "pako": "1.0.11", + "partial-json": "0.1.7", "pdf-lib": "^1.17.1", "pdf-parse": "^1.1.1", "prosemirror-model": "1.25.2", @@ -385,6 +388,7 @@ "tar": "^7.4.3", "tiny-pinyin": "^1.3.2", "tokenx": "^1.1.0", + "ts-morph": "^27.0.2", "tsx": "^4.20.3", "turndown-plugin-gfm": "^1.0.2", "tw-animate-css": "^1.3.8", @@ -425,10 +429,10 @@ "@img/sharp-linux-x64": "0.34.3", "@img/sharp-win32-x64": "0.34.3", "@langchain/core": "1.0.2", - "@ai-sdk/openai-compatible@1.0.27": "1.0.28" + "@ai-sdk/openai-compatible@1.0.27": "1.0.28", + "@ai-sdk/openai-compatible@1.0.30": "1.0.28" }, "patchedDependencies": { - "@anthropic-ai/claude-agent-sdk@0.1.62": "patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch", "@napi-rs/system-ocr@1.0.2": "patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", "tesseract.js@6.0.1": "patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", "@ai-sdk/google@3.0.0": "patches/@ai-sdk-google-npm-3.0.0-ef668576ff.patch", @@ -446,9 +450,13 @@ "atomically@1.7.0": "patches/atomically-npm-1.7.0-e742e5293b.patch", "file-stream-rotator@0.6.1": "patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch", "libsql@0.4.7": "patches/libsql-npm-0.4.7-444e260fb1.patch", - "pdf-parse@1.1.1": "patches/pdf-parse-npm-1.1.1-04a6109b2a.patch" + "pdf-parse@1.1.1": "patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", + "@ai-sdk/openai-compatible@1.0.28": "patches/@ai-sdk__openai-compatible@1.0.28.patch", + "@anthropic-ai/claude-agent-sdk@0.1.76": "patches/@anthropic-ai__claude-agent-sdk@0.1.76.patch", + "@openrouter/ai-sdk-provider": "patches/@openrouter__ai-sdk-provider.patch" }, "onlyBuiltDependencies": [ + "@j178/prek", "@kangfenmao/keyv-storage", "@paymoapp/electron-shutdown-handler", "@scarf/scarf", @@ -466,13 +474,32 @@ ] }, "packageManager": "pnpm@10.27.0", - "lint-staged": { - "*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [ - "biome format --write --no-errors-on-unmatched", - "eslint --fix" - ], - "*.{json,yml,yaml,css,html}": [ - "biome format --write --no-errors-on-unmatched" - ] + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-x64": "0.34.3", + "@libsql/darwin-arm64": "0.4.7", + "@libsql/darwin-x64": "0.4.7", + "@libsql/linux-arm64-gnu": "0.4.7", + "@libsql/linux-arm64-musl": "0.4.7", + "@libsql/linux-x64-gnu": "0.4.7", + "@libsql/linux-x64-musl": "0.4.7", + "@libsql/win32-x64-msvc": "0.4.7", + "@napi-rs/system-ocr-darwin-arm64": "1.0.2", + "@napi-rs/system-ocr-darwin-x64": "1.0.2", + "@napi-rs/system-ocr-win32-arm64-msvc": "1.0.2", + "@napi-rs/system-ocr-win32-x64-msvc": "1.0.2", + "@strongtz/win32-arm64-msvc": "0.4.7" } } diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts index 85781c428d..44ec83b8fe 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts @@ -21,9 +21,6 @@ const TOOL_USE_TAG_CONFIG: TagConfig = { separator: '\n' } -/** - * 默认系统提示符模板 - */ export const DEFAULT_SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \ You can use one or more tools per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use. @@ -38,10 +35,16 @@ Tool use is formatted using XML-style tags. The tool name is enclosed in opening The tool name should be the exact name of the tool you are using, and the arguments should be a JSON object containing the parameters required by that tool. For example: - python_interpreter - {"code": "5 + 3 + 1294.678"} + search + { "query": "browser,fetch" } + + exec + { "code": "const page = await CherryBrowser_fetch({ url: "https://example.com" })\nreturn page" } + + + The user will respond with the result of the tool use, which should be formatted as follows: @@ -59,13 +62,6 @@ For example, if the result of the tool use is an image file, you can use it in t Always adhere to this format for the tool use to ensure proper parsing and execution. -## Tool Use Examples -{{ TOOL_USE_EXAMPLES }} - -## Tool Use Available Tools -Above example were using notional tools that might not exist for you. You only have access to these tools: -{{ AVAILABLE_TOOLS }} - ## Tool Use Rules Here are the rules you should always follow to solve your task: 1. Always use the right arguments for the tools. Never use variable names as the action arguments, use the value instead. @@ -74,6 +70,8 @@ Here are the rules you should always follow to solve your task: 4. Never re-do a tool call that you previously did with the exact same parameters. 5. For tool use, MAKE SURE use XML tag format as shown in the examples above. Do not use any other format. +{{ TOOLS_INFO }} + ## Response rules Respond in the language of the user's query, unless the user instructions specify additional requirements for the language to be used. @@ -154,7 +152,8 @@ User: search 26 million (2019) -Assistant: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population.` + +A: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population.` /** * 构建可用工具部分(提取自 Cherry Studio) @@ -184,13 +183,30 @@ ${result} /** * 默认的系统提示符构建函数(提取自 Cherry Studio) */ -function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet): string { +function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet, mcpMode?: string): string { const availableTools = buildAvailableTools(tools) if (availableTools === null) return userSystemPrompt - const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES) + if (mcpMode == 'auto') { + return DEFAULT_SYSTEM_PROMPT.replace('{{ TOOLS_INFO }}', '').replace( + '{{ USER_SYSTEM_PROMPT }}', + userSystemPrompt || '' + ) + } + const toolsInfo = ` +## Tool Use Examples +{{ TOOL_USE_EXAMPLES }} + +## Tool Use Available Tools +Above example were using notional tools that might not exist for you. You only have access to these tools: +{{ AVAILABLE_TOOLS }}` + .replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES) .replace('{{ AVAILABLE_TOOLS }}', availableTools) - .replace('{{ USER_SYSTEM_PROMPT }}', userSystemPrompt || '') + + const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOLS_INFO }}', toolsInfo).replace( + '{{ USER_SYSTEM_PROMPT }}', + userSystemPrompt || '' + ) return fullPrompt } @@ -223,7 +239,17 @@ function defaultParseToolUse(content: string, tools: ToolSet): { results: ToolUs // Find all tool use blocks while ((match = toolUsePattern.exec(contentToProcess)) !== null) { const fullMatch = match[0] - const toolName = match[2].trim() + let toolName = match[2].trim() + switch (toolName.toLowerCase()) { + case 'search': + toolName = 'mcp__CherryHub__search' + break + case 'exec': + toolName = 'mcp__CherryHub__exec' + break + default: + break + } const toolArgs = match[4].trim() // Try to parse the arguments as JSON @@ -257,7 +283,12 @@ function defaultParseToolUse(content: string, tools: ToolSet): { results: ToolUs export const createPromptToolUsePlugin = ( config: PromptToolUseConfig = {} ): AiPlugin => { - const { enabled = true, buildSystemPrompt = defaultBuildSystemPrompt, parseToolUse = defaultParseToolUse } = config + const { + enabled = true, + buildSystemPrompt = defaultBuildSystemPrompt, + parseToolUse = defaultParseToolUse, + mcpMode + } = config return definePlugin({ name: 'built-in:prompt-tool-use', @@ -287,7 +318,12 @@ export const createPromptToolUsePlugin = ( // 构建系统提示符(只包含非 provider 工具) const userSystemPrompt = typeof params.system === 'string' ? params.system : '' - const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools) + const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools, mcpMode) + let systemMessage: string | null = systemPrompt + if (config.createSystemMessage) { + // 🎯 如果用户提供了自定义处理函数,使用它 + systemMessage = config.createSystemMessage(systemPrompt, params, context) + } // 保留 provide tools,移除其他 tools const transformedParams = { diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts index dadad8c508..a9f25189cf 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts @@ -22,6 +22,7 @@ export interface PromptToolUseConfig extends BaseToolUsePluginConfig { buildSystemPrompt?: (userSystemPrompt: string, tools: ToolSet) => string // 自定义工具解析函数(可选,有默认实现) parseToolUse?: (content: string, tools: ToolSet) => { results: ToolUseResult[]; content: string } + mcpMode?: string } /** diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 8361a917e5..8504c47005 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -48,6 +48,7 @@ export enum IpcChannel { App_QuoteToMain = 'app:quote-to-main', App_SetDisableHardwareAcceleration = 'app:set-disable-hardware-acceleration', + App_SetUseSystemTitleBar = 'app:set-use-system-title-bar', Notification_Send = 'notification:send', Notification_OnClick = 'notification:on-click', diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index af0191f4fa..0304d84675 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -233,7 +233,8 @@ export enum codeTools { geminiCli = 'gemini-cli', openaiCodex = 'openai-codex', iFlowCli = 'iflow-cli', - githubCopilotCli = 'github-copilot-cli' + githubCopilotCli = 'github-copilot-cli', + kimiCli = 'kimi-cli' } export enum terminalApps { diff --git a/packages/shared/mcp.ts b/packages/shared/mcp.ts new file mode 100644 index 0000000000..b8e5494f17 --- /dev/null +++ b/packages/shared/mcp.ts @@ -0,0 +1,116 @@ +/** + * Convert a string to camelCase, ensuring it's a valid JavaScript identifier. + * + * - Normalizes to lowercase first, then capitalizes word boundaries + * - Non-alphanumeric characters are treated as word separators + * - Non-ASCII characters are dropped (ASCII-only output) + * - If result starts with a digit, prefixes with underscore + * + * @example + * toCamelCase('my-server') // 'myServer' + * toCamelCase('MY_SERVER') // 'myServer' + * toCamelCase('123tool') // '_123tool' + */ +export function toCamelCase(str: string): string { + let result = str + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+(.)/g, (_, char) => char.toUpperCase()) + .replace(/[^a-zA-Z0-9]/g, '') + + if (result && !/^[a-zA-Z_]/.test(result)) { + result = '_' + result + } + + return result +} + +export type McpToolNameOptions = { + /** Prefix added before the name (e.g., 'mcp__'). Must be JS-identifier-safe. */ + prefix?: string + /** Delimiter between server and tool parts (e.g., '_' or '__'). Must be JS-identifier-safe. */ + delimiter?: string + /** Maximum length of the final name. Suffix numbers for uniqueness are included in this limit. */ + maxLength?: number + /** Mutable Set for collision detection. The final name will be added to this Set. */ + existingNames?: Set +} + +/** + * Build a valid JavaScript function name from server and tool names. + * Uses camelCase for both parts. + * + * @param serverName - The MCP server name (optional) + * @param toolName - The tool name + * @param options - Configuration options + * @returns A valid JS identifier + */ +export function buildMcpToolName( + serverName: string | undefined, + toolName: string, + options: McpToolNameOptions = {} +): string { + const { prefix = '', delimiter = '_', maxLength, existingNames } = options + + const serverPart = serverName ? toCamelCase(serverName) : '' + const toolPart = toCamelCase(toolName) + const baseName = serverPart ? `${prefix}${serverPart}${delimiter}${toolPart}` : `${prefix}${toolPart}` + + if (!existingNames) { + return maxLength ? truncateToLength(baseName, maxLength) : baseName + } + + let name = maxLength ? truncateToLength(baseName, maxLength) : baseName + let counter = 1 + + while (existingNames.has(name)) { + const suffix = String(counter) + const truncatedBase = maxLength ? truncateToLength(baseName, maxLength - suffix.length) : baseName + name = `${truncatedBase}${suffix}` + counter++ + } + + existingNames.add(name) + return name +} + +function truncateToLength(str: string, maxLength: number): string { + if (str.length <= maxLength) { + return str + } + return str.slice(0, maxLength).replace(/_+$/, '') +} + +/** + * Generate a unique function name from server name and tool name. + * Format: serverName_toolName (camelCase) + * + * @example + * generateMcpToolFunctionName('github', 'search_issues') // 'github_searchIssues' + */ +export function generateMcpToolFunctionName( + serverName: string | undefined, + toolName: string, + existingNames?: Set +): string { + return buildMcpToolName(serverName, toolName, { existingNames }) +} + +/** + * Builds a valid JavaScript function name for MCP tool calls. + * Format: mcp__{serverName}__{toolName} + * + * @param serverName - The MCP server name + * @param toolName - The tool name from the server + * @returns A valid JS identifier in format mcp__{server}__{tool}, max 63 chars + * + * @example + * buildFunctionCallToolName('github', 'search_issues') // 'mcp__github__searchIssues' + */ +export function buildFunctionCallToolName(serverName: string, toolName: string): string { + return buildMcpToolName(serverName, toolName, { + prefix: 'mcp__', + delimiter: '__', + maxLength: 63 + }) +} diff --git a/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch b/patches/@ai-sdk__openai-compatible@1.0.28.patch similarity index 80% rename from patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch rename to patches/@ai-sdk__openai-compatible@1.0.28.patch index 3178ffee5e..e72b46458f 100644 --- a/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch +++ b/patches/@ai-sdk__openai-compatible@1.0.28.patch @@ -9,9 +9,9 @@ index 48e2f6263c6ee4c75d7e5c28733e64f6ebe92200..00d0729c4a3cbf9a48e8e1e962c7e2b2 + sendReasoning: z.ZodOptional; }, z.core.$strip>; type OpenAICompatibleProviderOptions = z.infer; - + diff --git a/dist/index.js b/dist/index.js -index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e5bfe0f9a 100644 +index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..88349c614a69a268a2e4f3b157cb5e328ca1d347 100644 --- a/dist/index.js +++ b/dist/index.js @@ -41,7 +41,7 @@ function getOpenAIMetadata(message) { @@ -52,17 +52,38 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e tool_calls: toolCalls.length > 0 ? toolCalls : void 0, ...metadata }); -@@ -200,7 +208,8 @@ var openaiCompatibleProviderOptions = import_v4.z.object({ +@@ -200,7 +208,9 @@ var openaiCompatibleProviderOptions = import_v4.z.object({ /** * Controls the verbosity of the generated text. Defaults to `medium`. */ - textVerbosity: import_v4.z.string().optional() + textVerbosity: import_v4.z.string().optional(), -+ sendReasoning: import_v4.z.boolean().optional() ++ sendReasoning: import_v4.z.boolean().optional(), ++ strictJsonSchema: z.boolean().optional() }); - + // src/openai-compatible-error.ts -@@ -378,7 +387,7 @@ var OpenAICompatibleChatLanguageModel = class { +@@ -225,7 +235,8 @@ var defaultOpenAICompatibleErrorStructure = { + var import_provider2 = require("@ai-sdk/provider"); + function prepareTools({ + tools, +- toolChoice ++ toolChoice, ++ strictJsonSchema + }) { + tools = (tools == null ? void 0 : tools.length) ? tools : void 0; + const toolWarnings = []; +@@ -242,7 +253,8 @@ function prepareTools({ + function: { + name: tool.name, + description: tool.description, +- parameters: tool.inputSchema ++ parameters: tool.inputSchema, ++ strict: strictJsonSchema + } + }); + } +@@ -378,7 +390,7 @@ var OpenAICompatibleChatLanguageModel = class { reasoning_effort: compatibleOptions.reasoningEffort, verbosity: compatibleOptions.textVerbosity, // messages: @@ -71,7 +92,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e // tools: tools: openaiTools, tool_choice: openaiToolChoice -@@ -421,6 +430,17 @@ var OpenAICompatibleChatLanguageModel = class { +@@ -421,6 +433,17 @@ var OpenAICompatibleChatLanguageModel = class { text: reasoning }); } @@ -89,7 +110,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e if (choice.message.tool_calls != null) { for (const toolCall of choice.message.tool_calls) { content.push({ -@@ -598,6 +618,17 @@ var OpenAICompatibleChatLanguageModel = class { +@@ -598,6 +621,17 @@ var OpenAICompatibleChatLanguageModel = class { delta: delta.content }); } @@ -107,7 +128,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e if (delta.tool_calls != null) { for (const toolCallDelta of delta.tool_calls) { const index = toolCallDelta.index; -@@ -765,6 +796,14 @@ var OpenAICompatibleChatResponseSchema = import_v43.z.object({ +@@ -765,6 +799,14 @@ var OpenAICompatibleChatResponseSchema = import_v43.z.object({ arguments: import_v43.z.string() }) }) @@ -122,7 +143,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e ).nullish() }), finish_reason: import_v43.z.string().nullish() -@@ -795,6 +834,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => import_v43.z.union( +@@ -795,6 +837,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => import_v43.z.union( arguments: import_v43.z.string().nullish() }) }) @@ -138,7 +159,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e }).nullish(), finish_reason: import_v43.z.string().nullish() diff --git a/dist/index.mjs b/dist/index.mjs -index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5700264de 100644 +index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..fca65c04000ce4c01fb90e93326ac179c2378055 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -23,7 +23,7 @@ function getOpenAIMetadata(message) { @@ -179,17 +200,38 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5 tool_calls: toolCalls.length > 0 ? toolCalls : void 0, ...metadata }); -@@ -182,7 +190,8 @@ var openaiCompatibleProviderOptions = z.object({ +@@ -182,7 +190,9 @@ var openaiCompatibleProviderOptions = z.object({ /** * Controls the verbosity of the generated text. Defaults to `medium`. */ - textVerbosity: z.string().optional() + textVerbosity: z.string().optional(), -+ sendReasoning: z.boolean().optional() ++ sendReasoning: z.boolean().optional(), ++ strictJsonSchema: z.boolean().optional() }); - + // src/openai-compatible-error.ts -@@ -362,7 +371,7 @@ var OpenAICompatibleChatLanguageModel = class { +@@ -209,7 +219,8 @@ import { + } from "@ai-sdk/provider"; + function prepareTools({ + tools, +- toolChoice ++ toolChoice, ++ strictJsonSchema + }) { + tools = (tools == null ? void 0 : tools.length) ? tools : void 0; + const toolWarnings = []; +@@ -226,7 +237,8 @@ function prepareTools({ + function: { + name: tool.name, + description: tool.description, +- parameters: tool.inputSchema ++ parameters: tool.inputSchema, ++ strict: strictJsonSchema + } + }); + } +@@ -362,7 +374,7 @@ var OpenAICompatibleChatLanguageModel = class { reasoning_effort: compatibleOptions.reasoningEffort, verbosity: compatibleOptions.textVerbosity, // messages: @@ -198,7 +240,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5 // tools: tools: openaiTools, tool_choice: openaiToolChoice -@@ -405,6 +414,17 @@ var OpenAICompatibleChatLanguageModel = class { +@@ -405,6 +417,17 @@ var OpenAICompatibleChatLanguageModel = class { text: reasoning }); } @@ -216,7 +258,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5 if (choice.message.tool_calls != null) { for (const toolCall of choice.message.tool_calls) { content.push({ -@@ -582,6 +602,17 @@ var OpenAICompatibleChatLanguageModel = class { +@@ -582,6 +605,17 @@ var OpenAICompatibleChatLanguageModel = class { delta: delta.content }); } @@ -234,7 +276,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5 if (delta.tool_calls != null) { for (const toolCallDelta of delta.tool_calls) { const index = toolCallDelta.index; -@@ -749,6 +780,14 @@ var OpenAICompatibleChatResponseSchema = z3.object({ +@@ -749,6 +783,14 @@ var OpenAICompatibleChatResponseSchema = z3.object({ arguments: z3.string() }) }) @@ -249,7 +291,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5 ).nullish() }), finish_reason: z3.string().nullish() -@@ -779,6 +818,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => z3.union([ +@@ -779,6 +821,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => z3.union([ arguments: z3.string().nullish() }) }) diff --git a/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch b/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch deleted file mode 100644 index 62ab767576..0000000000 --- a/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch +++ /dev/null @@ -1,35 +0,0 @@ -diff --git a/sdk.mjs b/sdk.mjs -index dea7766a3432a1e809f12d6daba4f2834a219689..e0b02ef73da177ba32b903887d7bbbeaa08cc6d3 100755 ---- a/sdk.mjs -+++ b/sdk.mjs -@@ -6250,7 +6250,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 -@@ -6644,18 +6644,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]; -- const spawnMessage = isNative ? `Spawning Claude Code native binary: ${spawnCommand} ${spawnArgs.join(" ")}` : `Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(" ")}`; -- logForSdkDebugging(spawnMessage); -- if (stderr) { -- stderr(spawnMessage); -- } -+ logForSdkDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`); - const stderrMode = env.DEBUG_CLAUDE_AGENT_SDK || stderr ? "pipe" : "ignore"; -- this.child = spawn(spawnCommand, spawnArgs, { -+ this.child = fork(pathToClaudeCodeExecutable, args, { - cwd, -- stdio: ["pipe", "pipe", stderrMode], -+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"], - signal: this.abortController.signal, - env - }); diff --git a/patches/@anthropic-ai__claude-agent-sdk@0.1.76.patch b/patches/@anthropic-ai__claude-agent-sdk@0.1.76.patch new file mode 100644 index 0000000000..c699d6342c --- /dev/null +++ b/patches/@anthropic-ai__claude-agent-sdk@0.1.76.patch @@ -0,0 +1,33 @@ +diff --git a/sdk.mjs b/sdk.mjs +index 1e1c3e4e3f81db622fb2789d17f3d421f212306e..5d193cdb6a43c7799fd5eff2d8af80827bfbdf1e 100755 +--- a/sdk.mjs ++++ b/sdk.mjs +@@ -11985,7 +11985,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 +@@ -12999,14 +12999,14 @@ class ProcessTransport { + return isRunningWithBun() ? "bun" : "node"; + } + spawnLocalProcess(spawnOptions) { +- const { command, args, cwd: cwd2, env, signal } = spawnOptions; ++ const { args, cwd: cwd2, env, signal } = spawnOptions; + const stderrMode = env.DEBUG_CLAUDE_AGENT_SDK || this.options.stderr ? "pipe" : "ignore"; +- const childProcess = spawn(command, args, { ++ logForSdkDebugging(`Forking Claude Code Node.js process: ${args[0]} ${args.slice(1).join(" ")}`); ++ const childProcess = fork(args[0], args.slice(1), { + cwd: cwd2, +- stdio: ["pipe", "pipe", stderrMode], ++ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"], + signal, +- env, +- windowsHide: true ++ env + }); + if (env.DEBUG_CLAUDE_AGENT_SDK || this.options.stderr) { + childProcess.stderr.on("data", (data) => { diff --git a/patches/@openrouter__ai-sdk-provider.patch b/patches/@openrouter__ai-sdk-provider.patch new file mode 100644 index 0000000000..6549d3f5d2 --- /dev/null +++ b/patches/@openrouter__ai-sdk-provider.patch @@ -0,0 +1,140 @@ +diff --git a/dist/index.js b/dist/index.js +index f33510a50d11a2cb92a90ea70cc0ac84c89f29b9..db0af7e2cc05c47baeb29c0a3974a155316fbd05 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -1050,7 +1050,8 @@ var OpenRouterProviderMetadataSchema = import_v43.z.object({ + var OpenRouterProviderOptionsSchema = import_v43.z.object({ + openrouter: import_v43.z.object({ + reasoning_details: import_v43.z.array(ReasoningDetailUnionSchema).optional(), +- annotations: import_v43.z.array(FileAnnotationSchema).optional() ++ annotations: import_v43.z.array(FileAnnotationSchema).optional(), ++ strictJsonSchema: import_v43.z.boolean().optional() + }).optional() + }).optional(); + +@@ -1658,7 +1659,8 @@ var OpenRouterChatLanguageModel = class { + responseFormat, + topK, + tools, +- toolChoice ++ toolChoice, ++ providerOptions + }) { + var _a15; + const baseArgs = __spreadValues(__spreadValues({ +@@ -1712,7 +1714,8 @@ var OpenRouterChatLanguageModel = class { + function: { + name: tool.name, + description: tool.description, +- parameters: tool.inputSchema ++ parameters: tool.inputSchema, ++ strict: providerOptions?.openrouter?.strictJsonSchema + } + })); + return __spreadProps(__spreadValues({}, baseArgs), { +@@ -1725,7 +1728,7 @@ var OpenRouterChatLanguageModel = class { + async doGenerate(options) { + var _a15, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w; + const providerOptions = options.providerOptions || {}; +- const openrouterOptions = providerOptions.openrouter || {}; ++ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {}; + const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions); + const { value: responseValue, responseHeaders } = await postJsonToApi({ + url: this.config.url({ +@@ -1931,7 +1934,7 @@ var OpenRouterChatLanguageModel = class { + async doStream(options) { + var _a15; + const providerOptions = options.providerOptions || {}; +- const openrouterOptions = providerOptions.openrouter || {}; ++ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {}; + const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions); + const { value: response, responseHeaders } = await postJsonToApi({ + url: this.config.url({ +@@ -2564,7 +2567,7 @@ var OpenRouterCompletionLanguageModel = class { + async doGenerate(options) { + var _a15, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o; + const providerOptions = options.providerOptions || {}; +- const openrouterOptions = providerOptions.openrouter || {}; ++ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {}; + const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions); + const { value: response, responseHeaders } = await postJsonToApi({ + url: this.config.url({ +@@ -2623,7 +2626,7 @@ var OpenRouterCompletionLanguageModel = class { + } + async doStream(options) { + const providerOptions = options.providerOptions || {}; +- const openrouterOptions = providerOptions.openrouter || {}; ++ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {}; + const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions); + const { value: response, responseHeaders } = await postJsonToApi({ + url: this.config.url({ +diff --git a/dist/index.mjs b/dist/index.mjs +index 8a688331b88b4af738ee4ca8062b5f24124d3d81..a2aa299a44352addc26f8891d839ea31a2150ee2 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -1015,7 +1015,8 @@ var OpenRouterProviderMetadataSchema = z3.object({ + var OpenRouterProviderOptionsSchema = z3.object({ + openrouter: z3.object({ + reasoning_details: z3.array(ReasoningDetailUnionSchema).optional(), +- annotations: z3.array(FileAnnotationSchema).optional() ++ annotations: z3.array(FileAnnotationSchema).optional(), ++ strictJsonSchema: z3.boolean().optional() + }).optional() + }).optional(); + +@@ -1623,7 +1624,8 @@ var OpenRouterChatLanguageModel = class { + responseFormat, + topK, + tools, +- toolChoice ++ toolChoice, ++ providerOptions + }) { + var _a15; + const baseArgs = __spreadValues(__spreadValues({ +@@ -1677,7 +1679,8 @@ var OpenRouterChatLanguageModel = class { + function: { + name: tool.name, + description: tool.description, +- parameters: tool.inputSchema ++ parameters: tool.inputSchema, ++ strict: providerOptions?.openrouter?.strictJsonSchema + } + })); + return __spreadProps(__spreadValues({}, baseArgs), { +@@ -1690,7 +1693,7 @@ var OpenRouterChatLanguageModel = class { + async doGenerate(options) { + var _a15, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w; + const providerOptions = options.providerOptions || {}; +- const openrouterOptions = providerOptions.openrouter || {}; ++ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {}; + const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions); + const { value: responseValue, responseHeaders } = await postJsonToApi({ + url: this.config.url({ +@@ -1896,7 +1899,7 @@ var OpenRouterChatLanguageModel = class { + async doStream(options) { + var _a15; + const providerOptions = options.providerOptions || {}; +- const openrouterOptions = providerOptions.openrouter || {}; ++ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {}; + const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions); + const { value: response, responseHeaders } = await postJsonToApi({ + url: this.config.url({ +@@ -2529,7 +2532,7 @@ var OpenRouterCompletionLanguageModel = class { + async doGenerate(options) { + var _a15, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o; + const providerOptions = options.providerOptions || {}; +- const openrouterOptions = providerOptions.openrouter || {}; ++ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {}; + const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions); + const { value: response, responseHeaders } = await postJsonToApi({ + url: this.config.url({ +@@ -2588,7 +2591,7 @@ var OpenRouterCompletionLanguageModel = class { + } + async doStream(options) { + const providerOptions = options.providerOptions || {}; +- const openrouterOptions = providerOptions.openrouter || {}; ++ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {}; + const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions); + const { value: response, responseHeaders } = await postJsonToApi({ + url: this.config.url({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e93839f943..89033cde4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,20 +23,21 @@ overrides: '@img/sharp-win32-x64': 0.34.3 '@langchain/core': 1.0.2 '@ai-sdk/openai-compatible@1.0.27': 1.0.28 + '@ai-sdk/openai-compatible@1.0.30': 1.0.28 patchedDependencies: '@ai-sdk/google@2.0.49': hash: 279e9d43f675e4b979b32b78954dd37acc3026aa36ae2dd7701b5bad2f061522 path: patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch '@ai-sdk/openai-compatible@1.0.28': - hash: 66f6605ef3f852d8f2b638a1d64b138eb8b2ad34ca6f331a0496c1d1379379c1 - path: patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch + hash: 5ea49b4f07636a8e4630097e67e2787779ba7e933bd0459f81b1803cb125edda + path: patches/@ai-sdk__openai-compatible@1.0.28.patch '@ai-sdk/openai@2.0.85': hash: f2077f4759520d1de69b164dfd8adca1a9ace9de667e35cb0e55e812ce2ac13b path: patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch - '@anthropic-ai/claude-agent-sdk@0.1.62': - hash: 61ed4549b423c717cbfef526e3ed5b0329c2de2de88c9ef772188668f7dc26e8 - path: patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch + '@anthropic-ai/claude-agent-sdk@0.1.76': + hash: e063a8ede82d78f452f7f1290b9d4d9323866159b5624679163caa8edd4928d5 + path: patches/@anthropic-ai__claude-agent-sdk@0.1.76.patch '@anthropic-ai/vertex-sdk@0.11.4': hash: 12e3275df5632dfe717d4db64df70e9b0128dfac86195da27722effe4749662f path: patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch @@ -52,6 +53,9 @@ patchedDependencies: '@napi-rs/system-ocr@1.0.2': hash: aa1a73e445ee644774745b620589bb99d85bee6c95cc2a91fe9137e580da5bde path: patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch + '@openrouter/ai-sdk-provider': + hash: 508e8e662b8547de93410cb7c3b1336077f34c6bf79c520ef5273962ea777c52 + path: patches/@openrouter__ai-sdk-provider.patch '@tiptap/extension-drag-handle@3.2.0': hash: 8432665d4553fb9ba8ff2a126a9181c3ccfee06ae57688aa14f65aa560e52fce path: patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch @@ -88,8 +92,8 @@ importers: .: dependencies: '@anthropic-ai/claude-agent-sdk': - specifier: 0.1.62 - version: 0.1.62(patch_hash=61ed4549b423c717cbfef526e3ed5b0329c2de2de88c9ef772188668f7dc26e8)(zod@4.3.4) + specifier: 0.1.76 + version: 0.1.76(patch_hash=e063a8ede82d78f452f7f1290b9d4d9323866159b5624679163caa8edd4928d5)(zod@4.3.4) '@libsql/client': specifier: 0.14.0 version: 0.14.0 @@ -328,6 +332,9 @@ importers: '@hello-pangea/dnd': specifier: ^18.0.1 version: 18.0.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@j178/prek': + specifier: ^0.2.28 + version: 0.2.28 '@kangfenmao/keyv-storage': specifier: ^0.1.3 version: 0.1.3 @@ -357,7 +364,7 @@ importers: version: 2.3.0(encoding@0.1.13) '@openrouter/ai-sdk-provider': specifier: ^1.2.8 - version: 1.5.4(ai@5.0.117(zod@4.3.4))(zod@4.3.4) + version: 1.5.4(patch_hash=508e8e662b8547de93410cb7c3b1336077f34c6bf79c520ef5273962ea777c52)(ai@5.0.117(zod@4.3.4))(zod@4.3.4) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -676,6 +683,9 @@ importers: color: specifier: ^5.0.0 version: 5.0.3 + commander: + specifier: ^14.0.2 + version: 14.0.2 concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -811,9 +821,6 @@ importers: htmlparser2: specifier: ^10.0.0 version: 10.0.0 - husky: - specifier: ^9.1.7 - version: 9.1.7 i18next: specifier: ^23.11.5 version: 23.16.8 @@ -850,9 +857,6 @@ importers: linguist-languages: specifier: ^8.1.0 version: 8.2.0 - lint-staged: - specifier: ^15.5.0 - version: 15.5.2 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -910,6 +914,9 @@ importers: pako: specifier: 1.0.11 version: 1.0.11 + partial-json: + specifier: 0.1.7 + version: 0.1.7 pdf-lib: specifier: ^1.17.1 version: 1.17.1 @@ -1057,6 +1064,9 @@ importers: tokenx: specifier: ^1.1.0 version: 1.2.1 + ts-morph: + specifier: ^27.0.2 + version: 27.0.2 tsx: specifier: ^4.20.3 version: 4.21.0 @@ -1117,6 +1127,85 @@ importers: zod: specifier: ^4.1.5 version: 4.3.4 + optionalDependencies: + '@img/sharp-darwin-arm64': + specifier: 0.34.3 + version: 0.34.3 + '@img/sharp-darwin-x64': + specifier: 0.34.3 + version: 0.34.3 + '@img/sharp-libvips-darwin-arm64': + specifier: 1.2.0 + version: 1.2.0 + '@img/sharp-libvips-darwin-x64': + specifier: 1.2.0 + version: 1.2.0 + '@img/sharp-libvips-linux-arm64': + specifier: 1.2.0 + version: 1.2.0 + '@img/sharp-libvips-linux-x64': + specifier: 1.2.0 + version: 1.2.0 + '@img/sharp-libvips-linuxmusl-arm64': + specifier: 1.2.0 + version: 1.2.0 + '@img/sharp-libvips-linuxmusl-x64': + specifier: 1.2.0 + version: 1.2.0 + '@img/sharp-linux-arm64': + specifier: 0.34.3 + version: 0.34.3 + '@img/sharp-linux-x64': + specifier: 0.34.3 + version: 0.34.3 + '@img/sharp-linuxmusl-arm64': + specifier: 0.34.3 + version: 0.34.3 + '@img/sharp-linuxmusl-x64': + specifier: 0.34.3 + version: 0.34.3 + '@img/sharp-win32-arm64': + specifier: 0.34.3 + version: 0.34.3 + '@img/sharp-win32-x64': + specifier: 0.34.3 + version: 0.34.3 + '@libsql/darwin-arm64': + specifier: 0.4.7 + version: 0.4.7 + '@libsql/darwin-x64': + specifier: 0.4.7 + version: 0.4.7 + '@libsql/linux-arm64-gnu': + specifier: 0.4.7 + version: 0.4.7 + '@libsql/linux-arm64-musl': + specifier: 0.4.7 + version: 0.4.7 + '@libsql/linux-x64-gnu': + specifier: 0.4.7 + version: 0.4.7 + '@libsql/linux-x64-musl': + specifier: 0.4.7 + version: 0.4.7 + '@libsql/win32-x64-msvc': + specifier: 0.4.7 + version: 0.4.7 + '@napi-rs/system-ocr-darwin-arm64': + specifier: 1.0.2 + version: 1.0.2 + '@napi-rs/system-ocr-darwin-x64': + specifier: 1.0.2 + version: 1.0.2 + '@napi-rs/system-ocr-win32-arm64-msvc': + specifier: 1.0.2 + version: 1.0.2 + '@napi-rs/system-ocr-win32-x64-msvc': + specifier: 1.0.2 + version: 1.0.2 + '@strongtz/win32-arm64-msvc': + specifier: 0.4.7 + version: 0.4.7 packages/ai-sdk-provider: dependencies: @@ -1131,7 +1220,7 @@ importers: version: 2.0.85(patch_hash=f2077f4759520d1de69b164dfd8adca1a9ace9de667e35cb0e55e812ce2ac13b)(zod@4.3.5) '@ai-sdk/openai-compatible': specifier: 1.0.28 - version: 1.0.28(patch_hash=66f6605ef3f852d8f2b638a1d64b138eb8b2ad34ca6f331a0496c1d1379379c1)(zod@4.3.5) + version: 1.0.28(patch_hash=5ea49b4f07636a8e4630097e67e2787779ba7e933bd0459f81b1803cb125edda)(zod@4.3.5) '@ai-sdk/provider': specifier: ^2.0.0 version: 2.0.1 @@ -1171,7 +1260,7 @@ importers: version: 2.0.85(patch_hash=f2077f4759520d1de69b164dfd8adca1a9ace9de667e35cb0e55e812ce2ac13b)(zod@4.3.4) '@ai-sdk/openai-compatible': specifier: 1.0.28 - version: 1.0.28(patch_hash=66f6605ef3f852d8f2b638a1d64b138eb8b2ad34ca6f331a0496c1d1379379c1)(zod@4.3.4) + version: 1.0.28(patch_hash=5ea49b4f07636a8e4630097e67e2787779ba7e933bd0459f81b1803cb125edda)(zod@4.3.4) '@ai-sdk/provider': specifier: ^2.0.0 version: 2.0.1 @@ -1334,12 +1423,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai-compatible@1.0.30': - resolution: {integrity: sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@2.0.85': resolution: {integrity: sha512-3pzr7qVhsOXwjPAfmvFNZz3sRWCuyMOc3GgLHe7sWY0t8J4hA5mwQ4LISTKYI3iIr8IXzAQn9MUrC8Hiji9RpA==} engines: {node: '>=18'} @@ -1459,11 +1542,11 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@anthropic-ai/claude-agent-sdk@0.1.62': - resolution: {integrity: sha512-KoJAQ0kdrbOukh4r0CFvFZgSKlAGAVJf8baeK2jpFCxbUhqr99Ier88v1L2iehWSWkXR6oVaThCYozN74Q3jUw==} + '@anthropic-ai/claude-agent-sdk@0.1.76': + resolution: {integrity: sha512-s7RvpXoFaLXLG7A1cJBAPD8ilwOhhc/12fb5mJXRuD561o4FmPtQ+WRfuy9akMmrFRfLsKv8Ornw3ClGAPL2fw==} engines: {node: '>=18.0.0'} peerDependencies: - zod: ^3.24.1 + zod: ^3.24.1 || ^4.0.0 '@anthropic-ai/sdk@0.27.3': resolution: {integrity: sha512-IjLt0gd3L4jlOfilxVXTifn42FnVffMgDC04RJK1KDZpmkBWLv0XC92MVVmkxrFZNS/7l3xWgP/I3nqtX1sQHw==} @@ -2678,6 +2761,11 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} + '@j178/prek@0.2.28': + resolution: {integrity: sha512-ACRTrlMmaBQvz+sERKOWffgj5UoRUuqL7Bxg/X6aqQd/4/i/gjuDZJVa+K+12/At5PNDYVODFbHtJ6aD2TnfzQ==} + engines: {node: '>=14', npm: '>=6'} + hasBin: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -3476,6 +3564,9 @@ packages: '@oxc-project/types@0.106.0': resolution: {integrity: sha512-QdsH3rZq480VnOHSHgPYOhjL8O8LBdcnSjM408BpPCCUc0JYYZPG9Gafl9i3OcGk/7137o+gweb4cCv3WAUykg==} + '@oxc-project/types@0.108.0': + resolution: {integrity: sha512-7lf13b2IA/kZO6xgnIZA88sq3vwrxWk+2vxf6cc+omwYCRTiA5e63Beqf3fz/v8jEviChWWmFYBwzfSeyrsj7Q==} + '@oxlint-tsgolint/darwin-arm64@0.2.1': resolution: {integrity: sha512-1cjcZkgpkWTfkFx111weVsvlnDG3+a7Y3qei+VCbr55LwKTHSzGq9tVNakIHRJ6NZfX7bQKYU4yef3p6AKoteg==} cpu: [arm64] @@ -3982,6 +4073,12 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.0.0-beta.60': + resolution: {integrity: sha512-hOW6iQXtpG4uCW1zGK56+KhEXGttSkTp2ykncW/nkOIF/jOKTqbM944Q73HVeMXP1mPRvE2cZwNp3xeLIeyIGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.0-beta.53': resolution: {integrity: sha512-yIsKqMz0CtRnVa6x3Pa+mzTihr4Ty+Z6HfPbZ7RVbk1Uxnco4+CUn7Qbm/5SBol1JD/7nvY8rphAgyAi7Lj6Vg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3994,6 +4091,12 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-beta.60': + resolution: {integrity: sha512-vyDA4HXY2mP8PPtl5UE17uGPxUNG4m1wkfa3kAkR8JWrFbarV97UmLq22IWrNhtBPa89xqerzLK8KoVmz5JqCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-beta.53': resolution: {integrity: sha512-GTXe+mxsCGUnJOFMhfGWmefP7Q9TpYUseHvhAhr21nCTgdS8jPsvirb0tJwM3lN0/u/cg7bpFNa16fQrjKrCjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4006,6 +4109,12 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-beta.60': + resolution: {integrity: sha512-WnxyqxAKP2BsxouwGY/RCF5UFw/LA4QOHhJ7VEl+UCelHokiwqNHRbryLAyRy3TE1FZ5eae+vAFcaetAu/kWLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-beta.53': resolution: {integrity: sha512-9Tmp7bBvKqyDkMcL4e089pH3RsjD3SUungjmqWtyhNOxoQMh0fSmINTyYV8KXtE+JkxYMPWvnEt+/mfpVCkk8w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4018,6 +4127,12 @@ packages: cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-beta.60': + resolution: {integrity: sha512-JtyWJ+zXOHof5gOUYwdTWI2kL6b8q9eNwqB/oD4mfUFaC/COEB2+47JMhcq78dey9Ahmec3DZKRDZPRh9hNAMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53': resolution: {integrity: sha512-a1y5fiB0iovuzdbjUxa7+Zcvgv+mTmlGGC4XydVIsyl48eoxgaYkA3l9079hyTyhECsPq+mbr0gVQsFU11OJAQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4030,6 +4145,12 @@ packages: cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.60': + resolution: {integrity: sha512-LrMoKqpHx+kCaNSk84iSBd4yVOymLIbxJQtvFjDN2CjQraownR+IXcwYDblFcj9ivmS54T3vCboXBbm3s1zbPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53': resolution: {integrity: sha512-bpIGX+ov9PhJYV+wHNXl9rzq4F0QvILiURn0y0oepbQx+7stmQsKA0DhPGwmhfvF856wq+gbM8L92SAa/CBcLg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4042,6 +4163,12 @@ packages: cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.60': + resolution: {integrity: sha512-sqI+Vdx1gmXJMsXN3Fsewm3wlt7RHvRs1uysSp//NLsCoh9ZFEUr4ZzGhWKOg6Rvf+njNu/vCsz96x7wssLejQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.53': resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4054,6 +4181,12 @@ packages: cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.60': + resolution: {integrity: sha512-8xlqGLDtTP8sBfYwneTDu8+PRm5reNEHAuI/+6WPy9y350ls0KTFd3EJCOWEXWGW0F35ko9Fn9azmurBTjqOrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.53': resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4066,6 +4199,12 @@ packages: cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.60': + resolution: {integrity: sha512-iR4nhVouVZK1CiGGGyz+prF5Lw9Lmz30Rl36Hajex+dFVFiegka604zBwzTp5Tl0BZnr50ztnVJ30tGrBhDr8Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.0-beta.53': resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4078,6 +4217,12 @@ packages: cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.0-beta.60': + resolution: {integrity: sha512-HbfNcqNeqxFjSMf1Kpe8itr2e2lr0Bm6HltD2qXtfU91bSSikVs9EWsa1ThshQ1v2ZvxXckGjlVLtah6IoslPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-openharmony-arm64@1.0.0-beta.53': resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4090,6 +4235,12 @@ packages: cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.0-beta.60': + resolution: {integrity: sha512-BiiamFcgTJ+ZFOUIMO9AHXUo9WXvHVwGfSrJ+Sv0AsTd2w3VN7dJGiH3WRcxKFetljJHWvGbM4fdpY5lf6RIvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0-beta.53': resolution: {integrity: sha512-BUjAEgpABEJXilGq/BPh7jeU3WAJ5o15c1ZEgHaDWSz3LB881LQZnbNJHmUiM4d1JQWMYYyR1Y490IBHi2FPJg==} engines: {node: '>=14.0.0'} @@ -4100,6 +4251,11 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-beta.60': + resolution: {integrity: sha512-6roXGbHMdR2ucnxXuwbmQvk8tuYl3VGu0yv13KxspyKBxxBd4RS6iykzLD6mX2gMUHhfX8SVWz7n/62gfyKHow==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53': resolution: {integrity: sha512-s27uU7tpCWSjHBnxyVXHt3rMrQdJq5MHNv3BzsewCIroIw3DJFjMH1dzCPPMUFxnh1r52Nf9IJ/eWp6LDoyGcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4112,6 +4268,12 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.60': + resolution: {integrity: sha512-JBOm8/DC/CKnHyMHoJFdvzVHxUixid4dGkiTqGflxOxO43uSJMpl77pSPXvzwZ/VXwqblU2V0/PanyCBcRLowQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.53': resolution: {integrity: sha512-cjWL/USPJ1g0en2htb4ssMjIycc36RvdQAx1WlXnS6DpULswiUTVXPDesTifSKYSyvx24E0YqQkEm0K/M2Z/AA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4124,6 +4286,12 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.60': + resolution: {integrity: sha512-MKF0B823Efp+Ot8KsbwIuGhKH58pf+2rSM6VcqyNMlNBHheOM0Gf7JmEu+toc1jgN6fqjH7Et+8hAzsLVkIGfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -4133,6 +4301,9 @@ packages: '@rolldown/pluginutils@1.0.0-beta.58': resolution: {integrity: sha512-qWhDs6yFGR5xDfdrwiSa3CWGIHxD597uGE/A9xGqytBjANvh4rLCTTkq7szhMV4+Ygh+PMS90KVJ8xWG/TkX4w==} + '@rolldown/pluginutils@1.0.0-beta.60': + resolution: {integrity: sha512-Jz4aqXRPVtqkH1E3jRDzLO5cgN5JwW+WG0wXGE4NiJd25nougv/AHzxmKCzmVQUYnxLmTM0M4wrZp+LlC2FKLg==} + '@rollup/rollup-linux-x64-gnu@4.45.1': resolution: {integrity: sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==} cpu: [x64] @@ -4406,6 +4577,11 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@strongtz/win32-arm64-msvc@0.4.7': + resolution: {integrity: sha512-LMU0CVfKGxKzIUawkDw6pwCNFb64DSwqEQhVAfEdLGMZcvVIdJghWB9yMW+72DHzALpwHscIssAJGhYAT4AbfA==} + cpu: [arm64] + os: [win32] + '@svta/common-media-library@0.17.4': resolution: {integrity: sha512-nP/KThzQW5FZKdc9V7ICTa9/A7xGw66VQoLPYOEwwMZTTrISp1zIQAX4KAYJw2PN/VPnxJQJXIYbzZTXgMHctw==} engines: {node: '>=20'} @@ -4873,6 +5049,9 @@ packages: resolution: {integrity: sha512-g7SP7beaxrjxLnW//vskra07a1jsJowqp07KMouxh4gCwaF+ItHbRZN8O+1dhJivBi3VdasT71BPyk+8wzEreQ==} engines: {node: '>=15'} + '@ts-morph/common@0.28.1': + resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -5654,10 +5833,6 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - ansi-escapes@7.2.0: - resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} - engines: {node: '>=18'} - ansi-regex@2.1.1: resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} engines: {node: '>=0.10.0'} @@ -5792,6 +5967,9 @@ packages: aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + axios-proxy-builder@0.1.2: + resolution: {integrity: sha512-6uBVsBZzkB3tCC8iyx59mCjQckhB8+GQrI9Cop8eC7ybIsvs/KtnNgEBfRMSEa7GqK2VBGUzgjNYMdPIfotyPA==} + axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} @@ -6027,10 +6205,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -6120,10 +6294,6 @@ packages: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - cli-progress@3.12.0: resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==} engines: {node: '>=4'} @@ -6136,10 +6306,6 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} - cli-truncate@4.0.0: - resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} - engines: {node: '>=18'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -6162,6 +6328,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + code-inspector-core@0.20.17: resolution: {integrity: sha512-vZkEfNNFhB5KeBMGSsM4NaPBjmaQfAEMM2iyqn9K1EVpm070RN/aFVu42fmPRGpLqyInj4z9yZozzybCjZ2CIA==} @@ -6214,9 +6383,6 @@ packages: resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} engines: {node: '>=18'} - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -6228,6 +6394,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -6286,6 +6456,10 @@ packages: console-table-printer@2.15.0: resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==} + console.table@0.10.0: + resolution: {integrity: sha512-dPyZofqggxuvSf7WXvNjuRfnsOk1YazkVP8FdxH4tcH2c37wc79/Yl6Bhr7Lsu00KMgy2ql/qCMuNu8xctZM8g==} + engines: {node: '> 0.10'} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -6929,6 +7103,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + easy-table@1.1.0: + resolution: {integrity: sha512-oq33hWOSSnl2Hoh00tZWaIPi1ievrD9aFG82/IgjlycAnW9hHx5PkJiXpxPsgEE+H7BsbVQXFVFST8TEXS6/pA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -7049,10 +7226,6 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - epub@1.3.0: resolution: {integrity: sha512-6BL8gIitljkTf4HW52Ast6wenPTkMKllU28bRc5awVsT+xCaPl6nWSaqSmHbRgPrl1+5uekOPvOxy7DQzbhM8Q==} @@ -7299,10 +7472,6 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} - exifremove@1.0.1: resolution: {integrity: sha512-a4VrNsSgpKBo9dHFsRi098XYm/X8dHP2NPA4N/3WAIxInQ8VHOfic/F8uAMIlWk4QRurTFwClFUb0QkMj4nqHg==} @@ -7653,10 +7822,6 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} @@ -7679,6 +7844,10 @@ packages: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} hasBin: true + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} deprecated: Glob versions prior to v9 are no longer supported @@ -7904,18 +8073,9 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} - humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} - husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} - hasBin: true - i18next@23.16.8: resolution: {integrity: sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==} @@ -8067,14 +8227,6 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-fullwidth-code-point@4.0.0: - resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} - engines: {node: '>=12'} - - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -8135,10 +8287,6 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-typed-array@1.1.15: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} @@ -8558,10 +8706,6 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - linguist-languages@8.2.0: resolution: {integrity: sha512-KCUUH9x97QWYU0SXOCGxUrZR6cSfuQrMhABB7L/0I8N0LXOeaKe7+RZs7FAwvWCV2qKfZ4Wv1luLq4OfMezSJg==} @@ -8571,15 +8715,6 @@ packages: linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} - lint-staged@15.5.2: - resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==} - engines: {node: '>=18.12.0'} - hasBin: true - - listr2@8.3.3: - resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} - engines: {node: '>=18.0.0'} - localforage@1.10.0: resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} @@ -8648,10 +8783,6 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} - logform@2.7.0: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} @@ -8871,9 +9002,6 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - mermaid@11.12.2: resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==} @@ -9019,10 +9147,6 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -9057,14 +9181,6 @@ packages: resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} engines: {node: '>=8'} - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -9374,10 +9490,6 @@ packages: npm-packlist@1.4.8: resolution: {integrity: sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==} - npm-run-path@5.3.0: - resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - npmlog@4.1.2: resolution: {integrity: sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==} deprecated: This package is no longer supported. @@ -9443,14 +9555,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - oniguruma-parser@0.12.1: resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} @@ -9635,6 +9739,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -9657,10 +9764,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - path-posix@1.0.0: resolution: {integrity: sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==} @@ -9668,6 +9771,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -9721,11 +9828,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pidtree@0.6.0: - resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} - engines: {node: '>=0.10'} - hasBin: true - pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -10523,10 +10625,6 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - retry-axios@2.6.0: resolution: {integrity: sha512-pOLi+Gdll3JekwuFjXO3fTq+L9lzMQGcSq7M5gIjExcl3Gu1hd4XXuf5o3+LuSBsaULQH7DiNbsqPd1chVpQGQ==} engines: {node: '>=10.7.0'} @@ -10544,9 +10642,6 @@ packages: rettime@0.7.0: resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==} - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -10566,6 +10661,11 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true + rimraf@6.1.2: + resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==} + engines: {node: 20 || >=22} + hasBin: true + roarr@2.15.4: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} @@ -10655,6 +10755,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.0.0-beta.60: + resolution: {integrity: sha512-YYgpv7MiTp9LdLj1fzGzCtij8Yi2OKEc3HQtfbIxW4yuSgpQz9518I69U72T5ErPA/ATOXqlcisiLrWy+5V9YA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup-plugin-visualizer@5.14.0: resolution: {integrity: sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==} engines: {node: '>=18'} @@ -10868,14 +10973,6 @@ packages: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} - slice-ansi@5.0.0: - resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} - engines: {node: '>=12'} - - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -10957,10 +11054,6 @@ packages: strict-url-sanitise@0.0.1: resolution: {integrity: sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg==} - string-argv@0.3.2: - resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} - engines: {node: '>=0.6.19'} - string-convert@0.2.1: resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} @@ -11011,10 +11104,6 @@ packages: strip-dirs@2.1.0: resolution: {integrity: sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==} - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -11141,14 +11230,17 @@ packages: tar@4.4.19: resolution: {integrity: sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==} engines: {node: '>=4.5'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tar@7.5.2: resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} @@ -11346,6 +11438,9 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} + ts-morph@27.0.2: + resolution: {integrity: sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==} + ts-pattern@5.9.0: resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} @@ -11410,6 +11505,10 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + turndown-plugin-gfm@1.0.2: resolution: {integrity: sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==} @@ -11891,10 +11990,6 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -12159,7 +12254,7 @@ snapshots: '@ai-sdk/cerebras@1.0.34(zod@4.3.4)': dependencies: - '@ai-sdk/openai-compatible': 1.0.30(zod@4.3.4) + '@ai-sdk/openai-compatible': 1.0.28(patch_hash=5ea49b4f07636a8e4630097e67e2787779ba7e933bd0459f81b1803cb125edda)(zod@4.3.4) '@ai-sdk/provider': 2.0.1 '@ai-sdk/provider-utils': 3.0.20(zod@4.3.4) zod: 4.3.4 @@ -12215,7 +12310,7 @@ snapshots: '@ai-sdk/huggingface@0.0.10(zod@4.3.4)': dependencies: - '@ai-sdk/openai-compatible': 1.0.28(patch_hash=66f6605ef3f852d8f2b638a1d64b138eb8b2ad34ca6f331a0496c1d1379379c1)(zod@4.3.4) + '@ai-sdk/openai-compatible': 1.0.28(patch_hash=5ea49b4f07636a8e4630097e67e2787779ba7e933bd0459f81b1803cb125edda)(zod@4.3.4) '@ai-sdk/provider': 2.0.0 '@ai-sdk/provider-utils': 3.0.17(zod@4.3.4) zod: 4.3.4 @@ -12226,24 +12321,18 @@ snapshots: '@ai-sdk/provider-utils': 3.0.20(zod@4.3.4) zod: 4.3.4 - '@ai-sdk/openai-compatible@1.0.28(patch_hash=66f6605ef3f852d8f2b638a1d64b138eb8b2ad34ca6f331a0496c1d1379379c1)(zod@4.3.4)': + '@ai-sdk/openai-compatible@1.0.28(patch_hash=5ea49b4f07636a8e4630097e67e2787779ba7e933bd0459f81b1803cb125edda)(zod@4.3.4)': dependencies: '@ai-sdk/provider': 2.0.0 '@ai-sdk/provider-utils': 3.0.18(zod@4.3.4) zod: 4.3.4 - '@ai-sdk/openai-compatible@1.0.28(patch_hash=66f6605ef3f852d8f2b638a1d64b138eb8b2ad34ca6f331a0496c1d1379379c1)(zod@4.3.5)': + '@ai-sdk/openai-compatible@1.0.28(patch_hash=5ea49b4f07636a8e4630097e67e2787779ba7e933bd0459f81b1803cb125edda)(zod@4.3.5)': dependencies: '@ai-sdk/provider': 2.0.0 '@ai-sdk/provider-utils': 3.0.18(zod@4.3.5) zod: 4.3.5 - '@ai-sdk/openai-compatible@1.0.30(zod@4.3.4)': - dependencies: - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@4.3.4) - zod: 4.3.4 - '@ai-sdk/openai@2.0.85(patch_hash=f2077f4759520d1de69b164dfd8adca1a9ace9de667e35cb0e55e812ce2ac13b)(zod@4.3.4)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -12338,14 +12427,14 @@ snapshots: '@ai-sdk/xai@2.0.36(zod@4.3.4)': dependencies: - '@ai-sdk/openai-compatible': 1.0.28(patch_hash=66f6605ef3f852d8f2b638a1d64b138eb8b2ad34ca6f331a0496c1d1379379c1)(zod@4.3.4) + '@ai-sdk/openai-compatible': 1.0.28(patch_hash=5ea49b4f07636a8e4630097e67e2787779ba7e933bd0459f81b1803cb125edda)(zod@4.3.4) '@ai-sdk/provider': 2.0.0 '@ai-sdk/provider-utils': 3.0.17(zod@4.3.4) zod: 4.3.4 '@ai-sdk/xai@2.0.43(zod@4.3.4)': dependencies: - '@ai-sdk/openai-compatible': 1.0.30(zod@4.3.4) + '@ai-sdk/openai-compatible': 1.0.28(patch_hash=5ea49b4f07636a8e4630097e67e2787779ba7e933bd0459f81b1803cb125edda)(zod@4.3.4) '@ai-sdk/provider': 2.0.1 '@ai-sdk/provider-utils': 3.0.20(zod@4.3.4) zod: 4.3.4 @@ -12415,7 +12504,7 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@anthropic-ai/claude-agent-sdk@0.1.62(patch_hash=61ed4549b423c717cbfef526e3ed5b0329c2de2de88c9ef772188668f7dc26e8)(zod@4.3.4)': + '@anthropic-ai/claude-agent-sdk@0.1.76(patch_hash=e063a8ede82d78f452f7f1290b9d4d9323866159b5624679163caa8edd4928d5)(zod@4.3.4)': dependencies: zod: 4.3.4 optionalDependencies: @@ -14525,6 +14614,16 @@ snapshots: '@istanbuljs/schema@0.1.3': {} + '@j178/prek@0.2.28': + dependencies: + axios: 1.13.2(debug@4.4.3) + axios-proxy-builder: 0.1.2 + console.table: 0.10.0 + detect-libc: 2.1.2 + rimraf: 6.1.2 + transitivePeerDependencies: + - debug + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -14998,7 +15097,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.117(zod@4.3.4))(zod@4.3.4)': + '@openrouter/ai-sdk-provider@1.5.4(patch_hash=508e8e662b8547de93410cb7c3b1336077f34c6bf79c520ef5273962ea777c52)(ai@5.0.117(zod@4.3.4))(zod@4.3.4)': dependencies: '@openrouter/sdk': 0.1.27 ai: 5.0.117(zod@4.3.4) @@ -15115,7 +15214,7 @@ snapshots: '@opeoginni/github-copilot-openai-compatible@0.1.22(zod@4.3.4)': dependencies: '@ai-sdk/openai': 2.0.85(patch_hash=f2077f4759520d1de69b164dfd8adca1a9ace9de667e35cb0e55e812ce2ac13b)(zod@4.3.4) - '@ai-sdk/openai-compatible': 1.0.28(patch_hash=66f6605ef3f852d8f2b638a1d64b138eb8b2ad34ca6f331a0496c1d1379379c1)(zod@4.3.4) + '@ai-sdk/openai-compatible': 1.0.28(patch_hash=5ea49b4f07636a8e4630097e67e2787779ba7e933bd0459f81b1803cb125edda)(zod@4.3.4) '@ai-sdk/provider': 2.1.0-beta.5 '@ai-sdk/provider-utils': 3.0.20(zod@4.3.4) transitivePeerDependencies: @@ -15127,6 +15226,8 @@ snapshots: '@oxc-project/types@0.106.0': {} + '@oxc-project/types@0.108.0': {} + '@oxlint-tsgolint/darwin-arm64@0.2.1': optional: true @@ -15576,60 +15677,90 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-beta.58': optional: true + '@rolldown/binding-android-arm64@1.0.0-beta.60': + optional: true + '@rolldown/binding-darwin-arm64@1.0.0-beta.53': optional: true '@rolldown/binding-darwin-arm64@1.0.0-beta.58': optional: true + '@rolldown/binding-darwin-arm64@1.0.0-beta.60': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-beta.53': optional: true '@rolldown/binding-darwin-x64@1.0.0-beta.58': optional: true + '@rolldown/binding-darwin-x64@1.0.0-beta.60': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-beta.53': optional: true '@rolldown/binding-freebsd-x64@1.0.0-beta.58': optional: true + '@rolldown/binding-freebsd-x64@1.0.0-beta.60': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.58': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.60': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.58': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.60': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.53': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-beta.58': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.60': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.53': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-beta.58': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.60': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-beta.53': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-beta.58': optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-beta.60': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-beta.53': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-beta.58': optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-beta.60': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-beta.53': dependencies: '@napi-rs/wasm-runtime': 1.1.1 @@ -15640,24 +15771,37 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.1 optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-beta.60': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53': optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.58': optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.60': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.53': optional: true '@rolldown/binding-win32-x64-msvc@1.0.0-beta.58': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.60': + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.53': {} '@rolldown/pluginutils@1.0.0-beta.58': {} + '@rolldown/pluginutils@1.0.0-beta.60': {} + '@rollup/rollup-linux-x64-gnu@4.45.1': optional: true @@ -16057,6 +16201,9 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@strongtz/win32-arm64-msvc@0.4.7': + optional: true + '@svta/common-media-library@0.17.4': {} '@swc/core-darwin-arm64@1.15.8': @@ -16505,6 +16652,12 @@ snapshots: - encoding - supports-color + '@ts-morph/common@0.28.1': + dependencies: + minimatch: 10.1.1 + path-browserify: 1.0.1 + tinyglobby: 0.2.15 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -17752,10 +17905,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-escapes@7.2.0: - dependencies: - environment: 1.1.0 - ansi-regex@2.1.1: {} ansi-regex@5.0.1: {} @@ -17969,6 +18118,10 @@ snapshots: aws4fetch@1.0.20: {} + axios-proxy-builder@0.1.2: + dependencies: + tunnel: 0.0.6 + axios@1.13.2(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) @@ -18242,8 +18395,6 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chalk@5.6.2: {} - character-entities-html4@2.1.0: {} character-entities-legacy@1.1.4: {} @@ -18339,10 +18490,6 @@ snapshots: dependencies: restore-cursor: 3.1.0 - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - cli-progress@3.12.0: dependencies: string-width: 4.2.3 @@ -18355,11 +18502,6 @@ snapshots: string-width: 4.2.3 optional: true - cli-truncate@4.0.0: - dependencies: - slice-ansi: 5.0.0 - string-width: 7.2.0 - cli-width@4.1.0: {} cliui@8.0.1: @@ -18378,6 +18520,8 @@ snapshots: clsx@2.1.1: {} + code-block-writer@13.0.3: {} + code-inspector-core@0.20.17: dependencies: '@vue/compiler-dom': 3.5.26 @@ -18452,8 +18596,6 @@ snapshots: color-convert: 3.1.3 color-string: 2.1.4 - colorette@2.0.20: {} - combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -18462,6 +18604,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.2: {} + commander@2.20.3: {} commander@5.1.0: {} @@ -18521,6 +18665,10 @@ snapshots: dependencies: simple-wcswidth: 1.1.2 + console.table@0.10.0: + dependencies: + easy-table: 1.1.0 + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -19112,6 +19260,10 @@ snapshots: eastasianwidth@0.2.0: {} + easy-table@1.1.0: + optionalDependencies: + wcwidth: 1.0.1 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -19275,8 +19427,6 @@ snapshots: env-paths@2.2.1: {} - environment@1.1.0: {} - epub@1.3.0(patch_hash=e41b8d009235d2472b3f0d6291cbdc1d45a2fcf27348ad97f98fd4343d03e8d2): dependencies: adm-zip: 0.4.16 @@ -19624,18 +19774,6 @@ snapshots: dependencies: eventsource-parser: 3.0.6 - execa@8.0.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.3.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 - exifremove@1.0.1: {} expand-template@2.0.3: {} @@ -20046,8 +20184,6 @@ snapshots: get-stream@6.0.1: {} - get-stream@8.0.1: {} - get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -20079,6 +20215,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.0: + dependencies: + minimatch: 10.1.1 + minipass: 7.1.2 + path-scurry: 2.0.1 + glob@7.1.6: dependencies: fs.realpath: 1.0.0 @@ -20451,14 +20593,10 @@ snapshots: transitivePeerDependencies: - supports-color - human-signals@5.0.0: {} - humanize-ms@1.2.1: dependencies: ms: 2.1.3 - husky@9.1.7: {} - i18next@23.16.8: dependencies: '@babel/runtime': 7.28.4 @@ -20595,12 +20733,6 @@ snapshots: is-fullwidth-code-point@3.0.0: {} - is-fullwidth-code-point@4.0.0: {} - - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.4.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -20643,8 +20775,6 @@ snapshots: is-stream@2.0.1: {} - is-stream@3.0.0: {} - is-typed-array@1.1.15: dependencies: which-typed-array: 1.1.19 @@ -21027,8 +21157,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 - lilconfig@3.1.3: {} - linguist-languages@8.2.0: {} linkify-it@5.0.0: @@ -21037,30 +21165,6 @@ snapshots: linkifyjs@4.3.2: {} - lint-staged@15.5.2: - dependencies: - chalk: 5.6.2 - commander: 13.1.0 - debug: 4.4.3 - execa: 8.0.1 - lilconfig: 3.1.3 - listr2: 8.3.3 - micromatch: 4.0.8 - pidtree: 0.6.0 - string-argv: 0.3.2 - yaml: 2.8.2 - transitivePeerDependencies: - - supports-color - - listr2@8.3.3: - dependencies: - cli-truncate: 4.0.0 - colorette: 2.0.20 - eventemitter3: 5.0.1 - log-update: 6.1.0 - rfdc: 1.4.1 - wrap-ansi: 9.0.2 - localforage@1.10.0: dependencies: lie: 3.1.1 @@ -21113,14 +21217,6 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 - log-update@6.1.0: - dependencies: - ansi-escapes: 7.2.0 - cli-cursor: 5.0.0 - slice-ansi: 7.1.2 - strip-ansi: 7.1.2 - wrap-ansi: 9.0.2 - logform@2.7.0: dependencies: '@colors/colors': 1.6.0 @@ -21511,8 +21607,6 @@ snapshots: merge-descriptors@2.0.0: {} - merge-stream@2.0.0: {} - mermaid@11.12.2: dependencies: '@braintree/sanitize-url': 7.1.1 @@ -21844,11 +21938,6 @@ snapshots: transitivePeerDependencies: - supports-color - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -21869,10 +21958,6 @@ snapshots: mimic-fn@3.1.0: {} - mimic-fn@4.0.0: {} - - mimic-function@5.0.1: {} - mimic-response@1.0.1: {} mimic-response@2.1.0: {} @@ -22196,10 +22281,6 @@ snapshots: npm-normalize-package-bin: 1.0.1 optional: true - npm-run-path@5.3.0: - dependencies: - path-key: 4.0.0 - npmlog@4.1.2: dependencies: are-we-there-yet: 1.1.7 @@ -22274,14 +22355,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 - oniguruma-parser@0.12.1: {} oniguruma-to-es@4.3.4: @@ -22490,6 +22563,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + path-browserify@1.0.1: {} path-data-parser@0.1.0: {} @@ -22502,8 +22577,6 @@ snapshots: path-key@3.1.1: {} - path-key@4.0.0: {} - path-posix@1.0.0: {} path-scurry@1.11.1: @@ -22511,6 +22584,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.1: + dependencies: + lru-cache: 11.2.4 + minipass: 7.1.2 + path-to-regexp@6.3.0: {} path-to-regexp@8.3.0: {} @@ -22553,8 +22631,6 @@ snapshots: picomatch@4.0.3: {} - pidtree@0.6.0: {} - pify@2.3.0: {} pify@3.0.0: {} @@ -23615,11 +23691,6 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - retry-axios@2.6.0(axios@1.13.2): dependencies: axios: 1.13.2(debug@4.4.3) @@ -23630,8 +23701,6 @@ snapshots: rettime@0.7.0: {} - rfdc@1.4.1: {} - rimraf@2.6.3: dependencies: glob: 7.2.3 @@ -23649,6 +23718,11 @@ snapshots: dependencies: glob: 10.5.0 + rimraf@6.1.2: + dependencies: + glob: 13.0.0 + package-json-from-dist: 1.0.1 + roarr@2.15.4: dependencies: boolean: 3.2.0 @@ -23679,7 +23753,7 @@ snapshots: - oxc-resolver - supports-color - rolldown-plugin-dts@0.15.10(@typescript/native-preview@7.0.0-dev.20260104.1)(rolldown@1.0.0-beta.58)(typescript@5.8.3): + rolldown-plugin-dts@0.15.10(@typescript/native-preview@7.0.0-dev.20260104.1)(rolldown@1.0.0-beta.60)(typescript@5.8.3): dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 @@ -23689,7 +23763,7 @@ snapshots: debug: 4.4.3 dts-resolver: 2.1.3 get-tsconfig: 4.13.0 - rolldown: 1.0.0-beta.58 + rolldown: 1.0.0-beta.60 optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260104.1 typescript: 5.8.3 @@ -23697,7 +23771,7 @@ snapshots: - oxc-resolver - supports-color - rolldown-plugin-dts@0.15.10(@typescript/native-preview@7.0.0-dev.20260104.1)(rolldown@1.0.0-beta.58)(typescript@5.9.2): + rolldown-plugin-dts@0.15.10(@typescript/native-preview@7.0.0-dev.20260104.1)(rolldown@1.0.0-beta.60)(typescript@5.9.2): dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 @@ -23707,7 +23781,7 @@ snapshots: debug: 4.4.3 dts-resolver: 2.1.3 get-tsconfig: 4.13.0 - rolldown: 1.0.0-beta.58 + rolldown: 1.0.0-beta.60 optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260104.1 typescript: 5.9.2 @@ -23787,6 +23861,25 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.58 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.58 + rolldown@1.0.0-beta.60: + dependencies: + '@oxc-project/types': 0.108.0 + '@rolldown/pluginutils': 1.0.0-beta.60 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.60 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.60 + '@rolldown/binding-darwin-x64': 1.0.0-beta.60 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.60 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.60 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.60 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.60 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.60 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.60 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.60 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.60 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.60 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.60 + rollup-plugin-visualizer@5.14.0: dependencies: open: 8.4.2 @@ -24055,16 +24148,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 optional: true - slice-ansi@5.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 4.0.0 - - slice-ansi@7.1.2: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - smart-buffer@4.2.0: {} socks-proxy-agent@8.0.5: @@ -24143,8 +24226,6 @@ snapshots: strict-url-sanitise@0.0.1: {} - string-argv@0.3.2: {} - string-convert@0.2.1: {} string-ts@2.3.1: {} @@ -24204,8 +24285,6 @@ snapshots: dependencies: is-natural-number: 4.0.1 - strip-final-newline@3.0.0: {} - strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -24569,6 +24648,11 @@ snapshots: ts-dedent@2.2.0: {} + ts-morph@27.0.2: + dependencies: + '@ts-morph/common': 0.28.1 + code-block-writer: 13.0.3 + ts-pattern@5.9.0: {} tsdown@0.12.9(@typescript/native-preview@7.0.0-dev.20260104.1)(typescript@5.8.3): @@ -24603,8 +24687,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.58 - rolldown-plugin-dts: 0.15.10(@typescript/native-preview@7.0.0-dev.20260104.1)(rolldown@1.0.0-beta.58)(typescript@5.8.3) + rolldown: 1.0.0-beta.60 + rolldown-plugin-dts: 0.15.10(@typescript/native-preview@7.0.0-dev.20260104.1)(rolldown@1.0.0-beta.60)(typescript@5.8.3) semver: 7.7.3 tinyexec: 1.0.2 tinyglobby: 0.2.15 @@ -24627,8 +24711,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.58 - rolldown-plugin-dts: 0.15.10(@typescript/native-preview@7.0.0-dev.20260104.1)(rolldown@1.0.0-beta.58)(typescript@5.9.2) + rolldown: 1.0.0-beta.60 + rolldown-plugin-dts: 0.15.10(@typescript/native-preview@7.0.0-dev.20260104.1)(rolldown@1.0.0-beta.60)(typescript@5.9.2) semver: 7.7.3 tinyexec: 1.0.2 tinyglobby: 0.2.15 @@ -24659,6 +24743,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + tunnel@0.0.6: {} + turndown-plugin-gfm@1.0.2: {} turndown@7.2.0: @@ -25268,12 +25354,6 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.1.2 - wrappy@1.0.2: {} ws@8.18.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 18ec407efc..486c912434 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,8 @@ packages: - 'packages/*' + +supportedArchitectures: + os: + - current + cpu: + - current diff --git a/scripts/__tests__/check-hardcoded-strings.test.ts b/scripts/__tests__/check-hardcoded-strings.test.ts new file mode 100644 index 0000000000..8a7fb0302d --- /dev/null +++ b/scripts/__tests__/check-hardcoded-strings.test.ts @@ -0,0 +1,360 @@ +import * as path from 'path' +import { Node, Project } from 'ts-morph' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + HardcodedStringDetector, + hasCJK, + hasEnglishUIText, + isInCodeContext, + isNonUIString, + shouldSkipNode +} from '../check-hardcoded-strings' + +function createTestProject() { + return new Project({ + skipAddingFilesFromTsConfig: true, + skipFileDependencyResolution: true, + compilerOptions: { jsx: 2 } // React JSX + }) +} + +function findStringLiteral(project: Project, code: string, targetString: string): Node | undefined { + const sourceFile = project.createSourceFile('test.tsx', code, { overwrite: true }) + let found: Node | undefined + sourceFile.forEachDescendant((node) => { + if (Node.isStringLiteral(node) && node.getLiteralValue() === targetString) { + found = node + } + }) + return found +} + +function findTemplateLiteral(project: Project, code: string): Node | undefined { + const sourceFile = project.createSourceFile('test.tsx', code, { overwrite: true }) + let found: Node | undefined + sourceFile.forEachDescendant((node) => { + if (Node.isNoSubstitutionTemplateLiteral(node) || Node.isTemplateExpression(node)) { + found = node + } + }) + return found +} + +// Mock fs module +vi.mock('fs') + +describe('check-hardcoded-strings', () => { + const mockSrcDir = '/mock/src/renderer/src' + + beforeEach(() => { + vi.resetAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('hasCJK', () => { + it('should detect Chinese characters', () => { + expect(hasCJK('测试文本')).toBe(true) + expect(hasCJK('Hello 世界')).toBe(true) + expect(hasCJK('中文')).toBe(true) + }) + + it('should detect Japanese characters', () => { + expect(hasCJK('こんにちは')).toBe(true) // Hiragana + expect(hasCJK('カタカナ')).toBe(true) // Katakana + expect(hasCJK('日本語')).toBe(true) // Kanji + }) + + it('should detect Korean characters', () => { + expect(hasCJK('한국어')).toBe(true) // Hangul + expect(hasCJK('안녕하세요')).toBe(true) + }) + + it('should return false for non-CJK text', () => { + expect(hasCJK('Hello World')).toBe(false) + expect(hasCJK('12345')).toBe(false) + expect(hasCJK('')).toBe(false) + }) + }) + + describe('hasEnglishUIText', () => { + it('should detect English UI text patterns', () => { + expect(hasEnglishUIText('Create New File')).toBe(true) + expect(hasEnglishUIText('Save As')).toBe(true) + expect(hasEnglishUIText('Open Project')).toBe(true) + }) + + it('should reject single words', () => { + expect(hasEnglishUIText('Save')).toBe(false) + expect(hasEnglishUIText('Cancel')).toBe(false) + }) + + it('should reject lowercase text', () => { + expect(hasEnglishUIText('create new file')).toBe(false) + expect(hasEnglishUIText('save as')).toBe(false) + }) + + it('should reject too long phrases', () => { + expect(hasEnglishUIText('This Is A Very Long Phrase With Many Words')).toBe(false) + }) + }) + + describe('isNonUIString', () => { + it('should identify empty strings', () => { + expect(isNonUIString('')).toBe(true) + }) + + it('should identify pure numbers', () => { + expect(isNonUIString('123')).toBe(true) + expect(isNonUIString('0')).toBe(true) + expect(isNonUIString('999')).toBe(true) + }) + + it('should not mark regular UI text as non-UI', () => { + expect(isNonUIString('Hello World')).toBe(false) + expect(isNonUIString('Save')).toBe(false) + expect(isNonUIString('确认')).toBe(false) + expect(isNonUIString('请输入内容')).toBe(false) + expect(isNonUIString('-')).toBe(false) // Even short strings may be UI in specific contexts + }) + + it('should not filter technical strings (now handled by AST context)', () => { + // With AST-based detection, these are no longer filtered + // because we only check specific UI contexts where they rarely appear + expect(isNonUIString('./path/to/file')).toBe(false) + expect(isNonUIString('https://example.com')).toBe(false) + expect(isNonUIString('#fff')).toBe(false) + expect(isNonUIString('snake_case_id')).toBe(false) + }) + }) + + describe('File filtering', () => { + const IGNORED_DIRS = ['__tests__', 'node_modules', 'i18n', 'locales', 'types', 'assets'] + const IGNORED_FILES = ['*.test.ts', '*.test.tsx', '*.d.ts'] + + const mockShouldSkipFile = (filePath: string): boolean => { + const relativePath = filePath.replace(mockSrcDir + '/', '') + + if (IGNORED_DIRS.some((dir) => relativePath.includes(dir))) { + return true + } + + const fileName = path.basename(filePath) + if ( + IGNORED_FILES.some((pattern) => { + const regex = new RegExp(pattern.replace('*', '.*')) + return regex.test(fileName) + }) + ) { + return true + } + + return false + } + + it('should skip test files', () => { + expect(mockShouldSkipFile(`${mockSrcDir}/components/Button.test.tsx`)).toBe(true) + expect(mockShouldSkipFile(`${mockSrcDir}/utils/helper.test.ts`)).toBe(true) + }) + + it('should skip type definition files', () => { + expect(mockShouldSkipFile(`${mockSrcDir}/types/index.d.ts`)).toBe(true) + }) + + it('should skip i18n/locales directories', () => { + expect(mockShouldSkipFile(`${mockSrcDir}/i18n/locales/en-us.json`)).toBe(true) + expect(mockShouldSkipFile(`${mockSrcDir}/locales/zh-cn.json`)).toBe(true) + }) + + it('should skip __tests__ directories', () => { + expect(mockShouldSkipFile(`${mockSrcDir}/components/__tests__/Button.test.tsx`)).toBe(true) + }) + + it('should NOT skip regular component files', () => { + expect(mockShouldSkipFile(`${mockSrcDir}/components/Button.tsx`)).toBe(false) + expect(mockShouldSkipFile(`${mockSrcDir}/pages/Home.tsx`)).toBe(false) + }) + + it('should NOT skip regular TypeScript files', () => { + expect(mockShouldSkipFile(`${mockSrcDir}/utils/helper.ts`)).toBe(false) + }) + }) + + describe('HardcodedStringDetector', () => { + // These are integration tests that would require actual files + // For unit testing, we test the exported utility functions instead + + it('should be instantiable', () => { + const detector = new HardcodedStringDetector() + expect(detector).toBeDefined() + }) + }) + + describe('Legacy pattern compatibility (regex patterns for reference)', () => { + // Keep legacy pattern tests for backward compatibility reference + const CHINESE_PATTERNS = [ + { regex: />([^<]*[\u4e00-\u9fff][^<]*) { + const testLine = '测试文本' + const matches = testLine.match(CHINESE_PATTERNS[0].regex) + expect(matches).not.toBeNull() + }) + + it('should detect Chinese characters in placeholder attribute (regex)', () => { + const testLine = 'placeholder="请输入内容"' + const matches = testLine.match(CHINESE_PATTERNS[1].regex) + expect(matches).not.toBeNull() + }) + + it('should detect Chinese characters in title attribute (regex)', () => { + const testLine = 'title="提示信息"' + const matches = testLine.match(CHINESE_PATTERNS[1].regex) + expect(matches).not.toBeNull() + }) + }) + + describe('shouldSkipNode', () => { + let project: Project + + beforeEach(() => { + project = createTestProject() + }) + + it('should skip import declarations', () => { + const node = findStringLiteral(project, `import { foo } from 'some-module'`, 'some-module') + expect(node).toBeDefined() + expect(shouldSkipNode(node!)).toBe(true) + }) + + it('should skip export declarations', () => { + const node = findStringLiteral(project, `export { foo } from 'some-module'`, 'some-module') + expect(node).toBeDefined() + expect(shouldSkipNode(node!)).toBe(true) + }) + + it('should skip logger calls', () => { + const node = findStringLiteral(project, `logger.info('测试日志')`, '测试日志') + expect(node).toBeDefined() + expect(shouldSkipNode(node!)).toBe(true) + }) + + it('should skip console calls', () => { + const node = findStringLiteral(project, `console.log('测试日志')`, '测试日志') + expect(node).toBeDefined() + expect(shouldSkipNode(node!)).toBe(true) + }) + + it('should skip t() translation function calls', () => { + const node = findStringLiteral(project, `t('common.save')`, 'common.save') + expect(node).toBeDefined() + expect(shouldSkipNode(node!)).toBe(true) + }) + + it('should skip type alias declarations', () => { + const node = findStringLiteral(project, `type Status = '成功' | '失败'`, '成功') + expect(node).toBeDefined() + expect(shouldSkipNode(node!)).toBe(true) + }) + + it('should skip interface declarations', () => { + const node = findStringLiteral(project, `interface Foo { status: '成功' }`, '成功') + expect(node).toBeDefined() + expect(shouldSkipNode(node!)).toBe(true) + }) + + it('should skip enum members', () => { + const node = findStringLiteral(project, `enum Status { Success = '成功' }`, '成功') + expect(node).toBeDefined() + expect(shouldSkipNode(node!)).toBe(true) + }) + + it('should skip language/locale variable declarations', () => { + const node = findStringLiteral(project, `const languageOptions = ['中文', 'English']`, '中文') + expect(node).toBeDefined() + expect(shouldSkipNode(node!)).toBe(true) + }) + + it('should NOT skip regular string literals', () => { + const node = findStringLiteral(project, `const message = '测试消息'`, '测试消息') + expect(node).toBeDefined() + expect(shouldSkipNode(node!)).toBe(false) + }) + }) + + describe('isInCodeContext', () => { + let project: Project + + beforeEach(() => { + project = createTestProject() + }) + + it('should detect tagged template expressions with css tag', () => { + const node = findTemplateLiteral(project, 'const style = css`color: red;`') + expect(node).toBeDefined() + expect(isInCodeContext(node!)).toBe(true) + }) + + it('should detect tagged template expressions with styled tag', () => { + const node = findTemplateLiteral(project, 'const Button = styled.button`padding: 10px;`') + expect(node).toBeDefined() + expect(isInCodeContext(node!)).toBe(true) + }) + + it('should detect CSS variable names', () => { + const node = findStringLiteral(project, `const customStyle = 'color: blue'`, 'color: blue') + expect(node).toBeDefined() + expect(isInCodeContext(node!)).toBe(true) + }) + + it('should detect code variable names', () => { + const node = findStringLiteral(project, `const pythonCode = 'print("hello")'`, 'print("hello")') + expect(node).toBeDefined() + expect(isInCodeContext(node!)).toBe(true) + }) + + it('should detect CSS property assignments', () => { + const node = findStringLiteral(project, `const obj = { style: 'color: red' }`, 'color: red') + expect(node).toBeDefined() + expect(isInCodeContext(node!)).toBe(true) + }) + + it('should detect code property assignments', () => { + const node = findStringLiteral(project, `const obj = { script: 'console.log(1)' }`, 'console.log(1)') + expect(node).toBeDefined() + expect(isInCodeContext(node!)).toBe(true) + }) + + it('should detect JSX style attributes', () => { + const node = findStringLiteral(project, `
`, 'color: red') + expect(node).toBeDefined() + expect(isInCodeContext(node!)).toBe(true) + }) + + it('should detect executeJavaScript calls', () => { + const node = findStringLiteral(project, `webview.executeJavaScript('document.title')`, 'document.title') + expect(node).toBeDefined() + expect(isInCodeContext(node!)).toBe(true) + }) + + it('should detect executeJavaScript with string concatenation', () => { + const node = findStringLiteral(project, `webview.executeJavaScript('var x = ' + value + ';')`, 'var x = ') + expect(node).toBeDefined() + expect(isInCodeContext(node!)).toBe(true) + }) + + it('should NOT detect regular strings', () => { + const node = findStringLiteral(project, `const message = '普通消息'`, '普通消息') + expect(node).toBeDefined() + expect(isInCodeContext(node!)).toBe(false) + }) + }) +}) diff --git a/scripts/before-pack.js b/scripts/before-pack.js index 0793096645..dcbcd32009 100644 --- a/scripts/before-pack.js +++ b/scripts/before-pack.js @@ -1,105 +1,126 @@ const { Arch } = require('electron-builder') -const { downloadNpmPackage } = require('./utils') +const { execSync } = require('child_process') +const fs = require('fs') +const path = require('path') +const yaml = require('js-yaml') + +const workspaceConfigPath = path.join(__dirname, '..', 'pnpm-workspace.yaml') // if you want to add new prebuild binaries packages with different architectures, you can add them here // please add to allX64 and allArm64 from pnpm-lock.yaml -const allArm64 = { - '@img/sharp-darwin-arm64': '0.34.3', - '@img/sharp-win32-arm64': '0.34.3', - '@img/sharp-linux-arm64': '0.34.3', - - '@img/sharp-libvips-darwin-arm64': '1.2.4', - '@img/sharp-libvips-linux-arm64': '1.2.4', - - '@libsql/darwin-arm64': '0.4.7', - '@libsql/linux-arm64-gnu': '0.4.7', - '@strongtz/win32-arm64-msvc': '0.4.7', - - '@napi-rs/system-ocr-darwin-arm64': '1.0.2', - '@napi-rs/system-ocr-win32-arm64-msvc': '1.0.2' -} - -const allX64 = { - '@img/sharp-darwin-x64': '0.34.3', - '@img/sharp-linux-x64': '0.34.3', - '@img/sharp-win32-x64': '0.34.3', - - '@img/sharp-libvips-darwin-x64': '1.2.4', - '@img/sharp-libvips-linux-x64': '1.2.4', - - '@libsql/darwin-x64': '0.4.7', - '@libsql/linux-x64-gnu': '0.4.7', - '@libsql/win32-x64-msvc': '0.4.7', - - '@napi-rs/system-ocr-darwin-x64': '1.0.2', - '@napi-rs/system-ocr-win32-x64-msvc': '1.0.2' -} - -const claudeCodeVenderPath = '@anthropic-ai/claude-agent-sdk/vendor' -const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32'] +const packages = [ + '@img/sharp-darwin-arm64', + '@img/sharp-darwin-x64', + '@img/sharp-libvips-darwin-arm64', + '@img/sharp-libvips-darwin-x64', + '@img/sharp-libvips-linux-arm64', + '@img/sharp-libvips-linuxmusl-arm64', + '@img/sharp-libvips-linux-x64', + '@img/sharp-libvips-linuxmusl-x64', + '@img/sharp-linux-arm64', + '@img/sharp-linux-x64', + '@img/sharp-linuxmusl-arm64', + '@img/sharp-linuxmusl-x64', + '@img/sharp-win32-arm64', + '@img/sharp-win32-x64', + '@libsql/darwin-arm64', + '@libsql/darwin-x64', + '@libsql/linux-arm64-gnu', + '@libsql/linux-x64-gnu', + '@libsql/linux-arm64-musl', + '@libsql/linux-x64-musl', + '@libsql/win32-x64-msvc', + '@napi-rs/system-ocr-darwin-arm64', + '@napi-rs/system-ocr-darwin-x64', + '@napi-rs/system-ocr-win32-arm64-msvc', + '@napi-rs/system-ocr-win32-x64-msvc', + '@strongtz/win32-arm64-msvc' +] const platformToArch = { mac: 'darwin', windows: 'win32', - linux: 'linux' + linux: 'linux', + linuxmusl: 'linuxmusl' } exports.default = async function (context) { - const arch = context.arch - const archType = arch === Arch.arm64 ? 'arm64' : 'x64' - const platform = context.packager.platform.name + const arch = context.arch === Arch.arm64 ? 'arm64' : 'x64' + const platformName = context.packager.platform.name + const platform = platformToArch[platformName] - const downloadPackages = async (packages) => { - console.log('downloading packages ......') - const downloadPromises = [] - - for (const name of Object.keys(packages)) { - if (name.includes(`${platformToArch[platform]}`) && name.includes(`-${archType}`)) { - downloadPromises.push( - downloadNpmPackage( - name, - `https://registry.npmjs.org/${name}/-/${name.split('/').pop()}-${packages[name]}.tgz` - ) - ) - } + const downloadPackages = async () => { + // Skip if target platform and architecture match current system + if (platform === process.platform && arch === process.arch) { + console.log(`Skipping install: target (${platform}/${arch}) matches current system`) + return } - await Promise.all(downloadPromises) + console.log(`Installing packages for target platform=${platform} arch=${arch}...`) + + // Backup and modify pnpm-workspace.yaml to add target platform support + const originalWorkspaceConfig = fs.readFileSync(workspaceConfigPath, 'utf-8') + const workspaceConfig = yaml.load(originalWorkspaceConfig) + + // Add target platform to supportedArchitectures.os + if (!workspaceConfig.supportedArchitectures.os.includes(platform)) { + workspaceConfig.supportedArchitectures.os.push(platform) + } + + // Add target architecture to supportedArchitectures.cpu + if (!workspaceConfig.supportedArchitectures.cpu.includes(arch)) { + workspaceConfig.supportedArchitectures.cpu.push(arch) + } + + const modifiedWorkspaceConfig = yaml.dump(workspaceConfig) + console.log('Modified workspace config:', modifiedWorkspaceConfig) + fs.writeFileSync(workspaceConfigPath, modifiedWorkspaceConfig) + + try { + execSync(`pnpm install`, { stdio: 'inherit' }) + } finally { + // Restore original pnpm-workspace.yaml + fs.writeFileSync(workspaceConfigPath, originalWorkspaceConfig) + } } - const changeFilters = async (filtersToExclude, filtersToInclude) => { - // remove filters for the target architecture (allow inclusion) - let filters = context.packager.config.files[0].filter - filters = filters.filter((filter) => !filtersToInclude.includes(filter)) + await downloadPackages() + + const excludePackages = async (packagesToExclude) => { + // 从项目根目录的 electron-builder.yml 读取 files 配置,避免多次覆盖配置导致出错 + const electronBuilderConfigPath = path.join(__dirname, '..', 'electron-builder.yml') + const electronBuilderConfig = yaml.load(fs.readFileSync(electronBuilderConfigPath, 'utf-8')) + let filters = electronBuilderConfig.files // add filters for other architectures (exclude them) - filters.push(...filtersToExclude) + filters.push(...packagesToExclude) context.packager.config.files[0].filter = filters } - await downloadPackages(arch === Arch.arm64 ? allArm64 : allX64) + const arm64KeepPackages = packages.filter((p) => p.includes('arm64') && p.includes(platform)) + const arm64ExcludePackages = packages + .filter((p) => !arm64KeepPackages.includes(p)) + .map((p) => '!node_modules/' + p + '/**') - const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**') - const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*') - const excludeClaudeCodeRipgrepFilters = claudeCodeVenders - .filter((f) => f !== `${archType}-${platformToArch[platform]}`) - .map((f) => '!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + f + '/**') - const excludeClaudeCodeJBPlutins = ['!node_modules/' + claudeCodeVenderPath + '/' + 'claude-code-jetbrains-plugin'] + const x64KeepPackages = packages.filter((p) => p.includes('x64') && p.includes(platform)) + const x64ExcludePackages = packages + .filter((p) => !x64KeepPackages.includes(p)) + .map((p) => '!node_modules/' + p + '/**') - const includeClaudeCodeFilters = [ - '!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + `${archType}-${platformToArch[platform]}/**` - ] + const excludeRipgrepFilters = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32'] + .filter((f) => { + // On Windows ARM64, also keep x64-win32 for emulation compatibility + if (platform === 'win32' && context.arch === Arch.arm64 && f === 'x64-win32') { + return false + } + return f !== `${arch}-${platform}` + }) + .map((f) => '!node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep/' + f + '/**') - if (arch === Arch.arm64) { - await changeFilters( - [...x64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins], - [...arm64Filters, ...includeClaudeCodeFilters] - ) + if (context.arch === Arch.arm64) { + await excludePackages([...arm64ExcludePackages, ...excludeRipgrepFilters]) } else { - await changeFilters( - [...arm64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins], - [...x64Filters, ...includeClaudeCodeFilters] - ) + await excludePackages([...x64ExcludePackages, ...excludeRipgrepFilters]) } } diff --git a/scripts/check-hardcoded-strings.ts b/scripts/check-hardcoded-strings.ts new file mode 100644 index 0000000000..2c0fdd9416 --- /dev/null +++ b/scripts/check-hardcoded-strings.ts @@ -0,0 +1,464 @@ +/** + * AST-based hardcoded string detection for i18n + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { SourceFile } from 'ts-morph' +import { Node, Project } from 'ts-morph' + +const RENDERER_DIR = path.join(__dirname, '../src/renderer/src') +const MAIN_DIR = path.join(__dirname, '../src/main') +const EXTENSIONS = ['.tsx', '.ts'] +const IGNORED_DIRS = ['__tests__', 'node_modules', 'i18n', 'locales', 'types', 'assets'] +const IGNORED_FILES = ['*.test.ts', '*.test.tsx', '*.d.ts', '*prompts*.ts'] + +// 'content' is handled specially - only checked for specific components +const UI_ATTRIBUTES = [ + 'placeholder', + 'title', + 'label', + 'message', + 'description', + 'tooltip', + 'buttonLabel', + 'name', + 'detail', + 'body' +] + +const CONTEXT_SENSITIVE_ATTRIBUTES: Record = { + content: ['Tooltip', 'Popover', 'Modal', 'Popconfirm', 'Alert', 'Notification', 'Message'] +} + +const UI_PROPERTIES = ['message', 'text', 'title', 'label', 'placeholder', 'description', 'detail'] + +interface Finding { + file: string + line: number + content: string + type: 'chinese' | 'english' + source: 'renderer' | 'main' + nodeType: string +} + +const CJK_RANGES = [ + '\u3000-\u303f', // CJK Symbols and Punctuation + '\u3040-\u309f', // Hiragana + '\u30a0-\u30ff', // Katakana + '\u3100-\u312f', // Bopomofo + '\u3400-\u4dbf', // CJK Unified Ideographs Extension A + '\u4e00-\u9fff', // CJK Unified Ideographs + '\uac00-\ud7af', // Hangul Syllables + '\uf900-\ufaff' // CJK Compatibility Ideographs +].join('') + +function hasCJK(text: string): boolean { + return new RegExp(`[${CJK_RANGES}]`).test(text) +} + +function hasEnglishUIText(text: string): boolean { + const words = text.trim().split(/\s+/) + if (words.length < 2 || words.length > 6) return false + return /^[A-Z][a-z]+(\s+[A-Za-z]+){1,5}$/.test(text.trim()) +} + +function createFinding( + node: Node, + sourceFile: SourceFile, + type: 'chinese' | 'english', + source: 'renderer' | 'main', + nodeType: string +): Finding { + return { + file: sourceFile.getFilePath(), + line: sourceFile.getLineAndColumnAtPos(node.getStart()).line, + content: node.getText().slice(0, 100), + type, + source, + nodeType + } +} + +function shouldSkipNode(node: Node): boolean { + let current: Node | undefined = node + + while (current) { + const parent = current.getParent() + if (!parent) break + + if (Node.isImportDeclaration(parent) || Node.isExportDeclaration(parent)) { + return true + } + + if (Node.isCallExpression(parent)) { + const callText = parent.getExpression().getText() + if (/^(logger|console)\.(log|error|warn|info|debug|silly|trace|withContext)/.test(callText)) { + return true + } + const callee = parent.getExpression() + if (Node.isIdentifier(callee) && callee.getText() === 't') { + return true + } + } + + if (Node.isTypeNode(parent) || Node.isTypeAliasDeclaration(parent) || Node.isInterfaceDeclaration(parent)) { + return true + } + + if (Node.isPropertySignature(parent)) { + return true + } + + if (Node.isEnumMember(parent)) { + return true + } + + // Native language names should stay in native form + if (Node.isVariableDeclaration(parent)) { + const varName = parent.getName() + if (/language|locale/i.test(varName)) { + return true + } + } + + current = parent + } + + return false +} + +function isNonUIString(text: string): boolean { + if (text.length === 0) return true + if (/^\d+$/.test(text)) return true + return false +} + +const CODE_CONTEXT = { + cssTags: /^(css|keyframes|injectGlobal|createGlobalStyle|styled\.\w+)$/, + cssNames: /style|css|animation/i, + codeNames: /code|script|python|sql|query|html|template|regex|pattern|shim/i, + jsxAttrs: new Set(['style', 'css']), + execCalls: /\.(executeJavaScript|eval|Function|runPython|runPythonAsync)$/ +} + +function isInCodeContext(node: Node): boolean { + const parent = node.getParent() + if (!parent) return false + + if (Node.isTaggedTemplateExpression(parent)) { + return CODE_CONTEXT.cssTags.test(parent.getTag().getText()) + } + + if (Node.isVariableDeclaration(parent)) { + const name = parent.getName() + return CODE_CONTEXT.cssNames.test(name) || CODE_CONTEXT.codeNames.test(name) + } + + if (Node.isPropertyAssignment(parent)) { + const name = parent.getName() + return CODE_CONTEXT.cssNames.test(name) || CODE_CONTEXT.codeNames.test(name) + } + + if (Node.isJsxExpression(parent)) { + const attr = parent.getParent() + if (attr && Node.isJsxAttribute(attr)) { + return CODE_CONTEXT.jsxAttrs.has(attr.getNameNode().getText()) + } + } + + // Traverse up for code execution calls (handles string concatenation) + let current: Node | undefined = parent + while (current) { + if (Node.isCallExpression(current)) { + if (CODE_CONTEXT.execCalls.test(current.getExpression().getText())) { + return true + } + break + } + if (!Node.isBinaryExpression(current) && !Node.isParenthesizedExpression(current)) { + break + } + current = current.getParent() + } + + return false +} + +function getJsxElementName(attrNode: Node): string | null { + const parent = attrNode.getParent() + if (!parent) return null + + if (Node.isJsxOpeningElement(parent) || Node.isJsxSelfClosingElement(parent)) { + return parent.getTagNameNode().getText() + } + return null +} + +function shouldCheckAttribute(attrName: string, elementName: string | null): boolean { + if (UI_ATTRIBUTES.includes(attrName)) { + return true + } + const allowedComponents = CONTEXT_SENSITIVE_ATTRIBUTES[attrName] + if (allowedComponents && elementName) { + return allowedComponents.includes(elementName) + } + return false +} + +class HardcodedStringDetector { + private project: Project + + constructor() { + this.project = new Project({ + skipAddingFilesFromTsConfig: true, + skipFileDependencyResolution: true + }) + } + + scanFile(filePath: string, source: 'renderer' | 'main'): Finding[] { + const findings: Finding[] = [] + + try { + const sourceFile = this.project.addSourceFileAtPath(filePath) + sourceFile.forEachDescendant((node) => { + this.checkNode(node, sourceFile, source, findings) + }) + this.project.removeSourceFile(sourceFile) + } catch (error) { + console.error(`Error parsing ${filePath}:`, error) + } + + return findings + } + + private checkNode(node: Node, sourceFile: SourceFile, source: 'renderer' | 'main', findings: Finding[]): void { + if (shouldSkipNode(node)) return + + if (Node.isJsxText(node)) { + const text = node.getText().trim() + if (text && hasCJK(text)) { + // Skip SVG internal elements + const parent = node.getParent() + if (parent && (Node.isJsxElement(parent) || Node.isJsxSelfClosingElement(parent))) { + const tagName = Node.isJsxElement(parent) + ? parent.getOpeningElement().getTagNameNode().getText() + : parent.getTagNameNode().getText() + if (['title', 'desc', 'text', 'tspan'].includes(tagName)) { + return + } + } + findings.push(createFinding(node, sourceFile, 'chinese', source, 'JsxText')) + } + } + + if (Node.isJsxAttribute(node)) { + const attrName = node.getNameNode().getText() + const elementName = getJsxElementName(node) + + if (shouldCheckAttribute(attrName, elementName)) { + const initializer = node.getInitializer() + if (initializer && Node.isStringLiteral(initializer)) { + const value = initializer.getLiteralValue() + if (!isNonUIString(value)) { + if (hasCJK(value)) { + findings.push(createFinding(node, sourceFile, 'chinese', source, 'JsxAttribute')) + } else if (source === 'renderer' && hasEnglishUIText(value)) { + findings.push(createFinding(node, sourceFile, 'english', source, 'JsxAttribute')) + } + } + } + } + } + + if (Node.isStringLiteral(node)) { + if (isInCodeContext(node)) return + + const value = node.getLiteralValue() + if (isNonUIString(value)) return + + const parent = node.getParent() + + if (parent && Node.isPropertyAssignment(parent)) { + const propName = parent.getName() + if (UI_PROPERTIES.includes(propName)) { + if (hasCJK(value)) { + findings.push(createFinding(node, sourceFile, 'chinese', source, 'PropertyAssignment')) + } + } + } + + if (parent && Node.isCallExpression(parent)) { + const callText = parent.getExpression().getText() + if ( + /^(window\.toast|message|antdMessage|Modal|notification)\.(success|error|warning|info|confirm)/.test(callText) + ) { + if (hasCJK(value)) { + findings.push(createFinding(node, sourceFile, 'chinese', source, 'CallExpression')) + } + } + } + } + + if (Node.isTemplateExpression(node) || Node.isNoSubstitutionTemplateLiteral(node)) { + if (isInCodeContext(node)) return + + const text = node.getText() + if (hasCJK(text)) { + findings.push(createFinding(node, sourceFile, 'chinese', source, 'TemplateLiteral')) + } + } + } +} + +function shouldSkipFile(filePath: string, baseDir: string): boolean { + const relativePath = path.relative(baseDir, filePath) + + if (IGNORED_DIRS.some((dir) => relativePath.includes(dir))) { + return true + } + + const fileName = path.basename(filePath) + if ( + IGNORED_FILES.some((pattern) => { + const regex = new RegExp(pattern.replace('*', '.*')) + return regex.test(fileName) + }) + ) { + return true + } + + return false +} + +function scanDirectory(dir: string, source: 'renderer' | 'main', detector: HardcodedStringDetector): Finding[] { + const findings: Finding[] = [] + + if (!fs.existsSync(dir)) { + return findings + } + + const entries = fs.readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + if (!IGNORED_DIRS.includes(entry.name)) { + findings.push(...scanDirectory(fullPath, source, detector)) + } + } else if (entry.isFile() && EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + if (!shouldSkipFile(fullPath, source === 'renderer' ? RENDERER_DIR : MAIN_DIR)) { + findings.push(...detector.scanFile(fullPath, source)) + } + } + } + + return findings +} + +function formatFindings(findings: Finding[]): string { + if (findings.length === 0) { + return '✅ No hardcoded strings found!' + } + + const rendererFindings = findings.filter((f) => f.source === 'renderer') + const mainFindings = findings.filter((f) => f.source === 'main') + const chineseFindings = findings.filter((f) => f.type === 'chinese') + const englishFindings = findings.filter((f) => f.type === 'english') + + let output = '' + + if (rendererFindings.length > 0) { + output += '\n📦 Renderer Process:\n' + output += '-'.repeat(50) + '\n' + + const rendererChinese = rendererFindings.filter((f) => f.type === 'chinese') + const rendererEnglish = rendererFindings.filter((f) => f.type === 'english') + + if (rendererChinese.length > 0) { + output += '\n⚠️ Hardcoded Chinese strings:\n' + rendererChinese.forEach((f) => { + const relativePath = path.relative(RENDERER_DIR, f.file) + output += `\n📍 ${relativePath}:${f.line} [${f.nodeType}]\n` + output += ` ${f.content}\n` + }) + } + + if (rendererEnglish.length > 0) { + output += '\n⚠️ Potential hardcoded English strings:\n' + rendererEnglish.forEach((f) => { + const relativePath = path.relative(RENDERER_DIR, f.file) + output += `\n📍 ${relativePath}:${f.line} [${f.nodeType}]\n` + output += ` ${f.content}\n` + }) + } + } + + if (mainFindings.length > 0) { + output += '\n📦 Main Process:\n' + output += '-'.repeat(50) + '\n' + + const mainChinese = mainFindings.filter((f) => f.type === 'chinese') + + if (mainChinese.length > 0) { + output += '\n⚠️ Hardcoded Chinese strings:\n' + mainChinese.forEach((f) => { + const relativePath = path.relative(MAIN_DIR, f.file) + output += `\n📍 ${relativePath}:${f.line} [${f.nodeType}]\n` + output += ` ${f.content}\n` + }) + } + } + + output += '\n' + '='.repeat(50) + '\n' + output += `Total: ${findings.length} potential issues found\n` + output += ` - Renderer: ${rendererFindings.length} (Chinese: ${rendererFindings.filter((f) => f.type === 'chinese').length}, English: ${rendererFindings.filter((f) => f.type === 'english').length})\n` + output += ` - Main: ${mainFindings.length} (Chinese: ${mainFindings.length})\n` + output += ` - Total Chinese: ${chineseFindings.length}\n` + output += ` - Total English: ${englishFindings.length}\n` + + return output +} + +export function main(): void { + console.log('🔍 Scanning for hardcoded strings using AST analysis...\n') + + const detector = new HardcodedStringDetector() + + const rendererFindings = scanDirectory(RENDERER_DIR, 'renderer', detector) + const mainFindings = scanDirectory(MAIN_DIR, 'main', detector) + const findings = [...rendererFindings, ...mainFindings] + + const output = formatFindings(findings) + console.log(output) + + // Strict mode for CI + const strictMode = process.env.I18N_STRICT === 'true' || process.argv.includes('--strict') + const chineseCount = findings.filter((f) => f.type === 'chinese').length + + if (strictMode && chineseCount > 0) { + console.error('\n❌ Hardcoded Chinese strings detected in strict mode!') + console.error('Please replace these with i18n keys using the t() function.') + process.exit(1) + } + + if (findings.length > 0) { + console.log('\n💡 Tip: Consider replacing these strings with i18n keys.') + console.log(' Use the t() function from react-i18next for translations.') + } +} + +export { + HardcodedStringDetector, + hasCJK, + hasEnglishUIText, + isInCodeContext, + isNonUIString, + shouldSkipFile, + shouldSkipNode, + UI_ATTRIBUTES, + UI_PROPERTIES +} + +main() diff --git a/scripts/feishu-notify.js b/scripts/feishu-notify.js deleted file mode 100644 index d238dedb90..0000000000 --- a/scripts/feishu-notify.js +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Feishu (Lark) Webhook Notification Script - * Sends GitHub issue summaries to Feishu with signature verification - */ - -const crypto = require('crypto') -const https = require('https') - -/** - * Generate Feishu webhook signature - * @param {string} secret - Feishu webhook secret - * @param {number} timestamp - Unix timestamp in seconds - * @returns {string} Base64 encoded signature - */ -function generateSignature(secret, timestamp) { - const stringToSign = `${timestamp}\n${secret}` - const hmac = crypto.createHmac('sha256', stringToSign) - return hmac.digest('base64') -} - -/** - * Send message to Feishu webhook - * @param {string} webhookUrl - Feishu webhook URL - * @param {string} secret - Feishu webhook secret - * @param {object} content - Message content - * @returns {Promise} - */ -function sendToFeishu(webhookUrl, secret, content) { - return new Promise((resolve, reject) => { - const timestamp = Math.floor(Date.now() / 1000) - const sign = generateSignature(secret, timestamp) - - const payload = JSON.stringify({ - timestamp: timestamp.toString(), - sign: sign, - msg_type: 'interactive', - card: content - }) - - const url = new URL(webhookUrl) - const options = { - hostname: url.hostname, - path: url.pathname + url.search, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload) - } - } - - const req = https.request(options, (res) => { - let data = '' - res.on('data', (chunk) => { - data += chunk - }) - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - console.log('✅ Successfully sent to Feishu:', data) - resolve() - } else { - reject(new Error(`Feishu API error: ${res.statusCode} - ${data}`)) - } - }) - }) - - req.on('error', (error) => { - reject(error) - }) - - req.write(payload) - req.end() - }) -} - -/** - * Create Feishu card message from issue data - * @param {object} issueData - GitHub issue data - * @returns {object} Feishu card content - */ -function createIssueCard(issueData) { - const { issueUrl, issueNumber, issueTitle, issueSummary, issueAuthor, labels } = issueData - - // Build labels section if labels exist - const labelElements = - labels && labels.length > 0 - ? labels.map((label) => ({ - tag: 'markdown', - content: `\`${label}\`` - })) - : [] - - return { - elements: [ - { - tag: 'div', - text: { - tag: 'lark_md', - content: `**👤 Author:** ${issueAuthor}` - } - }, - ...(labelElements.length > 0 - ? [ - { - tag: 'div', - text: { - tag: 'lark_md', - content: `**🏷️ Labels:** ${labels.join(', ')}` - } - } - ] - : []), - { - tag: 'hr' - }, - { - tag: 'div', - text: { - tag: 'lark_md', - content: `**📋 Summary:**\n${issueSummary}` - } - }, - { - tag: 'hr' - }, - { - tag: 'action', - actions: [ - { - tag: 'button', - text: { - tag: 'plain_text', - content: '🔗 View Issue' - }, - type: 'primary', - url: issueUrl - } - ] - } - ], - header: { - template: 'blue', - title: { - tag: 'plain_text', - content: `#${issueNumber} - ${issueTitle}` - } - } - } -} - -/** - * Main function - */ -async function main() { - try { - // Get environment variables - const webhookUrl = process.env.FEISHU_WEBHOOK_URL - const secret = process.env.FEISHU_WEBHOOK_SECRET - const issueUrl = process.env.ISSUE_URL - const issueNumber = process.env.ISSUE_NUMBER - const issueTitle = process.env.ISSUE_TITLE - const issueSummary = process.env.ISSUE_SUMMARY - const issueAuthor = process.env.ISSUE_AUTHOR - const labelsStr = process.env.ISSUE_LABELS || '' - - // Validate required environment variables - if (!webhookUrl) { - throw new Error('FEISHU_WEBHOOK_URL environment variable is required') - } - if (!secret) { - throw new Error('FEISHU_WEBHOOK_SECRET environment variable is required') - } - if (!issueUrl || !issueNumber || !issueTitle || !issueSummary) { - throw new Error('Issue data environment variables are required') - } - - // Parse labels - const labels = labelsStr - ? labelsStr - .split(',') - .map((l) => l.trim()) - .filter(Boolean) - : [] - - // Create issue data object - const issueData = { - issueUrl, - issueNumber, - issueTitle, - issueSummary, - issueAuthor: issueAuthor || 'Unknown', - labels - } - - // Create card content - const card = createIssueCard(issueData) - - console.log('📤 Sending notification to Feishu...') - console.log(`Issue #${issueNumber}: ${issueTitle}`) - - // Send to Feishu - await sendToFeishu(webhookUrl, secret, card) - - console.log('✅ Notification sent successfully!') - } catch (error) { - console.error('❌ Error:', error.message) - process.exit(1) - } -} - -// Run main function -main() diff --git a/scripts/feishu-notify.ts b/scripts/feishu-notify.ts new file mode 100644 index 0000000000..8c195b8c6d --- /dev/null +++ b/scripts/feishu-notify.ts @@ -0,0 +1,421 @@ +#!/usr/bin/env npx tsx +/** + * @fileoverview Feishu (Lark) Webhook Notification CLI Tool + * @description Sends notifications to Feishu with signature verification. + * Supports subcommands for different notification types. + * @module feishu-notify + * @example + * // Send GitHub issue notification + * pnpm tsx feishu-notify.ts issue -u "https://..." -n "123" -t "Title" -m "Summary" + * + * // Using environment variables for credentials + * FEISHU_WEBHOOK_URL="..." FEISHU_WEBHOOK_SECRET="..." pnpm tsx feishu-notify.ts issue ... + */ + +import { Command } from 'commander' +import crypto from 'crypto' +import dotenv from 'dotenv' +import https from 'https' +import * as z from 'zod' + +// Load environment variables from .env file +dotenv.config() + +/** CLI tool version */ +const VERSION = '1.0.0' + +/** GitHub issue data structure */ +interface IssueData { + /** GitHub issue URL */ + issueUrl: string + /** Issue number */ + issueNumber: string + /** Issue title */ + issueTitle: string + /** Issue summary/description */ + issueSummary: string + /** Issue author username */ + issueAuthor: string + /** Issue labels */ + labels: string[] +} + +/** Feishu card text element */ +interface FeishuTextElement { + tag: 'div' + text: { + tag: 'lark_md' + content: string + } +} + +/** Feishu card horizontal rule element */ +interface FeishuHrElement { + tag: 'hr' +} + +/** Feishu card action button */ +interface FeishuActionElement { + tag: 'action' + actions: Array<{ + tag: 'button' + text: { + tag: 'plain_text' + content: string + } + type: 'primary' | 'default' + url: string + }> +} + +/** Feishu card element union type */ +type FeishuCardElement = FeishuTextElement | FeishuHrElement | FeishuActionElement + +/** Zod schema for Feishu header color template */ +const FeishuHeaderTemplateSchema = z.enum([ + 'blue', + 'wathet', + 'turquoise', + 'green', + 'yellow', + 'orange', + 'red', + 'carmine', + 'violet', + 'purple', + 'indigo', + 'grey', + 'default' +]) + +/** Feishu card header color template (inferred from schema) */ +type FeishuHeaderTemplate = z.infer + +/** Feishu interactive card structure */ +interface FeishuCard { + elements: FeishuCardElement[] + header: { + template: FeishuHeaderTemplate + title: { + tag: 'plain_text' + content: string + } + } +} + +/** Feishu webhook request payload */ +interface FeishuPayload { + timestamp: string + sign: string + msg_type: 'interactive' + card: FeishuCard +} + +/** Issue subcommand options */ +interface IssueOptions { + url: string + number: string + title: string + summary: string + author?: string + labels?: string +} + +/** Send subcommand options */ +interface SendOptions { + title: string + description: string + color?: string +} + +/** + * Generate Feishu webhook signature using HMAC-SHA256 + * @param secret - Feishu webhook secret + * @param timestamp - Unix timestamp in seconds + * @returns Base64 encoded signature + */ +function generateSignature(secret: string, timestamp: number): string { + const stringToSign = `${timestamp}\n${secret}` + const hmac = crypto.createHmac('sha256', stringToSign) + return hmac.digest('base64') +} + +/** + * Send message to Feishu webhook + * @param webhookUrl - Feishu webhook URL + * @param secret - Feishu webhook secret + * @param content - Feishu card message content + * @returns Resolves when message is sent successfully + * @throws When Feishu API returns non-2xx status code or network error occurs + */ +function sendToFeishu(webhookUrl: string, secret: string, content: FeishuCard): Promise { + return new Promise((resolve, reject) => { + const timestamp = Math.floor(Date.now() / 1000) + const sign = generateSignature(secret, timestamp) + + const payload: FeishuPayload = { + timestamp: timestamp.toString(), + sign, + msg_type: 'interactive', + card: content + } + + const payloadStr = JSON.stringify(payload) + const url = new URL(webhookUrl) + + const options: https.RequestOptions = { + hostname: url.hostname, + path: url.pathname + url.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payloadStr) + } + } + + const req = https.request(options, (res) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode && 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: Error) => { + reject(error) + }) + + req.write(payloadStr) + req.end() + }) +} + +/** + * Create Feishu card message from issue data + * @param issueData - GitHub issue data + * @returns Feishu card content + */ +function createIssueCard(issueData: IssueData): FeishuCard { + const { issueUrl, issueNumber, issueTitle, issueSummary, issueAuthor, labels } = issueData + + const elements: FeishuCardElement[] = [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: `**Author:** ${issueAuthor}` + } + } + ] + + if (labels.length > 0) { + elements.push({ + tag: 'div', + text: { + tag: 'lark_md', + content: `**Labels:** ${labels.join(', ')}` + } + }) + } + + elements.push( + { tag: 'hr' }, + { + tag: 'div', + text: { + tag: 'lark_md', + content: `**Summary:**\n${issueSummary}` + } + }, + { tag: 'hr' }, + { + tag: 'action', + actions: [ + { + tag: 'button', + text: { + tag: 'plain_text', + content: 'View Issue' + }, + type: 'primary', + url: issueUrl + } + ] + } + ) + + return { + elements, + header: { + template: 'blue', + title: { + tag: 'plain_text', + content: `#${issueNumber} - ${issueTitle}` + } + } + } +} + +/** + * Create a simple Feishu card message + * @param title - Card title + * @param description - Card description content + * @param color - Header color template (default: 'turquoise') + * @returns Feishu card content + */ +function createSimpleCard(title: string, description: string, color: FeishuHeaderTemplate = 'turquoise'): FeishuCard { + return { + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: description + } + } + ], + header: { + template: color, + title: { + tag: 'plain_text', + content: title + } + } + } +} + +/** + * Get Feishu credentials from environment variables + */ +function getCredentials(): { webhookUrl: string; secret: string } { + const webhookUrl = process.env.FEISHU_WEBHOOK_URL + const secret = process.env.FEISHU_WEBHOOK_SECRET + + if (!webhookUrl) { + console.error('Error: FEISHU_WEBHOOK_URL environment variable is required') + process.exit(1) + } + if (!secret) { + console.error('Error: FEISHU_WEBHOOK_SECRET environment variable is required') + process.exit(1) + } + + return { webhookUrl, secret } +} + +/** + * Handle send subcommand + */ +async function handleSendCommand(options: SendOptions): Promise { + const { webhookUrl, secret } = getCredentials() + + const { title, description, color = 'turquoise' } = options + + // Validate color parameter + const colorValidation = FeishuHeaderTemplateSchema.safeParse(color) + if (!colorValidation.success) { + console.error(`Error: Invalid color "${color}". Valid colors: ${FeishuHeaderTemplateSchema.options.join(', ')}`) + process.exit(1) + } + + const card = createSimpleCard(title, description, colorValidation.data) + + console.log('Sending notification to Feishu...') + console.log(`Title: ${title}`) + + await sendToFeishu(webhookUrl, secret, card) + + console.log('Notification sent successfully!') +} + +/** + * Handle issue subcommand + */ +async function handleIssueCommand(options: IssueOptions): Promise { + const { webhookUrl, secret } = getCredentials() + + const { url, number, title, summary, author = 'Unknown', labels: labelsStr = '' } = options + + if (!url || !number || !title || !summary) { + console.error('Error: --url, --number, --title, and --summary are required') + process.exit(1) + } + + const labels = labelsStr + ? labelsStr + .split(',') + .map((l) => l.trim()) + .filter(Boolean) + : [] + + const issueData: IssueData = { + issueUrl: url, + issueNumber: number, + issueTitle: title, + issueSummary: summary, + issueAuthor: author, + labels + } + + const card = createIssueCard(issueData) + + console.log('Sending notification to Feishu...') + console.log(`Issue #${number}: ${title}`) + + await sendToFeishu(webhookUrl, secret, card) + + console.log('Notification sent successfully!') +} + +// Configure CLI +const program = new Command() + +program.name('feishu-notify').description('Send notifications to Feishu webhook').version(VERSION) + +// Send subcommand (generic) +program + .command('send') + .description('Send a simple notification to Feishu') + .requiredOption('-t, --title ', 'Card title') + .requiredOption('-d, --description <description>', 'Card description (supports markdown)') + .option( + '-c, --color <color>', + `Header color template (default: turquoise). Options: ${FeishuHeaderTemplateSchema.options.join(', ')}`, + 'turquoise' + ) + .action(async (options: SendOptions) => { + try { + await handleSendCommand(options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error) + process.exit(1) + } + }) + +// Issue subcommand +program + .command('issue') + .description('Send GitHub issue notification to Feishu') + .requiredOption('-u, --url <url>', 'GitHub issue URL') + .requiredOption('-n, --number <number>', 'Issue number') + .requiredOption('-t, --title <title>', 'Issue title') + .requiredOption('-m, --summary <summary>', 'Issue summary') + .option('-a, --author <author>', 'Issue author', 'Unknown') + .option('-l, --labels <labels>', 'Issue labels, comma-separated') + .action(async (options: IssueOptions) => { + try { + await handleIssueCommand(options) + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error) + process.exit(1) + } + }) + +program.parse() diff --git a/scripts/utils.js b/scripts/utils.js deleted file mode 100644 index cafa07b681..0000000000 --- a/scripts/utils.js +++ /dev/null @@ -1,64 +0,0 @@ -const fs = require('fs') -const path = require('path') -const os = require('os') -const zlib = require('zlib') -const tar = require('tar') -const { pipeline } = require('stream/promises') - -async function downloadNpmPackage(packageName, url) { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-download-')) - const targetDir = path.join('./node_modules/', packageName) - const filename = path.join(tempDir, packageName.replace('/', '-') + '.tgz') - const extractDir = path.join(tempDir, 'extract') - - // Skip if directory already exists - if (fs.existsSync(targetDir)) { - console.log(`${targetDir} already exists, skipping download...`) - return - } - - try { - console.log(`Downloading ${packageName}...`, url) - - // Download file using fetch API - const response = await fetch(url) - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const fileStream = fs.createWriteStream(filename) - await pipeline(response.body, fileStream) - - console.log(`Extracting ${filename}...`) - - // Create extraction directory - fs.mkdirSync(extractDir, { recursive: true }) - - // Extract tar.gz file using Node.js streams - await pipeline(fs.createReadStream(filename), zlib.createGunzip(), tar.extract({ cwd: extractDir })) - - // Remove the downloaded file - fs.rmSync(filename, { force: true }) - - // Create target directory - fs.mkdirSync(targetDir, { recursive: true }) - - // Move extracted package contents to target directory - const packageDir = path.join(extractDir, 'package') - if (fs.existsSync(packageDir)) { - fs.cpSync(packageDir, targetDir, { recursive: true }) - } - } catch (error) { - console.error(`Error processing ${packageName}: ${error.message}`) - throw error - } finally { - // Clean up temp directory - if (fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }) - } - } -} - -module.exports = { - downloadNpmPackage -} diff --git a/src/main/__tests__/mcp.test.ts b/src/main/__tests__/mcp.test.ts new file mode 100644 index 0000000000..809db3d665 --- /dev/null +++ b/src/main/__tests__/mcp.test.ts @@ -0,0 +1,240 @@ +import { buildFunctionCallToolName, buildMcpToolName, generateMcpToolFunctionName, toCamelCase } from '@shared/mcp' +import { describe, expect, it } from 'vitest' + +describe('toCamelCase', () => { + it('should convert hyphenated strings', () => { + expect(toCamelCase('my-server')).toBe('myServer') + expect(toCamelCase('my-tool-name')).toBe('myToolName') + }) + + it('should convert underscored strings', () => { + expect(toCamelCase('my_server')).toBe('myServer') + expect(toCamelCase('search_issues')).toBe('searchIssues') + }) + + it('should handle mixed delimiters', () => { + expect(toCamelCase('my-server_name')).toBe('myServerName') + }) + + it('should handle leading numbers by prefixing underscore', () => { + expect(toCamelCase('123server')).toBe('_123server') + }) + + it('should handle special characters', () => { + expect(toCamelCase('test@server!')).toBe('testServer') + expect(toCamelCase('tool#name$')).toBe('toolName') + }) + + it('should trim whitespace', () => { + expect(toCamelCase(' server ')).toBe('server') + }) + + it('should handle empty string', () => { + expect(toCamelCase('')).toBe('') + }) + + it('should handle uppercase snake case', () => { + expect(toCamelCase('MY_SERVER')).toBe('myServer') + expect(toCamelCase('SEARCH_ISSUES')).toBe('searchIssues') + }) + + it('should handle mixed case', () => { + expect(toCamelCase('MyServer')).toBe('myserver') + expect(toCamelCase('myTOOL')).toBe('mytool') + }) +}) + +describe('buildMcpToolName', () => { + it('should build basic name with defaults', () => { + expect(buildMcpToolName('github', 'search_issues')).toBe('github_searchIssues') + }) + + it('should handle undefined server name', () => { + expect(buildMcpToolName(undefined, 'search_issues')).toBe('searchIssues') + }) + + it('should apply custom prefix and delimiter', () => { + expect(buildMcpToolName('github', 'search', { prefix: 'mcp__', delimiter: '__' })).toBe('mcp__github__search') + }) + + it('should respect maxLength', () => { + const result = buildMcpToolName('veryLongServerName', 'veryLongToolName', { maxLength: 20 }) + expect(result.length).toBeLessThanOrEqual(20) + }) + + it('should handle collision with existingNames', () => { + const existingNames = new Set(['github_search']) + const result = buildMcpToolName('github', 'search', { existingNames }) + expect(result).toBe('github_search1') + expect(existingNames.has('github_search1')).toBe(true) + }) + + it('should respect maxLength when adding collision suffix', () => { + const existingNames = new Set(['a'.repeat(20)]) + const result = buildMcpToolName('a'.repeat(20), '', { maxLength: 20, existingNames }) + expect(result.length).toBeLessThanOrEqual(20) + expect(existingNames.has(result)).toBe(true) + }) + + it('should handle multiple collisions with maxLength', () => { + const existingNames = new Set(['abcd', 'abc1', 'abc2']) + const result = buildMcpToolName('abcd', '', { maxLength: 4, existingNames }) + expect(result).toBe('abc3') + expect(result.length).toBeLessThanOrEqual(4) + }) +}) + +describe('generateMcpToolFunctionName', () => { + it('should return format serverName_toolName in camelCase', () => { + expect(generateMcpToolFunctionName('github', 'search_issues')).toBe('github_searchIssues') + }) + + it('should handle hyphenated names', () => { + expect(generateMcpToolFunctionName('my-server', 'my-tool')).toBe('myServer_myTool') + }) + + it('should handle undefined server name', () => { + expect(generateMcpToolFunctionName(undefined, 'search_issues')).toBe('searchIssues') + }) + + it('should handle collision detection', () => { + const existingNames = new Set<string>() + const first = generateMcpToolFunctionName('github', 'search', existingNames) + const second = generateMcpToolFunctionName('github', 'search', existingNames) + expect(first).toBe('github_search') + expect(second).toBe('github_search1') + }) +}) + +describe('buildFunctionCallToolName', () => { + describe('basic format', () => { + it('should return format mcp__{server}__{tool} in camelCase', () => { + const result = buildFunctionCallToolName('github', 'search_issues') + expect(result).toBe('mcp__github__searchIssues') + }) + + it('should handle simple server and tool names', () => { + expect(buildFunctionCallToolName('fetch', 'get_page')).toBe('mcp__fetch__getPage') + expect(buildFunctionCallToolName('database', 'query')).toBe('mcp__database__query') + }) + }) + + describe('valid JavaScript identifier', () => { + it('should always start with mcp__ prefix (valid JS identifier start)', () => { + const result = buildFunctionCallToolName('123server', '456tool') + expect(result).toMatch(/^mcp__/) + }) + + it('should handle hyphenated names with camelCase', () => { + const result = buildFunctionCallToolName('my-server', 'my-tool') + expect(result).toBe('mcp__myServer__myTool') + }) + + it('should be a valid JavaScript identifier', () => { + const testCases = [ + ['github', 'create_issue'], + ['my-server', 'fetch-data'], + ['test@server', 'tool#name'], + ['server.name', 'tool.action'] + ] + + for (const [server, tool] of testCases) { + const result = buildFunctionCallToolName(server, tool) + expect(result).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/) + } + }) + }) + + describe('character sanitization', () => { + it('should convert special characters to camelCase boundaries', () => { + expect(buildFunctionCallToolName('my-server', 'my-tool-name')).toBe('mcp__myServer__myToolName') + expect(buildFunctionCallToolName('test@server!', 'tool#name$')).toBe('mcp__testServer__toolName') + expect(buildFunctionCallToolName('server.name', 'tool.action')).toBe('mcp__serverName__toolAction') + }) + + it('should handle spaces', () => { + const result = buildFunctionCallToolName('my server', 'my tool') + expect(result).toBe('mcp__myServer__myTool') + }) + }) + + describe('length constraints', () => { + it('should not exceed 63 characters', () => { + const longServerName = 'a'.repeat(50) + const longToolName = 'b'.repeat(50) + const result = buildFunctionCallToolName(longServerName, longToolName) + expect(result.length).toBeLessThanOrEqual(63) + }) + + it('should not end with underscores after truncation', () => { + const longServerName = 'a'.repeat(30) + const longToolName = 'b'.repeat(30) + const result = buildFunctionCallToolName(longServerName, longToolName) + expect(result).not.toMatch(/_+$/) + expect(result.length).toBeLessThanOrEqual(63) + }) + }) + + describe('edge cases', () => { + it('should handle empty server name', () => { + const result = buildFunctionCallToolName('', 'tool') + expect(result).toBe('mcp__tool') + }) + + it('should handle empty tool name', () => { + const result = buildFunctionCallToolName('server', '') + expect(result).toBe('mcp__server__') + }) + + it('should trim whitespace from names', () => { + const result = buildFunctionCallToolName(' server ', ' tool ') + expect(result).toBe('mcp__server__tool') + }) + + it('should handle mixed case by normalizing to lowercase first', () => { + const result = buildFunctionCallToolName('MyServer', 'MyTool') + expect(result).toBe('mcp__myserver__mytool') + }) + + it('should handle uppercase snake case', () => { + const result = buildFunctionCallToolName('MY_SERVER', 'SEARCH_ISSUES') + expect(result).toBe('mcp__myServer__searchIssues') + }) + }) + + describe('deterministic output', () => { + it('should produce consistent results for same input', () => { + const result1 = buildFunctionCallToolName('github', 'search_repos') + const result2 = buildFunctionCallToolName('github', 'search_repos') + expect(result1).toBe(result2) + }) + + it('should produce different results for different inputs', () => { + const result1 = buildFunctionCallToolName('server1', 'tool') + const result2 = buildFunctionCallToolName('server2', 'tool') + expect(result1).not.toBe(result2) + }) + }) + + describe('real-world scenarios', () => { + it('should handle GitHub MCP server', () => { + expect(buildFunctionCallToolName('github', 'create_issue')).toBe('mcp__github__createIssue') + expect(buildFunctionCallToolName('github', 'search_repositories')).toBe('mcp__github__searchRepositories') + }) + + it('should handle filesystem MCP server', () => { + expect(buildFunctionCallToolName('filesystem', 'read_file')).toBe('mcp__filesystem__readFile') + expect(buildFunctionCallToolName('filesystem', 'write_file')).toBe('mcp__filesystem__writeFile') + }) + + it('should handle hyphenated server names (common in npm packages)', () => { + expect(buildFunctionCallToolName('cherry-fetch', 'get_page')).toBe('mcp__cherryFetch__getPage') + expect(buildFunctionCallToolName('mcp-server-github', 'search')).toBe('mcp__mcpServerGithub__search') + }) + + it('should handle scoped npm package style names', () => { + const result = buildFunctionCallToolName('@anthropic/mcp-server', 'chat') + expect(result).toBe('mcp__AnthropicMcpServer__chat') + }) + }) +}) diff --git a/src/main/apiServer/routes/agents/handlers/messages.ts b/src/main/apiServer/routes/agents/handlers/messages.ts index 1b547abba8..abec51ec01 100644 --- a/src/main/apiServer/routes/agents/handlers/messages.ts +++ b/src/main/apiServer/routes/agents/handlers/messages.ts @@ -1,6 +1,10 @@ import { loggerService } from '@logger' import { MESSAGE_STREAM_TIMEOUT_MS } from '@main/apiServer/config/timeouts' -import { createStreamAbortController, STREAM_TIMEOUT_REASON } from '@main/apiServer/utils/createStreamAbortController' +import { + createStreamAbortController, + STREAM_TIMEOUT_REASON, + type StreamAbortController +} from '@main/apiServer/utils/createStreamAbortController' import { agentService, sessionMessageService, sessionService } from '@main/services/agents' import type { Request, Response } from 'express' @@ -26,7 +30,7 @@ const verifyAgentAndSession = async (agentId: string, sessionId: string) => { } export const createMessage = async (req: Request, res: Response): Promise<void> => { - let clearAbortTimeout: (() => void) | undefined + let streamController: StreamAbortController | undefined try { const { agentId, sessionId } = req.params @@ -45,14 +49,10 @@ export const createMessage = async (req: Request, res: Response): Promise<void> res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Headers', 'Cache-Control') - const { - abortController, - registerAbortHandler, - clearAbortTimeout: helperClearAbortTimeout - } = createStreamAbortController({ + streamController = createStreamAbortController({ timeoutMs: MESSAGE_STREAM_TIMEOUT_MS }) - clearAbortTimeout = helperClearAbortTimeout + const { abortController, registerAbortHandler, dispose } = streamController const { stream, completion } = await sessionMessageService.createSessionMessage( session, messageData, @@ -64,8 +64,8 @@ export const createMessage = async (req: Request, res: Response): Promise<void> let responseEnded = false let streamFinished = false - const cleanupAbortTimeout = () => { - clearAbortTimeout?.() + const cleanup = () => { + dispose() } const finalizeResponse = () => { @@ -78,7 +78,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void> } responseEnded = true - cleanupAbortTimeout() + cleanup() try { // res.write('data: {"type":"finish"}\n\n') res.write('data: [DONE]\n\n') @@ -108,7 +108,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void> * - Mark the response as ended to prevent further writes */ registerAbortHandler((abortReason) => { - cleanupAbortTimeout() + cleanup() if (responseEnded) return @@ -189,7 +189,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void> logger.error('Error writing stream error to SSE', { error: writeError }) } responseEnded = true - cleanupAbortTimeout() + cleanup() res.end() } } @@ -221,14 +221,14 @@ export const createMessage = async (req: Request, res: Response): Promise<void> logger.error('Error writing completion error to SSE stream', { error: writeError }) } responseEnded = true - cleanupAbortTimeout() + cleanup() res.end() }) // Clear timeout when response ends - res.on('close', cleanupAbortTimeout) - res.on('finish', cleanupAbortTimeout) + res.on('close', cleanup) + res.on('finish', cleanup) } catch (error: any) { - clearAbortTimeout?.() + streamController?.dispose() logger.error('Error in streaming message handler', { error, agentId: req.params.agentId, diff --git a/src/main/apiServer/utils/createStreamAbortController.ts b/src/main/apiServer/utils/createStreamAbortController.ts index 243ad5b96e..e07b9a31f0 100644 --- a/src/main/apiServer/utils/createStreamAbortController.ts +++ b/src/main/apiServer/utils/createStreamAbortController.ts @@ -4,6 +4,7 @@ export interface StreamAbortController { abortController: AbortController registerAbortHandler: (handler: StreamAbortHandler) => void clearAbortTimeout: () => void + dispose: () => void } export const STREAM_TIMEOUT_REASON = 'stream timeout' @@ -40,6 +41,15 @@ export const createStreamAbortController = (options: CreateStreamAbortController signal.addEventListener('abort', handleAbort, { once: true }) + let disposed = false + + const dispose = () => { + if (disposed) return + disposed = true + clearAbortTimeout() + signal.removeEventListener('abort', handleAbort) + } + const registerAbortHandler = (handler: StreamAbortHandler) => { abortHandler = handler @@ -59,6 +69,7 @@ export const createStreamAbortController = (options: CreateStreamAbortController return { abortController, registerAbortHandler, - clearAbortTimeout + clearAbortTimeout, + dispose } } diff --git a/src/main/index.ts b/src/main/index.ts index 536485a490..6f07c769ba 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -75,6 +75,15 @@ if (isLinux && process.env.XDG_SESSION_TYPE === 'wayland') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal') } +/** + * Set window class and name for Linux + * This ensures the window manager identifies the app correctly on both X11 and Wayland + */ +if (isLinux) { + app.commandLine.appendSwitch('class', 'CherryStudio') + app.commandLine.appendSwitch('name', 'CherryStudio') +} + // DocumentPolicyIncludeJSCallStacksInCrashReports: Enable features for unresponsive renderer js call stacks // EarlyEstablishGpuChannel,EstablishGpuChannelAsync: Enable features for early establish gpu channel // speed up the startup time diff --git a/src/main/ipc.ts b/src/main/ipc.ts index ccaa664ab8..4d32ada513 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -900,6 +900,9 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App) ipcMain.handle(IpcChannel.App_SetDisableHardwareAcceleration, (_, isDisable: boolean) => { configManager.setDisableHardwareAcceleration(isDisable) }) + ipcMain.handle(IpcChannel.App_SetUseSystemTitleBar, (_, isActive: boolean) => { + configManager.setUseSystemTitleBar(isActive) + }) ipcMain.handle(IpcChannel.TRACE_SAVE_DATA, (_, topicId: string) => saveSpans(topicId)) ipcMain.handle(IpcChannel.TRACE_GET_DATA, (_, topicId: string, traceId: string, modelName?: string) => getSpans(topicId, traceId, modelName) diff --git a/src/main/knowledge/preprocess/BasePreprocessProvider.ts b/src/main/knowledge/preprocess/BasePreprocessProvider.ts index ed4a5fb8d4..92dbe259b0 100644 --- a/src/main/knowledge/preprocess/BasePreprocessProvider.ts +++ b/src/main/knowledge/preprocess/BasePreprocessProvider.ts @@ -4,7 +4,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { windowService } from '@main/services/WindowService' import { getFileExt, getTempDir } from '@main/utils/file' -import type { FileMetadata, PreprocessProvider } from '@types' +import type { FileMetadata, PreprocessProvider, PreprocessReadPdfResult } from '@types' import { PDFDocument } from 'pdf-lib' const logger = loggerService.withContext('BasePreprocessProvider') @@ -90,7 +90,7 @@ export default abstract class BasePreprocessProvider { return new Promise((resolve) => setTimeout(resolve, ms)) } - public async readPdf(buffer: Buffer) { + public async readPdf(buffer: Buffer): Promise<PreprocessReadPdfResult> { const pdfDoc = await PDFDocument.load(buffer, { ignoreEncryption: true }) return { numPages: pdfDoc.getPageCount() diff --git a/src/main/knowledge/preprocess/MineruPreprocessProvider.ts b/src/main/knowledge/preprocess/MineruPreprocessProvider.ts index 80aec40622..6def76f346 100644 --- a/src/main/knowledge/preprocess/MineruPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/MineruPreprocessProvider.ts @@ -56,8 +56,6 @@ type QuotaResponse = { export default class MineruPreprocessProvider extends BasePreprocessProvider { constructor(provider: PreprocessProvider, userId?: string) { super(provider, userId) - // TODO: remove after free period ends - this.provider.apiKey = this.provider.apiKey || import.meta.env.MAIN_VITE_MINERU_API_KEY } public async parseFile( @@ -65,6 +63,10 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider { file: FileMetadata ): Promise<{ processedFile: FileMetadata; quota: number }> { try { + if (!this.provider.apiKey) { + throw new Error('MinerU API key is required') + } + const filePath = fileStorage.getFilePathById(file) logger.info(`MinerU preprocess processing started: ${filePath}`) await this.validateFile(filePath) @@ -96,6 +98,10 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider { public async checkQuota() { try { + if (!this.provider.apiKey) { + throw new Error('MinerU API key is required') + } + const quota = await net.fetch(`${this.provider.apiHost}/api/v4/quota`, { method: 'GET', headers: { diff --git a/src/main/knowledge/preprocess/PaddleocrPreprocessProvider.ts b/src/main/knowledge/preprocess/PaddleocrPreprocessProvider.ts new file mode 100644 index 0000000000..bd47b4a49c --- /dev/null +++ b/src/main/knowledge/preprocess/PaddleocrPreprocessProvider.ts @@ -0,0 +1,289 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { loggerService } from '@logger' +import { fileStorage } from '@main/services/FileStorage' +import { getFileType } from '@main/utils/file' +import { MB } from '@shared/config/constant' +import type { FileMetadata, PreprocessProvider, PreprocessReadPdfResult } from '@types' +import { net } from 'electron' +import * as z from 'zod' + +import BasePreprocessProvider from './BasePreprocessProvider' + +const logger = loggerService.withContext('PaddleocrPreprocessProvider') + +/** + * 单个文件大小不超过50MB,为避免处理超时,建议每个文件不超过100页。若超过100页,API只解析前100页,后续页将被忽略。 + * 来源:PaddleOCR 官方 API 调用说明 https://aistudio.baidu.com/paddleocr + */ +export const PDF_SIZE_LIMIT_MB = 50 +export const PDF_PAGE_LIMIT = 100 +export const PDF_SIZE_LIMIT_BYTES = PDF_SIZE_LIMIT_MB * MB + +enum FileType { + PDF = 0, + Image = 1 +} + +const ApiResponseSchema = z.looseObject({ + result: z + .looseObject({ + layoutParsingResults: z + .array( + z.looseObject({ + markdown: z.looseObject({ + text: z.string().min(1, 'Markdown text cannot be empty') + }) + }) + ) + .min(1, 'At least one layout parsing result required') + }) + .optional(), + errorCode: z.number().optional(), + errorMsg: z.string().optional() +}) + +type ApiResponse = z.infer<typeof ApiResponseSchema> + +const isApiSuccess = (response: ApiResponse): boolean => { + const hasNoError = !response.errorCode || response.errorCode === 0 + const hasSuccessMsg = !response.errorMsg || /success/i.test(response.errorMsg) + return hasNoError && hasSuccessMsg +} + +function formatZodError(error: z.ZodError): string { + return error.issues + .map((issue) => { + const path = issue.path.join('.') + const code = issue.code + const message = issue.message + return `[${code}] ${path}: ${message}` + }) + .join('; ') +} + +function getErrorMessage(error: unknown): string { + if (error instanceof z.ZodError) { + return formatZodError(error) + } else if (error instanceof Error) { + return error.message + } else if (typeof error === 'string') { + return error + } else { + return 'Unknown error' + } +} + +export default class PaddleocrPreprocessProvider extends BasePreprocessProvider { + constructor(provider: PreprocessProvider, userId?: string) { + super(provider, userId) + } + + /** + * 解析文件并通过 PaddleOCR 进行预处理(当前仅支持 PDF 文件) + * @param sourceId - 源任务ID,用于进度更新/日志追踪 + * @param file - 待处理的文件元数据(仅支持 ext 为 .pdf 的文件) + * @returns {Promise<{processedFile: FileMetadata; quota: number}>} 处理后的文件元数据 + 配额消耗(当前 PaddleOCR 配额为 0) + * @throws {Error} 若传入非 PDF 文件、文件大小超限、页数超限等会抛出异常 + */ + public async parseFile( + sourceId: string, + file: FileMetadata + ): Promise<{ processedFile: FileMetadata; quota: number }> { + try { + const filePath = fileStorage.getFilePathById(file) + logger.info(`PaddleOCR preprocess processing started: ${filePath}`) + + const fileBuffer = await this.validateFile(filePath) + + // 进度条 + await this.sendPreprocessProgress(sourceId, 25) + + // 1.读取pdf文件并编码为base64 + const fileData = fileBuffer.toString('base64') + await this.sendPreprocessProgress(sourceId, 50) + + // 2. 调用PadlleOCR文档处理API + const apiResponse = await this.callPaddleOcrApi(fileData, FileType.PDF) + logger.info(`PaddleOCR API call completed`) + + await this.sendPreprocessProgress(sourceId, 75) + + // 3. 处理 API 错误场景 + if (!isApiSuccess(apiResponse)) { + const errorCode = apiResponse.errorCode ?? -1 + const errorMsg = apiResponse.errorMsg || 'Unknown error' + const fullErrorMsg = `PaddleOCR API processing failed [${errorCode}]: ${errorMsg}` + logger.error(fullErrorMsg) + throw new Error(fullErrorMsg) + } + + // 4. 保存markdown文本 + const outputDir = await this.saveResults(apiResponse.result, file) + + await this.sendPreprocessProgress(sourceId, 100) + + const processedFile = await this.createProcessedFileInfo(file, outputDir) + + // 5. 创建处理后数据 + return { + processedFile, + quota: 0 + } + } catch (error: unknown) { + logger.error(`PaddleOCR preprocess processing failed for:`, error as Error) + throw new Error(getErrorMessage(error)) + } + } + + public async checkQuota(): Promise<number> { + // PaddleOCR doesn't have quota checking, return 0 + return 0 + } + + private getMarkdownFileName(file: FileMetadata): string { + return file.origin_name.replace(/\.(pdf|jpg|jpeg|png)$/i, '.md') + } + + private async validateFile(filePath: string): Promise<Buffer> { + // 阶段1:校验文件类型 + logger.info(`Validating PDF file: ${filePath}`) + const ext = path.extname(filePath).toLowerCase() + if (ext !== '.pdf') { + throw new Error(`File ${filePath} is not a PDF (extension: ${ext.slice(1)})`) + } + + // 阶段2:校验文件大小 + const stats = await fs.promises.stat(filePath) + const fileSizeBytes = stats.size + if (fileSizeBytes > PDF_SIZE_LIMIT_BYTES) { + const fileSizeMB = Math.round(fileSizeBytes / MB) + throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of ${PDF_SIZE_LIMIT_MB}MB`) + } + + // 阶段3:校验页数(兼容 PDF 解析失败的场景) + const pdfBuffer = await fs.promises.readFile(filePath) + let doc: PreprocessReadPdfResult | undefined + + try { + doc = await this.readPdf(pdfBuffer) + } catch (error: unknown) { + // PDF 解析失败:抛异常,跳过页数校验 + const errorMsg = getErrorMessage(error) + logger.error( + `Failed to parse PDF structure (file may be corrupted or use non-standard format). ` + + `Skipping page count validation. Will attempt to process with PaddleOCR API. ` + + `Error details: ${errorMsg}. ` + + `Suggestion: If processing fails, try repairing the PDF using tools like Adobe Acrobat or online PDF repair services.` + ) + throw error + } + + if (doc?.numPages > PDF_PAGE_LIMIT) { + throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of ${PDF_PAGE_LIMIT} pages`) + } + + logger.info(`PDF validation passed: ${doc.numPages} pages, ${Math.round(fileSizeBytes / MB)}MB`) + + return pdfBuffer + } + + private async createProcessedFileInfo(file: FileMetadata, outputDir: string): Promise<FileMetadata> { + const finalMdFileName = this.getMarkdownFileName(file) + const finalMdPath = path.join(outputDir, finalMdFileName) + + const ext = path.extname(finalMdPath) + const type = getFileType(ext) + const fileSize = (await fs.promises.stat(finalMdPath)).size + + return { + ...file, + name: finalMdFileName, + path: finalMdPath, + type: type, + ext: ext, + size: fileSize + } + } + + private async callPaddleOcrApi(fileData: string, fileType: number): Promise<ApiResponse> { + if (!this.provider.apiHost) { + throw new Error('PaddleOCR API host is not configured') + } + + const endpoint = this.provider.apiHost + const payload = { + file: fileData, + fileType: fileType, + useDocOrientationClassify: false, + useDocUnwarping: false, + useTextlineOrientation: false, + useChartRecognition: false + } + + try { + const response = await net.fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Client-Platform': 'cherry-studio', + Authorization: `token ${this.provider.apiKey}` + }, + body: JSON.stringify(payload) + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`PaddleOCR API error: HTTP ${response.status} - ${errorText}`) + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const rawData = await response.json() + logger.debug('PaddleOCR API response', { data: rawData }) + + // Zod 校验响应结构(不合法则直接抛错) + const validatedData = ApiResponseSchema.parse(rawData) + return validatedData // 返回完整响应 + } catch (error: unknown) { + const errorMsg = getErrorMessage(error) + logger.error(`Failed to call PaddleOCR API: ${errorMsg}`, { error }) + throw new Error(`Failed to call PaddleOCR API: ${errorMsg}`) + } + } + + private async saveResults(result: ApiResponse['result'], file: FileMetadata): Promise<string> { + const outputDir = path.join(this.storageDir, file.id) + + // 确保输出目录存在且为空 + if (fs.existsSync(outputDir)) { + fs.rmSync(outputDir, { recursive: true, force: true }) + } + fs.mkdirSync(outputDir, { recursive: true }) + + // 处理 result 为 undefined 的场景(API 无解析结果) + if (!result) { + const errorMsg = `Parsing failed: No valid parsing result from PaddleOCR API for file [ID: ${file.id}]` + // Keep warning log for troubleshooting + logger.error(errorMsg) + // Throw exception to interrupt function execution (no empty file created) + throw new Error(errorMsg) + } + + // Zod 保证:result 存在时,layoutParsingResults 必是非空数组 + const markdownText = result.layoutParsingResults + .filter((layoutResult) => layoutResult?.markdown?.text) + .map((layoutResult) => layoutResult.markdown.text) + .join('\n\n') + + // 直接构造目标文件名 + const finalMdFileName = this.getMarkdownFileName(file) + const finalMdPath = path.join(outputDir, finalMdFileName) + + // 保存 Markdown 文件 + fs.writeFileSync(finalMdPath, markdownText, 'utf-8') + + logger.info(`Saved markdown file: ${finalMdPath}`) + return outputDir + } +} diff --git a/src/main/knowledge/preprocess/PreprocessProviderFactory.ts b/src/main/knowledge/preprocess/PreprocessProviderFactory.ts index 94d4e70d5a..fa858c8f4d 100644 --- a/src/main/knowledge/preprocess/PreprocessProviderFactory.ts +++ b/src/main/knowledge/preprocess/PreprocessProviderFactory.ts @@ -6,6 +6,8 @@ import Doc2xPreprocessProvider from './Doc2xPreprocessProvider' import MineruPreprocessProvider from './MineruPreprocessProvider' import MistralPreprocessProvider from './MistralPreprocessProvider' import OpenMineruPreprocessProvider from './OpenMineruPreprocessProvider' +import PaddleocrPreprocessProvider from './PaddleocrPreprocessProvider' + export default class PreprocessProviderFactory { static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider { switch (provider.id) { @@ -17,6 +19,8 @@ export default class PreprocessProviderFactory { return new MineruPreprocessProvider(provider, userId) case 'open-mineru': return new OpenMineruPreprocessProvider(provider, userId) + case 'paddleocr': + return new PaddleocrPreprocessProvider(provider, userId) default: return new DefaultPreprocessProvider(provider) } diff --git a/src/main/knowledge/reranker/GeneralReranker.ts b/src/main/knowledge/reranker/GeneralReranker.ts index 96beffbb4c..edd49ec514 100644 --- a/src/main/knowledge/reranker/GeneralReranker.ts +++ b/src/main/knowledge/reranker/GeneralReranker.ts @@ -58,7 +58,7 @@ export default class GeneralReranker extends BaseReranker { return this.getRerankResult(searchResults, rerankResults) } catch (error: any) { const errorDetails = this.formatErrorMessage(url, error, requestBody) - throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`) + throw new Error(`Rerank request failed: ${error.message}\nRequest details: ${errorDetails}`) } } } diff --git a/src/main/mcpServers/dify-knowledge.ts b/src/main/mcpServers/dify-knowledge.ts index d1f2c07e1d..d8a1342a08 100644 --- a/src/main/mcpServers/dify-knowledge.ts +++ b/src/main/mcpServers/dify-knowledge.ts @@ -109,7 +109,7 @@ class DifyKnowledgeServer { const parsed = SearchKnowledgeArgsSchema.safeParse(args) if (!parsed.success) { const errorDetails = JSON.stringify(parsed.error.format(), null, 2) - throw new Error(`无效的参数:\n${errorDetails}`) + throw new Error(`Invalid arguments:\n${errorDetails}`) } return await this.performSearchKnowledge( parsed.data.id, @@ -144,7 +144,7 @@ class DifyKnowledgeServer { if (!response.ok) { const errorText = await response.text() - throw new Error(`API 请求失败,状态码 ${response.status}: ${errorText}`) + throw new Error(`API request failed, status code ${response.status}: ${errorText}`) } const apiResponse = await response.json() @@ -161,7 +161,7 @@ class DifyKnowledgeServer { ? knowledges.map((k) => `- **${k.name}** (ID: ${k.id})\n ${k.description || 'No Description'}`).join('\n') : '- No knowledges found.' - const formattedText = `### 可用知识库:\n\n${listText}` + const formattedText = `### Available Knowledge Bases:\n\n${listText}` return { content: [{ type: 'text', text: formattedText }] @@ -206,13 +206,13 @@ class DifyKnowledgeServer { if (!response.ok) { const errorText = await response.text() - throw new Error(`API 请求失败,状态码 ${response.status}: ${errorText}`) + throw new Error(`API request failed, status code ${response.status}: ${errorText}`) } const searchResponse: DifySearchKnowledgeResponse = await response.json() if (!searchResponse || !Array.isArray(searchResponse.records)) { - throw new Error(`从 Dify API 收到的响应格式无效: ${JSON.stringify(searchResponse)}`) + throw new Error(`Invalid response format from Dify API: ${JSON.stringify(searchResponse)}`) } const header = `### Query: ${query}\n\n` diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 909901c1c8..1e9e0bab20 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -9,6 +9,7 @@ import DiDiMcpServer from './didi-mcp' import DifyKnowledgeServer from './dify-knowledge' import FetchServer from './fetch' import FileSystemServer from './filesystem' +import HubServer from './hub' import MemoryServer from './memory' import PythonServer from './python' import ThinkingServer from './sequentialthinking' @@ -52,6 +53,9 @@ export function createInMemoryMCPServer( case BuiltinMCPServerNames.browser: { return new BrowserServer().server } + case BuiltinMCPServerNames.hub: { + return new HubServer().server + } default: throw new Error(`Unknown in-memory MCP server: ${name}`) } diff --git a/src/main/mcpServers/hub/README.md b/src/main/mcpServers/hub/README.md new file mode 100644 index 0000000000..e15141b9c5 --- /dev/null +++ b/src/main/mcpServers/hub/README.md @@ -0,0 +1,213 @@ +# Hub MCP Server + +A built-in MCP server that aggregates all active MCP servers in Cherry Studio and exposes them through `search` and `exec` tools. + +## Overview + +The Hub server enables LLMs to discover and call tools from all active MCP servers without needing to know the specific server names or tool signatures upfront. + +## Auto Mode Integration + +The Hub server is the core component of Cherry Studio's **Auto MCP Mode**. When an assistant is set to Auto mode: + +1. **Automatic Injection**: The Hub server is automatically injected as the only MCP server for the assistant +2. **System Prompt**: A specialized system prompt (`HUB_MODE_SYSTEM_PROMPT`) is appended to guide the LLM on how to use the `search` and `exec` tools +3. **Dynamic Discovery**: The LLM can discover and use any tools from all active MCP servers without manual configuration + +### MCP Modes + +Cherry Studio supports three MCP modes per assistant: + +| Mode | Description | Tools Available | +|------|-------------|-----------------| +| **Disabled** | No MCP tools | None | +| **Auto** | Hub server only | `search`, `exec` | +| **Manual** | User selects servers | Selected server tools | + +### How Auto Mode Works + +``` +User Message + │ + ▼ +┌─────────────────────────────────────────┐ +│ Assistant (mcpMode: 'auto') │ +│ │ +│ System Prompt + HUB_MODE_SYSTEM_PROMPT │ +│ Tools: [hub.search, hub.exec] │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ LLM decides to use MCP tools │ +│ │ +│ 1. search({ query: "github,repo" }) │ +│ 2. exec({ code: "await searchRepos()" })│ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Hub Server │ +│ │ +│ Aggregates all active MCP servers │ +│ Routes tool calls to appropriate server │ +└─────────────────────────────────────────┘ +``` + +### Relevant Code + +- **Type Definition**: `src/renderer/src/types/index.ts` - `McpMode` type and `getEffectiveMcpMode()` +- **Hub Server Constant**: `src/renderer/src/store/mcp.ts` - `hubMCPServer` +- **Server Selection**: `src/renderer/src/services/ApiService.ts` - `getMcpServersForAssistant()` +- **System Prompt**: `src/renderer/src/config/prompts.ts` - `HUB_MODE_SYSTEM_PROMPT` +- **Prompt Injection**: `src/renderer/src/aiCore/prepareParams/parameterBuilder.ts` + +## Tools + +### `search` + +Search for available MCP tools by keywords. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `query` | string | Yes | Search keywords, comma-separated for OR matching | +| `limit` | number | No | Maximum results to return (default: 10, max: 50) | + +**Example:** +```json +{ + "query": "browser,chrome", + "limit": 5 +} +``` + +**Returns:** JavaScript function declarations with JSDoc comments that can be used in the `exec` tool. + +```javascript +// Found 2 tool(s): + +/** + * Launch a browser instance + * + * @param {{ browser?: "chromium" | "firefox" | "webkit", headless?: boolean }} params + * @returns {Promise<unknown>} + */ +async function launchBrowser(params) { + return await __callTool("browser__launch_browser", params); +} +``` + +### `exec` + +Execute JavaScript code that calls MCP tools. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `code` | string | Yes | JavaScript code to execute | + +**Built-in Helpers:** +- `parallel(...promises)` - Run multiple tool calls concurrently (Promise.all) +- `settle(...promises)` - Run multiple tool calls and get all results (Promise.allSettled) +- `console.log/warn/error/info/debug` - Captured in output logs + +**Example:** +```javascript +// Call a single tool +const result = await searchRepos({ query: "react" }); +return result; + +// Call multiple tools in parallel +const [users, repos] = await parallel( + getUsers({ limit: 10 }), + searchRepos({ query: "typescript" }) +); +return { users, repos }; +``` + +**Returns:** +```json +{ + "result": { "users": [...], "repos": [...] }, + "logs": ["[log] Processing..."], + "error": null +} +``` + +## Usage Flow + +1. **Search** for tools using keywords: + ``` + search({ query: "github,repository" }) + ``` + +2. **Review** the returned function signatures and JSDoc + +3. **Execute** code using the discovered tools: + ``` + exec({ code: 'return await searchRepos({ query: "react" })' }) + ``` + +## Configuration + +The Hub server is a built-in server identified as `@cherry/hub`. + +### Using Auto Mode (Recommended) + +The easiest way to use the Hub server is through Auto mode: + +1. Click the **MCP Tools** button (hammer icon) in the input bar +2. Select **Auto** mode +3. The Hub server is automatically enabled for the assistant + +### Manual Configuration + +Alternatively, you can enable the Hub server manually: + +1. Go to **Settings** → **MCP Servers** +2. Find **Hub** in the built-in servers list +3. Toggle it on +4. In the assistant's MCP settings, select the Hub server + +## Caching + +- Tool definitions are cached for **10 minutes** +- Cache is automatically refreshed when expired +- Cache is invalidated when MCP servers connect/disconnect + +## Limitations + +- Code execution has a **60-second timeout** +- Console logs are limited to **1000 entries** +- Search results are limited to **50 tools** maximum +- The Hub server excludes itself from the aggregated server list + +## Architecture + +``` +LLM + │ + ▼ +HubServer + ├── search → ToolRegistry → SearchIndex + └── exec → Runtime → callMcpTool() + │ + ▼ + MCPService.callTool() + │ + ▼ + External MCP Servers +``` + +## Files + +| File | Description | +|------|-------------| +| `index.ts` | Main HubServer class | +| `types.ts` | TypeScript type definitions | +| `generator.ts` | Converts MCP tools to JS functions with JSDoc | +| `tool-registry.ts` | In-memory tool cache with TTL | +| `search.ts` | Keyword-based tool search | +| `runtime.ts` | JavaScript code execution engine | +| `mcp-bridge.ts` | Bridge to Cherry Studio's MCPService | diff --git a/src/main/mcpServers/hub/__tests__/generator.test.ts b/src/main/mcpServers/hub/__tests__/generator.test.ts new file mode 100644 index 0000000000..772a5e3c58 --- /dev/null +++ b/src/main/mcpServers/hub/__tests__/generator.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import { generateToolFunction, generateToolsCode } from '../generator' +import type { GeneratedTool } from '../types' + +describe('generator', () => { + describe('generateToolFunction', () => { + it('generates a simple tool function', () => { + const tool = { + id: 'test-id', + name: 'search_repos', + description: 'Search for GitHub repositories', + serverId: 'github', + serverName: 'github-server', + inputSchema: { + type: 'object' as const, + properties: { + query: { type: 'string', description: 'Search query' }, + limit: { type: 'number', description: 'Max results' } + }, + required: ['query'] + }, + type: 'mcp' as const + } + + const existingNames = new Set<string>() + const callTool = async () => ({ success: true }) + + const result = generateToolFunction(tool, existingNames, callTool) + + expect(result.functionName).toBe('githubServer_searchRepos') + expect(result.jsCode).toContain('async function githubServer_searchRepos') + expect(result.jsCode).toContain('Search for GitHub repositories') + expect(result.jsCode).toContain('__callTool') + }) + + it('handles unique function names', () => { + const tool = { + id: 'test-id', + name: 'search', + serverId: 'server1', + serverName: 'server1', + inputSchema: { type: 'object' as const, properties: {} }, + type: 'mcp' as const + } + + const existingNames = new Set<string>(['server1_search']) + const callTool = async () => ({}) + + const result = generateToolFunction(tool, existingNames, callTool) + + expect(result.functionName).toBe('server1_search1') + }) + + it('handles enum types in schema', () => { + const tool = { + id: 'test-id', + name: 'launch_browser', + serverId: 'browser', + serverName: 'browser', + inputSchema: { + type: 'object' as const, + properties: { + browser: { + type: 'string', + enum: ['chromium', 'firefox', 'webkit'] + } + } + }, + type: 'mcp' as const + } + + const existingNames = new Set<string>() + const callTool = async () => ({}) + + const result = generateToolFunction(tool, existingNames, callTool) + + expect(result.jsCode).toContain('"chromium" | "firefox" | "webkit"') + }) + }) + + describe('generateToolsCode', () => { + it('generates code for multiple tools', () => { + const tools: GeneratedTool[] = [ + { + serverId: 's1', + serverName: 'server1', + toolName: 'tool1', + functionName: 'server1_tool1', + jsCode: 'async function server1_tool1() {}', + fn: async () => ({}), + signature: '{}', + returns: 'unknown' + }, + { + serverId: 's2', + serverName: 'server2', + toolName: 'tool2', + functionName: 'server2_tool2', + jsCode: 'async function server2_tool2() {}', + fn: async () => ({}), + signature: '{}', + returns: 'unknown' + } + ] + + const result = generateToolsCode(tools) + + expect(result).toContain('2 tool(s)') + expect(result).toContain('async function server1_tool1') + expect(result).toContain('async function server2_tool2') + }) + + it('returns message for empty tools', () => { + const result = generateToolsCode([]) + expect(result).toBe('// No tools available') + }) + }) +}) diff --git a/src/main/mcpServers/hub/__tests__/hub.test.ts b/src/main/mcpServers/hub/__tests__/hub.test.ts new file mode 100644 index 0000000000..51ec727ee9 --- /dev/null +++ b/src/main/mcpServers/hub/__tests__/hub.test.ts @@ -0,0 +1,229 @@ +import type { MCPTool } from '@types' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { HubServer } from '../index' + +const mockTools: MCPTool[] = [ + { + id: 'github__search_repos', + name: 'search_repos', + description: 'Search for GitHub repositories', + serverId: 'github', + serverName: 'GitHub', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + limit: { type: 'number', description: 'Max results' } + }, + required: ['query'] + }, + type: 'mcp' + }, + { + id: 'github__get_user', + name: 'get_user', + description: 'Get GitHub user profile', + serverId: 'github', + serverName: 'GitHub', + inputSchema: { + type: 'object', + properties: { + username: { type: 'string', description: 'GitHub username' } + }, + required: ['username'] + }, + type: 'mcp' + }, + { + id: 'database__query', + name: 'query', + description: 'Execute a database query', + serverId: 'database', + serverName: 'Database', + inputSchema: { + type: 'object', + properties: { + sql: { type: 'string', description: 'SQL query to execute' } + }, + required: ['sql'] + }, + type: 'mcp' + } +] + +vi.mock('@main/services/MCPService', () => ({ + default: { + listAllActiveServerTools: vi.fn(async () => mockTools), + callToolById: vi.fn(async (toolId: string, args: unknown) => { + if (toolId === 'github__search_repos') { + return { + content: [{ type: 'text', text: JSON.stringify({ repos: ['repo1', 'repo2'], query: args }) }] + } + } + if (toolId === 'github__get_user') { + return { + content: [{ type: 'text', text: JSON.stringify({ username: (args as any).username, id: 123 }) }] + } + } + if (toolId === 'database__query') { + return { + content: [{ type: 'text', text: JSON.stringify({ rows: [{ id: 1 }, { id: 2 }] }) }] + } + } + return { content: [{ type: 'text', text: '{}' }] } + }), + abortTool: vi.fn(async () => true) + } +})) + +import mcpService from '@main/services/MCPService' + +describe('HubServer Integration', () => { + let hubServer: HubServer + + beforeEach(() => { + vi.clearAllMocks() + hubServer = new HubServer() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('full search → exec flow', () => { + it('searches for tools and executes them', async () => { + const searchResult = await (hubServer as any).handleSearch({ query: 'github,repos' }) + + expect(searchResult.content).toBeDefined() + const searchText = JSON.parse(searchResult.content[0].text) + expect(searchText.total).toBeGreaterThan(0) + expect(searchText.tools).toContain('github_searchRepos') + + const execResult = await (hubServer as any).handleExec({ + code: 'return await github_searchRepos({ query: "test" })' + }) + + expect(execResult.content).toBeDefined() + const execOutput = JSON.parse(execResult.content[0].text) + expect(execOutput.result).toEqual({ repos: ['repo1', 'repo2'], query: { query: 'test' } }) + }) + + it('handles multiple tool calls in parallel', async () => { + await (hubServer as any).handleSearch({ query: 'github' }) + + const execResult = await (hubServer as any).handleExec({ + code: ` + const results = await parallel( + github_searchRepos({ query: "react" }), + github_getUser({ username: "octocat" }) + ); + return results + ` + }) + + const execOutput = JSON.parse(execResult.content[0].text) + expect(execOutput.result).toHaveLength(2) + expect(execOutput.result[0]).toEqual({ repos: ['repo1', 'repo2'], query: { query: 'react' } }) + expect(execOutput.result[1]).toEqual({ username: 'octocat', id: 123 }) + }) + + it('searches across multiple servers', async () => { + const searchResult = await (hubServer as any).handleSearch({ query: 'query' }) + + const searchText = JSON.parse(searchResult.content[0].text) + expect(searchText.tools).toContain('database_query') + }) + }) + + describe('tools caching', () => { + it('uses cached tools within TTL', async () => { + await (hubServer as any).handleSearch({ query: 'github' }) + const firstCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length + + await (hubServer as any).handleSearch({ query: 'github' }) + const secondCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length + + expect(secondCallCount).toBe(firstCallCount) // Should use cache + }) + + it('refreshes tools after cache invalidation', async () => { + await (hubServer as any).handleSearch({ query: 'github' }) + const firstCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length + + hubServer.invalidateCache() + + await (hubServer as any).handleSearch({ query: 'github' }) + const secondCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length + + expect(secondCallCount).toBe(firstCallCount + 1) + }) + }) + + describe('error handling', () => { + it('throws error for invalid search query', async () => { + await expect((hubServer as any).handleSearch({})).rejects.toThrow('query parameter is required') + }) + + it('throws error for invalid exec code', async () => { + await expect((hubServer as any).handleExec({})).rejects.toThrow('code parameter is required') + }) + + it('handles runtime errors in exec', async () => { + const execResult = await (hubServer as any).handleExec({ + code: 'throw new Error("test error")' + }) + + const execOutput = JSON.parse(execResult.content[0].text) + expect(execOutput.error).toBe('test error') + expect(execOutput.isError).toBe(true) + }) + }) + + describe('exec timeouts', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('aborts in-flight tool calls and returns logs on timeout', async () => { + vi.useFakeTimers() + + let toolCallStarted: (() => void) | null = null + const toolCallStartedPromise = new Promise<void>((resolve) => { + toolCallStarted = resolve + }) + + vi.mocked(mcpService.callToolById).mockImplementationOnce(async () => { + toolCallStarted?.() + return await new Promise(() => {}) + }) + + const execPromise = (hubServer as any).handleExec({ + code: ` + console.log("starting"); + return await github_searchRepos({ query: "hang" }); + ` + }) + + await toolCallStartedPromise + await vi.advanceTimersByTimeAsync(60000) + await vi.runAllTimersAsync() + + const execResult = await execPromise + const execOutput = JSON.parse(execResult.content[0].text) + + expect(execOutput.error).toBe('Execution timed out after 60000ms') + expect(execOutput.result).toBeUndefined() + expect(execOutput.isError).toBe(true) + expect(execOutput.logs).toContain('[log] starting') + expect(vi.mocked(mcpService.abortTool)).toHaveBeenCalled() + }) + }) + + describe('server instance', () => { + it('creates a valid MCP server instance', () => { + expect(hubServer.server).toBeDefined() + expect(hubServer.server.setRequestHandler).toBeDefined() + }) + }) +}) diff --git a/src/main/mcpServers/hub/__tests__/runtime.test.ts b/src/main/mcpServers/hub/__tests__/runtime.test.ts new file mode 100644 index 0000000000..5268aa6dec --- /dev/null +++ b/src/main/mcpServers/hub/__tests__/runtime.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest' + +import { Runtime } from '../runtime' +import type { GeneratedTool } from '../types' + +vi.mock('../mcp-bridge', () => ({ + callMcpTool: vi.fn(async (toolId: string, params: unknown) => { + if (toolId === 'server__failing_tool') { + throw new Error('Tool failed') + } + return { toolId, params, success: true } + }) +})) + +const createMockTool = (partial: Partial<GeneratedTool>): GeneratedTool => ({ + serverId: 'server1', + serverName: 'server1', + toolName: 'tool', + functionName: 'server1_mockTool', + jsCode: 'async function server1_mockTool() {}', + fn: async (params) => ({ result: params }), + signature: '{}', + returns: 'unknown', + ...partial +}) + +describe('Runtime', () => { + describe('execute', () => { + it('executes simple code and returns result', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute('return 1 + 1', tools) + + expect(result.result).toBe(2) + expect(result.error).toBeUndefined() + }) + + it('executes async code', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute('return await Promise.resolve(42)', tools) + + expect(result.result).toBe(42) + }) + + it('calls tool functions', async () => { + const runtime = new Runtime() + const tools = [ + createMockTool({ + functionName: 'searchRepos', + fn: async (params) => ({ repos: ['repo1', 'repo2'], query: params }) + }) + ] + + const result = await runtime.execute('return await searchRepos({ query: "test" })', tools) + + expect(result.result).toEqual({ toolId: 'searchRepos', params: { query: 'test' }, success: true }) + }) + + it('captures console logs', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute( + ` + console.log("hello"); + console.warn("warning"); + return "done" + `, + tools + ) + + expect(result.result).toBe('done') + expect(result.logs).toContain('[log] hello') + expect(result.logs).toContain('[warn] warning') + }) + + it('handles errors gracefully', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute('throw new Error("test error")', tools) + + expect(result.result).toBeUndefined() + expect(result.error).toBe('test error') + expect(result.isError).toBe(true) + }) + + it('supports parallel helper', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute( + ` + const results = await parallel( + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3) + ); + return results + `, + tools + ) + + expect(result.result).toEqual([1, 2, 3]) + }) + + it('supports settle helper', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute( + ` + const results = await settle( + Promise.resolve(1), + Promise.reject(new Error("fail")) + ); + return results.map(r => r.status) + `, + tools + ) + + expect(result.result).toEqual(['fulfilled', 'rejected']) + }) + + it('returns last expression when no explicit return', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute( + ` + const x = 10; + const y = 20; + return x + y + `, + tools + ) + + expect(result.result).toBe(30) + }) + + it('stops execution when a tool throws', async () => { + const runtime = new Runtime() + const tools = [ + createMockTool({ + functionName: 'server__failing_tool' + }) + ] + + const result = await runtime.execute('return await server__failing_tool({})', tools) + + expect(result.result).toBeUndefined() + expect(result.error).toBe('Tool failed') + expect(result.isError).toBe(true) + }) + }) +}) diff --git a/src/main/mcpServers/hub/__tests__/search.test.ts b/src/main/mcpServers/hub/__tests__/search.test.ts new file mode 100644 index 0000000000..4e483003f2 --- /dev/null +++ b/src/main/mcpServers/hub/__tests__/search.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' + +import { searchTools } from '../search' +import type { GeneratedTool } from '../types' + +const createMockTool = (partial: Partial<GeneratedTool>): GeneratedTool => { + const functionName = partial.functionName || 'server1_tool' + return { + serverId: 'server1', + serverName: 'server1', + toolName: partial.toolName || 'tool', + functionName, + jsCode: `async function ${functionName}() {}`, + fn: async () => ({}), + signature: '{}', + returns: 'unknown', + ...partial + } +} + +describe('search', () => { + describe('searchTools', () => { + it('returns all tools when query is empty', () => { + const tools = [ + createMockTool({ toolName: 'tool1', functionName: 'tool1' }), + createMockTool({ toolName: 'tool2', functionName: 'tool2' }) + ] + + const result = searchTools(tools, { query: '' }) + + expect(result.total).toBe(2) + expect(result.tools).toContain('tool1') + expect(result.tools).toContain('tool2') + }) + + it('filters tools by single keyword', () => { + const tools = [ + createMockTool({ toolName: 'search_repos', functionName: 'searchRepos' }), + createMockTool({ toolName: 'get_user', functionName: 'getUser' }), + createMockTool({ toolName: 'search_users', functionName: 'searchUsers' }) + ] + + const result = searchTools(tools, { query: 'search' }) + + expect(result.total).toBe(2) + expect(result.tools).toContain('searchRepos') + expect(result.tools).toContain('searchUsers') + expect(result.tools).not.toContain('getUser') + }) + + it('supports OR matching with comma-separated keywords', () => { + const tools = [ + createMockTool({ toolName: 'browser_open', functionName: 'browserOpen' }), + createMockTool({ toolName: 'chrome_launch', functionName: 'chromeLaunch' }), + createMockTool({ toolName: 'file_read', functionName: 'fileRead' }) + ] + + const result = searchTools(tools, { query: 'browser,chrome' }) + + expect(result.total).toBe(2) + expect(result.tools).toContain('browserOpen') + expect(result.tools).toContain('chromeLaunch') + expect(result.tools).not.toContain('fileRead') + }) + + it('matches against description', () => { + const tools = [ + createMockTool({ + toolName: 'launch', + functionName: 'launch', + description: 'Launch a browser instance' + }), + createMockTool({ + toolName: 'close', + functionName: 'close', + description: 'Close a window' + }) + ] + + const result = searchTools(tools, { query: 'browser' }) + + expect(result.total).toBe(1) + expect(result.tools).toContain('launch') + }) + + it('respects limit parameter', () => { + const tools = Array.from({ length: 20 }, (_, i) => + createMockTool({ toolName: `tool${i}`, functionName: `server1_tool${i}` }) + ) + + const result = searchTools(tools, { query: 'tool', limit: 5 }) + + expect(result.total).toBe(20) + const matches = (result.tools.match(/async function server1_tool\d+/g) || []).length + expect(matches).toBe(5) + }) + + it('is case insensitive', () => { + const tools = [createMockTool({ toolName: 'SearchRepos', functionName: 'searchRepos' })] + + const result = searchTools(tools, { query: 'SEARCH' }) + + expect(result.total).toBe(1) + }) + + it('ranks exact matches higher', () => { + const tools = [ + createMockTool({ toolName: 'searching', functionName: 'searching' }), + createMockTool({ toolName: 'search', functionName: 'search' }), + createMockTool({ toolName: 'search_more', functionName: 'searchMore' }) + ] + + const result = searchTools(tools, { query: 'search', limit: 1 }) + + expect(result.tools).toContain('function search(') + }) + }) +}) diff --git a/src/main/mcpServers/hub/generator.ts b/src/main/mcpServers/hub/generator.ts new file mode 100644 index 0000000000..523e6c864f --- /dev/null +++ b/src/main/mcpServers/hub/generator.ts @@ -0,0 +1,152 @@ +import { generateMcpToolFunctionName } from '@shared/mcp' +import type { MCPTool } from '@types' + +import type { GeneratedTool } from './types' + +type PropertySchema = Record<string, unknown> +type InputSchema = { + type?: string + properties?: Record<string, PropertySchema> + required?: string[] +} + +function schemaTypeToTS(prop: Record<string, unknown>): string { + const type = prop.type as string | string[] | undefined + const enumValues = prop.enum as unknown[] | undefined + + if (enumValues && Array.isArray(enumValues)) { + return enumValues.map((v) => (typeof v === 'string' ? `"${v}"` : String(v))).join(' | ') + } + + if (Array.isArray(type)) { + return type.map((t) => primitiveTypeToTS(t)).join(' | ') + } + + if (type === 'array') { + const items = prop.items as Record<string, unknown> | undefined + if (items) { + return `${schemaTypeToTS(items)}[]` + } + return 'unknown[]' + } + + if (type === 'object') { + return 'object' + } + + return primitiveTypeToTS(type) +} + +function primitiveTypeToTS(type: string | undefined): string { + switch (type) { + case 'string': + return 'string' + case 'number': + case 'integer': + return 'number' + case 'boolean': + return 'boolean' + case 'null': + return 'null' + default: + return 'unknown' + } +} + +function jsonSchemaToSignature(schema: Record<string, unknown> | undefined): string { + if (!schema || typeof schema !== 'object') { + return '{}' + } + + const properties = schema.properties as Record<string, Record<string, unknown>> | undefined + if (!properties) { + return '{}' + } + + const required = (schema.required as string[]) || [] + const parts: string[] = [] + + for (const [key, prop] of Object.entries(properties)) { + const isRequired = required.includes(key) + const typeStr = schemaTypeToTS(prop) + parts.push(`${key}${isRequired ? '' : '?'}: ${typeStr}`) + } + + return `{ ${parts.join(', ')} }` +} + +function generateJSDoc(tool: MCPTool, inputSchema: InputSchema | undefined, returns: string): string { + const lines: string[] = ['/**'] + + if (tool.description) { + const desc = tool.description.split('\n')[0] + lines.push(` * ${desc}`) + } + + const properties = inputSchema?.properties || {} + const required = inputSchema?.required || [] + + if (Object.keys(properties).length > 0) { + lines.push(` * @param {Object} params`) + for (const [name, prop] of Object.entries(properties)) { + const isReq = required.includes(name) + const type = schemaTypeToTS(prop) + const paramName = isReq ? `params.${name}` : `[params.${name}]` + const desc = (prop.description as string)?.split('\n')[0] || '' + lines.push(` * @param {${type}} ${paramName} ${desc}`) + } + } + + lines.push(` * @returns {Promise<${returns}>}`) + lines.push(` */`) + + return lines.join('\n') +} + +export function generateToolFunction( + tool: MCPTool, + existingNames: Set<string>, + callToolFn: (functionName: string, params: unknown) => Promise<unknown> +): GeneratedTool { + const functionName = generateMcpToolFunctionName(tool.serverName, tool.name, existingNames) + + const inputSchema = tool.inputSchema as InputSchema | undefined + const outputSchema = tool.outputSchema as Record<string, unknown> | undefined + + const signature = jsonSchemaToSignature(inputSchema) + const returns = outputSchema ? jsonSchemaToSignature(outputSchema) : 'unknown' + + const jsDoc = generateJSDoc(tool, inputSchema, returns) + + const jsCode = `${jsDoc} +async function ${functionName}(params) { + return await __callTool("${functionName}", params); +}` + + const fn = async (params: unknown): Promise<unknown> => { + return await callToolFn(functionName, params) + } + + return { + serverId: tool.serverId, + serverName: tool.serverName, + toolName: tool.name, + functionName, + jsCode, + fn, + signature, + returns, + description: tool.description + } +} + +export function generateToolsCode(tools: GeneratedTool[]): string { + if (tools.length === 0) { + return '// No tools available' + } + + const header = `// ${tools.length} tool(s). ALWAYS use: const r = await ToolName({...}); return r;` + const code = tools.map((t) => t.jsCode).join('\n\n') + + return header + '\n\n' + code +} diff --git a/src/main/mcpServers/hub/index.ts b/src/main/mcpServers/hub/index.ts new file mode 100644 index 0000000000..2c55075a0d --- /dev/null +++ b/src/main/mcpServers/hub/index.ts @@ -0,0 +1,184 @@ +import { loggerService } from '@logger' +import { CacheService } from '@main/services/CacheService' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js' + +import { generateToolFunction } from './generator' +import { callMcpTool, clearToolMap, listAllTools, syncToolMapFromGeneratedTools } from './mcp-bridge' +import { Runtime } from './runtime' +import { searchTools } from './search' +import type { ExecInput, GeneratedTool, SearchQuery } from './types' + +const logger = loggerService.withContext('MCPServer:Hub') +const TOOLS_CACHE_KEY = 'hub:tools' +const TOOLS_CACHE_TTL = 60 * 1000 // 1 minute + +/** + * Hub MCP Server - A meta-server that aggregates all active MCP servers. + * + * This server is NOT included in builtinMCPServers because: + * 1. It aggregates tools from all other MCP servers, not a standalone tool provider + * 2. It's designed for LLM "code mode" - enabling AI to discover and call tools programmatically + * 3. It should be auto-enabled when code mode features are used, not manually installed by users + * + * The server exposes two tools: + * - `search`: Find available tools by keywords, returns JS function signatures + * - `exec`: Execute JavaScript code that calls discovered tools + */ +export class HubServer { + public server: Server + private runtime: Runtime + + constructor() { + this.runtime = new Runtime() + + this.server = new Server( + { + name: 'hub-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ) + + this.setupRequestHandlers() + } + + private setupRequestHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: 'search', + description: + 'Search for available MCP tools by keywords. Use this FIRST to discover tools. Returns JavaScript async function declarations with JSDoc showing exact function names, parameters, and return types for use in `exec`.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Comma-separated search keywords. A tool matches if ANY keyword appears in its name, description, or server name. Example: "chrome,browser,tab" matches tools related to Chrome OR browser OR tabs.' + }, + limit: { + type: 'number', + description: 'Maximum number of tools to return (default: 10, max: 50)' + } + }, + required: ['query'] + } + }, + { + name: 'exec', + description: + 'Execute JavaScript that calls MCP tools discovered via `search`. IMPORTANT: You MUST explicitly `return` the final value, or the result will be `undefined`.', + inputSchema: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'JavaScript code to execute. The code runs inside an async context, so use `await` directly. Do NOT wrap your code in `(async () => { ... })()` - this causes double-wrapping and returns undefined. All discovered tools are async functions (call as `await ToolName(params)`). Helpers: `parallel(...promises)`, `settle(...promises)`, `console.*`. You MUST `return` the final value. Examples: `const r = await Tool({ id: "1" }); return r` or `return await Tool({ x: 1 })`' + } + }, + required: ['code'] + } + } + ] + } + }) + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + + if (!args) { + throw new McpError(ErrorCode.InvalidParams, 'No arguments provided') + } + + try { + switch (name) { + case 'search': + return await this.handleSearch(args as unknown as SearchQuery) + case 'exec': + return await this.handleExec(args as unknown as ExecInput) + default: + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`) + } + } catch (error) { + if (error instanceof McpError) { + throw error + } + logger.error(`Error executing tool ${name}:`, error as Error) + throw new McpError( + ErrorCode.InternalError, + `Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}` + ) + } + }) + } + + private async fetchTools(): Promise<GeneratedTool[]> { + const cached = CacheService.get<GeneratedTool[]>(TOOLS_CACHE_KEY) + if (cached) { + logger.debug('Returning cached tools') + syncToolMapFromGeneratedTools(cached) + return cached + } + + logger.debug('Fetching fresh tools') + const allTools = await listAllTools() + const existingNames = new Set<string>() + const tools = allTools.map((tool) => generateToolFunction(tool, existingNames, callMcpTool)) + CacheService.set(TOOLS_CACHE_KEY, tools, TOOLS_CACHE_TTL) + syncToolMapFromGeneratedTools(tools) + return tools + } + + invalidateCache(): void { + CacheService.remove(TOOLS_CACHE_KEY) + clearToolMap() + logger.debug('Tools cache invalidated') + } + + private async handleSearch(query: SearchQuery) { + if (!query.query || typeof query.query !== 'string') { + throw new McpError(ErrorCode.InvalidParams, 'query parameter is required and must be a string') + } + + const tools = await this.fetchTools() + const result = searchTools(tools, query) + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ] + } + } + + private async handleExec(input: ExecInput) { + if (!input.code || typeof input.code !== 'string') { + throw new McpError(ErrorCode.InvalidParams, 'code parameter is required and must be a string') + } + + const tools = await this.fetchTools() + const result = await this.runtime.execute(input.code, tools) + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ], + isError: result.isError + } + } +} + +export default HubServer diff --git a/src/main/mcpServers/hub/mcp-bridge.ts b/src/main/mcpServers/hub/mcp-bridge.ts new file mode 100644 index 0000000000..9e45eeba0d --- /dev/null +++ b/src/main/mcpServers/hub/mcp-bridge.ts @@ -0,0 +1,96 @@ +/** + * Bridge module for Hub server to access MCPService. + * Re-exports the methods needed by tool-registry and runtime. + */ +import mcpService from '@main/services/MCPService' +import { generateMcpToolFunctionName } from '@shared/mcp' +import type { MCPCallToolResponse, MCPTool, MCPToolResultContent } from '@types' + +import type { GeneratedTool } from './types' + +export const listAllTools = () => mcpService.listAllActiveServerTools() + +const toolFunctionNameToIdMap = new Map<string, { serverId: string; toolName: string }>() + +export async function refreshToolMap(): Promise<void> { + const tools = await listAllTools() + syncToolMapFromTools(tools) +} + +export function syncToolMapFromTools(tools: MCPTool[]): void { + toolFunctionNameToIdMap.clear() + const existingNames = new Set<string>() + for (const tool of tools) { + const functionName = generateMcpToolFunctionName(tool.serverName, tool.name, existingNames) + toolFunctionNameToIdMap.set(functionName, { serverId: tool.serverId, toolName: tool.name }) + } +} + +export function syncToolMapFromGeneratedTools(tools: GeneratedTool[]): void { + toolFunctionNameToIdMap.clear() + for (const tool of tools) { + toolFunctionNameToIdMap.set(tool.functionName, { serverId: tool.serverId, toolName: tool.toolName }) + } +} + +export function clearToolMap(): void { + toolFunctionNameToIdMap.clear() +} + +export const callMcpTool = async (functionName: string, params: unknown, callId?: string): Promise<unknown> => { + const toolInfo = toolFunctionNameToIdMap.get(functionName) + if (!toolInfo) { + await refreshToolMap() + const retryToolInfo = toolFunctionNameToIdMap.get(functionName) + if (!retryToolInfo) { + throw new Error(`Tool not found: ${functionName}`) + } + const toolId = `${retryToolInfo.serverId}__${retryToolInfo.toolName}` + const result = await mcpService.callToolById(toolId, params, callId) + throwIfToolError(result) + return extractToolResult(result) + } + const toolId = `${toolInfo.serverId}__${toolInfo.toolName}` + const result = await mcpService.callToolById(toolId, params, callId) + throwIfToolError(result) + return extractToolResult(result) +} + +export const abortMcpTool = async (callId: string): Promise<boolean> => { + return mcpService.abortTool(null as unknown as Electron.IpcMainInvokeEvent, callId) +} + +function extractToolResult(result: MCPCallToolResponse): unknown { + if (!result.content || result.content.length === 0) { + return null + } + + const textContent = result.content.find((c) => c.type === 'text') + if (textContent?.text) { + try { + return JSON.parse(textContent.text) + } catch { + return textContent.text + } + } + + return result.content +} + +function throwIfToolError(result: MCPCallToolResponse): void { + if (!result.isError) { + return + } + + const textContent = extractTextContent(result.content) + throw new Error(textContent ?? 'Tool execution failed') +} + +function extractTextContent(content: MCPToolResultContent[] | undefined): string | undefined { + if (!content || content.length === 0) { + return undefined + } + + const textBlock = content.find((item) => item.type === 'text' && item.text) + return textBlock?.text +} diff --git a/src/main/mcpServers/hub/runtime.ts b/src/main/mcpServers/hub/runtime.ts new file mode 100644 index 0000000000..dd33fc9ff0 --- /dev/null +++ b/src/main/mcpServers/hub/runtime.ts @@ -0,0 +1,170 @@ +import crypto from 'node:crypto' +import { Worker } from 'node:worker_threads' + +import { loggerService } from '@logger' + +import { abortMcpTool, callMcpTool } from './mcp-bridge' +import type { + ExecOutput, + GeneratedTool, + HubWorkerCallToolMessage, + HubWorkerExecMessage, + HubWorkerMessage, + HubWorkerResultMessage +} from './types' +import { hubWorkerSource } from './worker' + +const logger = loggerService.withContext('MCPServer:Hub:Runtime') + +const MAX_LOGS = 1000 +const EXECUTION_TIMEOUT = 60000 + +export class Runtime { + async execute(code: string, tools: GeneratedTool[]): Promise<ExecOutput> { + return await new Promise<ExecOutput>((resolve) => { + const logs: string[] = [] + const activeCallIds = new Map<string, string>() + let finished = false + let timedOut = false + let timeoutId: NodeJS.Timeout | null = null + + const worker = new Worker(hubWorkerSource, { eval: true }) + + const addLog = (entry: string) => { + if (logs.length >= MAX_LOGS) { + return + } + logs.push(entry) + } + + const finalize = async (output: ExecOutput, terminateWorker = true) => { + if (finished) { + return + } + finished = true + if (timeoutId) { + clearTimeout(timeoutId) + } + worker.removeAllListeners() + if (terminateWorker) { + try { + await worker.terminate() + } catch (error) { + logger.warn('Failed to terminate exec worker', error as Error) + } + } + resolve(output) + } + + const abortActiveTools = async () => { + const callIds = Array.from(activeCallIds.values()) + activeCallIds.clear() + if (callIds.length === 0) { + return + } + await Promise.allSettled(callIds.map((callId) => abortMcpTool(callId))) + } + + const handleToolCall = async (message: HubWorkerCallToolMessage) => { + if (finished || timedOut) { + return + } + const callId = crypto.randomUUID() + activeCallIds.set(message.requestId, callId) + + try { + const result = await callMcpTool(message.functionName, message.params, callId) + if (finished || timedOut) { + return + } + worker.postMessage({ type: 'toolResult', requestId: message.requestId, result }) + } catch (error) { + if (finished || timedOut) { + return + } + const errorMessage = error instanceof Error ? error.message : String(error) + worker.postMessage({ type: 'toolError', requestId: message.requestId, error: errorMessage }) + } finally { + activeCallIds.delete(message.requestId) + } + } + + const handleResult = (message: HubWorkerResultMessage) => { + const resolvedLogs = message.logs && message.logs.length > 0 ? message.logs : logs + void finalize({ + result: message.result, + logs: resolvedLogs.length > 0 ? resolvedLogs : undefined + }) + } + + const handleError = (errorMessage: string, messageLogs?: string[], terminateWorker = true) => { + const resolvedLogs = messageLogs && messageLogs.length > 0 ? messageLogs : logs + void finalize( + { + result: undefined, + logs: resolvedLogs.length > 0 ? resolvedLogs : undefined, + error: errorMessage, + isError: true + }, + terminateWorker + ) + } + + const handleMessage = (message: HubWorkerMessage) => { + if (!message || typeof message !== 'object') { + return + } + switch (message.type) { + case 'log': + addLog(message.entry) + break + case 'callTool': + void handleToolCall(message) + break + case 'result': + handleResult(message) + break + case 'error': + handleError(message.error, message.logs) + break + default: + break + } + } + + timeoutId = setTimeout(() => { + timedOut = true + void (async () => { + await abortActiveTools() + try { + await worker.terminate() + } catch (error) { + logger.warn('Failed to terminate exec worker after timeout', error as Error) + } + handleError(`Execution timed out after ${EXECUTION_TIMEOUT}ms`, undefined, false) + })() + }, EXECUTION_TIMEOUT) + + worker.on('message', handleMessage) + worker.on('error', (error) => { + logger.error('Worker execution error', error) + handleError(error instanceof Error ? error.message : String(error)) + }) + worker.on('exit', (code) => { + if (finished || timedOut) { + return + } + const message = code === 0 ? 'Exec worker exited unexpectedly' : `Exec worker exited with code ${code}` + logger.error(message) + handleError(message, undefined, false) + }) + + const execMessage: HubWorkerExecMessage = { + type: 'exec', + code, + tools: tools.map((tool) => ({ functionName: tool.functionName })) + } + worker.postMessage(execMessage) + }) + } +} diff --git a/src/main/mcpServers/hub/search.ts b/src/main/mcpServers/hub/search.ts new file mode 100644 index 0000000000..7bed36a285 --- /dev/null +++ b/src/main/mcpServers/hub/search.ts @@ -0,0 +1,109 @@ +import { generateToolsCode } from './generator' +import type { GeneratedTool, SearchQuery, SearchResult } from './types' + +const DEFAULT_LIMIT = 10 +const MAX_LIMIT = 50 + +export function searchTools(tools: GeneratedTool[], query: SearchQuery): SearchResult { + const { query: queryStr, limit = DEFAULT_LIMIT } = query + const effectiveLimit = Math.min(Math.max(1, limit), MAX_LIMIT) + + const keywords = queryStr + .toLowerCase() + .split(',') + .map((k) => k.trim()) + .filter((k) => k.length > 0) + + if (keywords.length === 0) { + const sliced = tools.slice(0, effectiveLimit) + return { + tools: generateToolsCode(sliced), + total: tools.length + } + } + + const matchedTools = tools.filter((tool) => { + const searchText = buildSearchText(tool).toLowerCase() + return keywords.some((keyword) => searchText.includes(keyword)) + }) + + const rankedTools = rankTools(matchedTools, keywords) + const sliced = rankedTools.slice(0, effectiveLimit) + + return { + tools: generateToolsCode(sliced), + total: matchedTools.length + } +} + +function buildSearchText(tool: GeneratedTool): string { + const combinedName = tool.serverName ? `${tool.serverName}_${tool.toolName}` : tool.toolName + const parts = [ + tool.toolName, + tool.functionName, + tool.serverName, + combinedName, + tool.description || '', + tool.signature + ] + return parts.join(' ') +} + +function rankTools(tools: GeneratedTool[], keywords: string[]): GeneratedTool[] { + const scored = tools.map((tool) => ({ + tool, + score: calculateScore(tool, keywords) + })) + + scored.sort((a, b) => b.score - a.score) + + return scored.map((s) => s.tool) +} + +function calculateScore(tool: GeneratedTool, keywords: string[]): number { + let score = 0 + const toolName = tool.toolName.toLowerCase() + const serverName = (tool.serverName || '').toLowerCase() + const functionName = tool.functionName.toLowerCase() + const description = (tool.description || '').toLowerCase() + + for (const keyword of keywords) { + // Match tool name + if (toolName === keyword) { + score += 10 + } else if (toolName.startsWith(keyword)) { + score += 5 + } else if (toolName.includes(keyword)) { + score += 3 + } + + // Match server name + if (serverName === keyword) { + score += 8 + } else if (serverName.startsWith(keyword)) { + score += 4 + } else if (serverName.includes(keyword)) { + score += 2 + } + + // Match function name (serverName_toolName format) + if (functionName === keyword) { + score += 10 + } else if (functionName.startsWith(keyword)) { + score += 5 + } else if (functionName.includes(keyword)) { + score += 3 + } + + if (description.includes(keyword)) { + const count = (description.match(new RegExp(escapeRegex(keyword), 'g')) || []).length + score += Math.min(count, 3) + } + } + + return score +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/src/main/mcpServers/hub/types.ts b/src/main/mcpServers/hub/types.ts new file mode 100644 index 0000000000..1c24ac7e77 --- /dev/null +++ b/src/main/mcpServers/hub/types.ts @@ -0,0 +1,113 @@ +import type { MCPServer, MCPTool } from '@types' + +export interface GeneratedTool { + serverId: string + serverName: string + toolName: string + functionName: string + jsCode: string + fn: (params: unknown) => Promise<unknown> + signature: string + returns: string + description?: string +} + +export interface SearchQuery { + query: string + limit?: number +} + +export interface SearchResult { + tools: string + total: number +} + +export interface ExecInput { + code: string +} + +export type ExecOutput = { + result: unknown + logs?: string[] + error?: string + isError?: boolean +} + +export interface ToolRegistryOptions { + ttl?: number +} + +export interface MCPToolWithServer extends MCPTool { + server: MCPServer +} + +export interface ExecutionContext { + __callTool: (functionName: string, params: unknown) => Promise<unknown> + parallel: <T>(...promises: Promise<T>[]) => Promise<T[]> + settle: <T>(...promises: Promise<T>[]) => Promise<PromiseSettledResult<T>[]> + console: ConsoleMethods + [functionName: string]: unknown +} + +export interface ConsoleMethods { + log: (...args: unknown[]) => void + warn: (...args: unknown[]) => void + error: (...args: unknown[]) => void + info: (...args: unknown[]) => void + debug: (...args: unknown[]) => void +} + +export type HubWorkerTool = { + functionName: string +} + +export type HubWorkerExecMessage = { + type: 'exec' + code: string + tools: HubWorkerTool[] +} + +export type HubWorkerCallToolMessage = { + type: 'callTool' + requestId: string + functionName: string + params: unknown +} + +export type HubWorkerToolResultMessage = { + type: 'toolResult' + requestId: string + result: unknown +} + +export type HubWorkerToolErrorMessage = { + type: 'toolError' + requestId: string + error: string +} + +export type HubWorkerResultMessage = { + type: 'result' + result: unknown + logs?: string[] +} + +export type HubWorkerErrorMessage = { + type: 'error' + error: string + logs?: string[] +} + +export type HubWorkerLogMessage = { + type: 'log' + entry: string +} + +export type HubWorkerMessage = + | HubWorkerExecMessage + | HubWorkerCallToolMessage + | HubWorkerToolResultMessage + | HubWorkerToolErrorMessage + | HubWorkerResultMessage + | HubWorkerErrorMessage + | HubWorkerLogMessage diff --git a/src/main/mcpServers/hub/worker.ts b/src/main/mcpServers/hub/worker.ts new file mode 100644 index 0000000000..88dcbc6858 --- /dev/null +++ b/src/main/mcpServers/hub/worker.ts @@ -0,0 +1,133 @@ +export const hubWorkerSource = ` +const crypto = require('node:crypto') +const { parentPort } = require('node:worker_threads') + +const MAX_LOGS = 1000 + +const logs = [] +const pendingCalls = new Map() +let isExecuting = false + +const stringify = (value) => { + if (value === undefined) return 'undefined' + if (value === null) return 'null' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (value instanceof Error) return value.message + + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +const pushLog = (level, args) => { + if (logs.length >= MAX_LOGS) { + return + } + const message = args.map((arg) => stringify(arg)).join(' ') + const entry = \`[\${level}] \${message}\` + logs.push(entry) + parentPort?.postMessage({ type: 'log', entry }) +} + +const capturedConsole = { + log: (...args) => pushLog('log', args), + warn: (...args) => pushLog('warn', args), + error: (...args) => pushLog('error', args), + info: (...args) => pushLog('info', args), + debug: (...args) => pushLog('debug', args) +} + +const callTool = (functionName, params) => + new Promise((resolve, reject) => { + const requestId = crypto.randomUUID() + pendingCalls.set(requestId, { resolve, reject }) + parentPort?.postMessage({ type: 'callTool', requestId, functionName, params }) + }) + +const buildContext = (tools) => { + const context = { + __callTool: callTool, + parallel: (...promises) => Promise.all(promises), + settle: (...promises) => Promise.allSettled(promises), + console: capturedConsole + } + + for (const tool of tools) { + context[tool.functionName] = (params) => callTool(tool.functionName, params) + } + + return context +} + +const runCode = async (code, context) => { + const contextKeys = Object.keys(context) + const contextValues = contextKeys.map((key) => context[key]) + + const wrappedCode = \` + return (async () => { + \${code} + })() + \` + + const fn = new Function(...contextKeys, wrappedCode) + return await fn(...contextValues) +} + +const handleExec = async (code, tools) => { + if (isExecuting) { + return + } + isExecuting = true + + try { + const context = buildContext(tools) + const result = await runCode(code, context) + parentPort?.postMessage({ type: 'result', result, logs: logs.length > 0 ? logs : undefined }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + parentPort?.postMessage({ type: 'error', error: errorMessage, logs: logs.length > 0 ? logs : undefined }) + } finally { + pendingCalls.clear() + } +} + +const handleToolResult = (message) => { + const pending = pendingCalls.get(message.requestId) + if (!pending) { + return + } + pendingCalls.delete(message.requestId) + pending.resolve(message.result) +} + +const handleToolError = (message) => { + const pending = pendingCalls.get(message.requestId) + if (!pending) { + return + } + pendingCalls.delete(message.requestId) + pending.reject(new Error(message.error)) +} + +parentPort?.on('message', (message) => { + if (!message || typeof message !== 'object') { + return + } + switch (message.type) { + case 'exec': + handleExec(message.code, message.tools ?? []) + break + case 'toolResult': + handleToolResult(message) + break + case 'toolError': + handleToolError(message) + break + default: + break + } +}) +` diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index 35655a88e7..1e143f1ae4 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -23,6 +23,22 @@ import { promisify } from 'util' const execAsync = promisify(require('child_process').exec) const logger = loggerService.withContext('CodeToolsService') +// Sensitive environment variable keys to redact in logs +const SENSITIVE_ENV_KEYS = ['API_KEY', 'APIKEY', 'AUTHORIZATION', 'TOKEN', 'SECRET', 'PASSWORD'] + +/** + * Sanitize environment variables for safe logging + * Redacts values of sensitive keys to prevent credential leakage + */ +function sanitizeEnvForLogging(env: Record<string, string>): Record<string, string> { + const sanitized: Record<string, string> = {} + for (const [key, value] of Object.entries(env)) { + const isSensitive = SENSITIVE_ENV_KEYS.some((k) => key.toUpperCase().includes(k)) + sanitized[key] = isSensitive ? '<redacted>' : value + } + return sanitized +} + interface VersionInfo { installed: string | null latest: string | null @@ -87,6 +103,8 @@ class CodeToolsService { return '@iflow-ai/iflow-cli' case codeTools.githubCopilotCli: return '@github/copilot' + case codeTools.kimiCli: + return 'kimi-cli' // Python package default: throw new Error(`Unsupported CLI tool: ${cliTool}`) } @@ -106,6 +124,8 @@ class CodeToolsService { return 'iflow' case codeTools.githubCopilotCli: return 'copilot' + case codeTools.kimiCli: + return 'kimi' default: throw new Error(`Unsupported CLI tool: ${cliTool}`) } @@ -451,7 +471,7 @@ class CodeToolsService { } } - const needsUpdate = !!(installedVersion && latestVersion && installedVersion !== latestVersion) + const needsUpdate = !!(latestVersion && isInstalled && (!installedVersion || installedVersion !== latestVersion)) logger.info( `Version check result for ${cliTool}: installed=${installedVersion}, latest=${latestVersion}, needsUpdate=${needsUpdate}` ) @@ -613,7 +633,7 @@ class CodeToolsService { } logger.info('Setting environment variables:', Object.keys(env)) - logger.info('Environment variable values:', env) + logger.debug('Environment variable values:', sanitizeEnvForLogging(env)) if (isWindows) { // Windows uses set command @@ -636,8 +656,7 @@ class CodeToolsService { .map(([key, value]) => { const sanitizedValue = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"') const exportCmd = `export ${key}="${sanitizedValue}"` - logger.info(`Setting env var: ${key}="${sanitizedValue}"`) - logger.info(`Export command: ${exportCmd}`) + logger.debug(`Setting env var: ${key}=<redacted>`) return exportCmd }) .join(' && ') @@ -647,26 +666,37 @@ class CodeToolsService { let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"` - // Add configuration parameters for OpenAI Codex - if (cliTool === codeTools.openaiCodex && env.OPENAI_MODEL_PROVIDER && env.OPENAI_MODEL_PROVIDER != 'openai') { - const provider = env.OPENAI_MODEL_PROVIDER - const model = env.OPENAI_MODEL - // delete the latest / - const baseUrl = env.OPENAI_BASE_URL.replace(/\/$/, '') + // Special handling for kimi-cli: use uvx instead of bun + if (cliTool === codeTools.kimiCli) { + const uvPath = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin', await getBinaryName('uv')) + baseCommand = `${uvPath} tool run ${packageName}` + } + + // Add configuration parameters for OpenAI Codex using command line args + if (cliTool === codeTools.openaiCodex && env.OPENAI_MODEL_PROVIDER) { + const providerId = env.OPENAI_MODEL_PROVIDER + const providerName = env.OPENAI_MODEL_PROVIDER_NAME || providerId + const normalizedBaseUrl = env.OPENAI_BASE_URL.replace(/\/$/, '') + const model = _model const configParams = [ - `--config model_provider="${provider}"`, - `--config model="${model}"`, - `--config model_providers.${provider}.name="${provider}"`, - `--config model_providers.${provider}.base_url="${baseUrl}"`, - `--config model_providers.${provider}.env_key="OPENAI_API_KEY"` + `--config model_provider="${providerId}"`, + `--config model_providers.${providerId}.name="${providerName}"`, + `--config model_providers.${providerId}.base_url="${normalizedBaseUrl}"`, + `--config model_providers.${providerId}.env_key="OPENAI_API_KEY"`, + `--config model_providers.${providerId}.wire_api="responses"`, + `--config model="${model}"` ].join(' ') baseCommand = `${baseCommand} ${configParams}` } const bunInstallPath = path.join(os.homedir(), HOME_CHERRY_DIR) - if (isInstalled) { + // Special handling for kimi-cli: uvx handles installation automatically + if (cliTool === codeTools.kimiCli) { + // uvx will automatically download and run kimi-cli, no need to install + // Just use the base command directly + } else if (isInstalled) { // If already installed, run executable directly (with optional update message) if (updateMessage) { baseCommand = `echo "Checking ${cliTool} version..."${updateMessage} && ${baseCommand}` @@ -777,14 +807,15 @@ class CodeToolsService { terminalArgs = args } - // Set cleanup task (delete temp file after 5 minutes) + // Set cleanup task (delete temp file after 60 seconds) + // Windows Terminal (UWP app) may take longer to initialize and read the file setTimeout(() => { try { fs.existsSync(batFilePath) && fs.unlinkSync(batFilePath) } catch (error) { logger.warn(`Failed to cleanup temp bat file: ${error}`) } - }, 10 * 1000) // Delete temp file after 10 seconds + }, 60 * 1000) // Delete temp file after 60 seconds break } diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 98537c85a1..b1eafb31b2 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -45,6 +45,7 @@ export enum ConfigKeys { SelectionAssistantFilterMode = 'selectionAssistantFilterMode', SelectionAssistantFilterList = 'selectionAssistantFilterList', DisableHardwareAcceleration = 'disableHardwareAcceleration', + UseSystemTitleBar = 'useSystemTitleBar', Proxy = 'proxy', EnableDeveloperMode = 'enableDeveloperMode', ClientId = 'clientId', @@ -251,6 +252,14 @@ export class ConfigManager { this.set(ConfigKeys.DisableHardwareAcceleration, value) } + getUseSystemTitleBar(): boolean { + return this.get<boolean>(ConfigKeys.UseSystemTitleBar, false) + } + + setUseSystemTitleBar(value: boolean) { + this.set(ConfigKeys.UseSystemTitleBar, value) + } + setAndNotify(key: string, value: unknown) { this.set(key, value, true) } diff --git a/src/main/services/DxtService.ts b/src/main/services/DxtService.ts index 59521efc63..59e97b2aa1 100644 --- a/src/main/services/DxtService.ts +++ b/src/main/services/DxtService.ts @@ -8,6 +8,27 @@ import { v4 as uuidv4 } from 'uuid' const logger = loggerService.withContext('DxtService') +/** + * Ensure a target path is within the base directory to prevent path traversal attacks. + * This is the correct approach: validate the final resolved path rather than sanitizing input. + * + * @param basePath - The base directory that the target must be within + * @param targetPath - The target path to validate + * @returns The resolved target path if valid + * @throws Error if the target path escapes the base directory + */ +export function ensurePathWithin(basePath: string, targetPath: string): string { + const resolvedBase = path.resolve(basePath) + const resolvedTarget = path.resolve(path.normalize(targetPath)) + + // Must be direct child of base directory, no subdirectories allowed + if (path.dirname(resolvedTarget) !== resolvedBase) { + throw new Error('Path traversal detected: target path must be direct child of base directory') + } + + return resolvedTarget +} + // Type definitions export interface DxtManifest { dxt_version: string @@ -68,6 +89,76 @@ export interface DxtUploadResult { error?: string } +/** + * Validate and sanitize a command to prevent path traversal attacks. + * Commands should be either: + * 1. Simple command names (e.g., "node", "python", "npx") - looked up in PATH + * 2. Absolute paths (e.g., "/usr/bin/node", "C:\\Program Files\\node\\node.exe") + * 3. Relative paths starting with ./ or .\ (relative to extractDir) + * + * Rejects commands containing path traversal sequences (..) + * + * @param command - The command to validate + * @returns The validated command + * @throws Error if command contains path traversal or is invalid + */ +export function validateCommand(command: string): string { + if (!command || typeof command !== 'string') { + throw new Error('Invalid command: command must be a non-empty string') + } + + const trimmed = command.trim() + if (!trimmed) { + throw new Error('Invalid command: command cannot be empty') + } + + // Check for path traversal sequences + // This catches: .., ../, ..\, /../, \..\, etc. + if (/(?:^|[/\\])\.\.(?:[/\\]|$)/.test(trimmed) || trimmed === '..') { + throw new Error(`Invalid command: path traversal detected in "${command}"`) + } + + // Check for null bytes + if (trimmed.includes('\0')) { + throw new Error('Invalid command: null byte detected') + } + + return trimmed +} + +/** + * Validate command arguments to prevent injection attacks. + * Rejects arguments containing path traversal sequences. + * + * @param args - The arguments array to validate + * @returns The validated arguments array + * @throws Error if any argument contains path traversal + */ +export function validateArgs(args: string[]): string[] { + if (!Array.isArray(args)) { + throw new Error('Invalid args: must be an array') + } + + return args.map((arg, index) => { + if (typeof arg !== 'string') { + throw new Error(`Invalid args: argument at index ${index} must be a string`) + } + + // Check for null bytes + if (arg.includes('\0')) { + throw new Error(`Invalid args: null byte detected in argument at index ${index}`) + } + + // Check for path traversal in arguments that look like paths + // Only validate if the arg contains path separators (indicating it's meant to be a path) + if ((arg.includes('/') || arg.includes('\\')) && /(?:^|[/\\])\.\.(?:[/\\]|$)/.test(arg)) { + throw new Error(`Invalid args: path traversal detected in argument at index ${index}`) + } + + return arg + }) +} + export function performVariableSubstitution( value: string, extractDir: string, @@ -134,12 +225,16 @@ export function applyPlatformOverrides(mcpConfig: any, extractDir: string, userC // Apply variable substitution to all string values if (resolvedConfig.command) { resolvedConfig.command = performVariableSubstitution(resolvedConfig.command, extractDir, userConfig) + // Validate command after substitution to prevent path traversal attacks + resolvedConfig.command = validateCommand(resolvedConfig.command) } if (resolvedConfig.args) { resolvedConfig.args = resolvedConfig.args.map((arg: string) => performVariableSubstitution(arg, extractDir, userConfig) ) + // Validate args after substitution to prevent path traversal attacks + resolvedConfig.args = validateArgs(resolvedConfig.args) } if (resolvedConfig.env) { @@ -271,10 +366,8 @@ class DxtService { } // Use server name as the final extract directory for automatic version management - // Sanitize the name to prevent creating subdirectories - const sanitizedName = manifest.name.replace(/\//g, '-') - const serverDirName = `server-${sanitizedName}` - const finalExtractDir = path.join(this.mcpDir, serverDirName) + const serverDirName = `server-${manifest.name}` + const finalExtractDir = ensurePathWithin(this.mcpDir, path.join(this.mcpDir, serverDirName)) // Clean up any existing version of this server if (fs.existsSync(finalExtractDir)) { @@ -354,27 +447,15 @@ class DxtService { public cleanupDxtServer(serverName: string): boolean { try { - // Handle server names that might contain slashes (e.g., "anthropic/sequential-thinking") - // by replacing slashes with the same separator used during installation - const sanitizedName = serverName.replace(/\//g, '-') - const serverDirName = `server-${sanitizedName}` - const serverDir = path.join(this.mcpDir, serverDirName) + const serverDirName = `server-${serverName}` + const serverDir = ensurePathWithin(this.mcpDir, path.join(this.mcpDir, serverDirName)) - // First try the sanitized path if (fs.existsSync(serverDir)) { logger.debug(`Removing DXT server directory: ${serverDir}`) fs.rmSync(serverDir, { recursive: true, force: true }) return true } - // Fallback: try with original name in case it was stored differently - const originalServerDir = path.join(this.mcpDir, `server-${serverName}`) - if (fs.existsSync(originalServerDir)) { - logger.debug(`Removing DXT server directory: ${originalServerDir}`) - fs.rmSync(originalServerDir, { recursive: true, force: true }) - return true - } - logger.warn(`Server directory not found: ${serverDir}`) return false } catch (error) { diff --git a/src/main/services/ExportService.ts b/src/main/services/ExportService.ts index c6dfa604cf..096ef05ef1 100644 --- a/src/main/services/ExportService.ts +++ b/src/main/services/ExportService.ts @@ -2,6 +2,7 @@ // ExportService import { loggerService } from '@logger' +import { t } from '@main/utils/locales' import { AlignmentType, BorderStyle, @@ -391,8 +392,8 @@ export class ExportService { const buffer = await Packer.toBuffer(doc) const filePath = dialog.showSaveDialogSync({ - title: '保存文件', - filters: [{ name: 'Word Document', extensions: ['docx'] }], + title: t('dialog.save_file'), + filters: [{ name: t('dialog.word_document'), extensions: ['docx'] }], defaultPath: fileName }) diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index a82094efcb..0baf51b9cb 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -9,6 +9,7 @@ import { readTextFileWithAutoEncoding, scanDir } from '@main/utils/file' +import { t } from '@main/utils/locales' import { documentExts, imageExts, KB, MB } from '@shared/config/constant' import { parseDataUrl } from '@shared/utils' import type { FileMetadata, NotesTreeNode } from '@types' @@ -821,9 +822,9 @@ class FileStorage { ): Promise<{ fileName: string; filePath: string; content?: Buffer; size: number } | null> => { try { const result: OpenDialogReturnValue = await dialog.showOpenDialog({ - title: '打开文件', + title: t('dialog.open_file'), properties: ['openFile'], - filters: [{ name: '所有文件', extensions: ['*'] }], + filters: [{ name: t('dialog.all_files'), extensions: ['*'] }], ...options }) @@ -1437,7 +1438,7 @@ class FileStorage { ): Promise<string> => { try { const result: SaveDialogReturnValue = await dialog.showSaveDialog({ - title: '保存文件', + title: t('dialog.save_file'), defaultPath: fileName, ...options }) @@ -1461,7 +1462,7 @@ class FileStorage { try { const filePath = dialog.showSaveDialogSync({ defaultPath: `${name}.png`, - filters: [{ name: 'PNG Image', extensions: ['png'] }] + filters: [{ name: t('dialog.png_image'), extensions: ['png'] }] }) if (filePath) { @@ -1476,7 +1477,7 @@ class FileStorage { public selectFolder = async (_: Electron.IpcMainInvokeEvent, options: OpenDialogOptions): Promise<string | null> => { try { const result: OpenDialogReturnValue = await dialog.showOpenDialog({ - title: '选择文件夹', + title: t('dialog.select_folder'), properties: ['openDirectory'], ...options }) diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 7d36e6d7e3..7c0d31384d 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -3,9 +3,9 @@ import os from 'node:os' import path from 'node:path' import { loggerService } from '@logger' +import { getMCPServersFromRedux } from '@main/apiServer/utils/mcp' import { createInMemoryMCPServer } from '@main/mcpServers/factory' import { makeSureDirExists, removeEnvProxy } from '@main/utils' -import { buildFunctionCallToolName } from '@main/utils/mcp' import { findCommandInShellEnv, getBinaryName, getBinaryPath, isBinaryExists } from '@main/utils/process' import getLoginShellEnvironment from '@main/utils/shell-env' import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core' @@ -35,6 +35,7 @@ import { HOME_CHERRY_DIR } from '@shared/config/constant' import type { MCPProgressEvent } from '@shared/config/types' import type { MCPServerLogEntry } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' +import { buildFunctionCallToolName } from '@shared/mcp' import { defaultAppHeaders } from '@shared/utils' import { BuiltinMCPServerNames, @@ -165,6 +166,67 @@ class McpService { this.getServerLogs = this.getServerLogs.bind(this) } + /** + * List all tools from all active MCP servers (excluding hub). + * Used by Hub server's tool registry. + */ + public async listAllActiveServerTools(): Promise<MCPTool[]> { + const servers = await getMCPServersFromRedux() + const activeServers = servers.filter((server) => server.isActive) + + const results = await Promise.allSettled( + activeServers.map(async (server) => { + const tools = await this.listToolsImpl(server) + const disabledTools = new Set(server.disabledTools ?? []) + return disabledTools.size > 0 ? tools.filter((tool) => !disabledTools.has(tool.name)) : tools + }) + ) + + const allTools: MCPTool[] = [] + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + allTools.push(...result.value) + } else { + logger.error( + `[listAllActiveServerTools] Failed to list tools from ${activeServers[index].name}:`, + result.reason as Error + ) + } + }) + + return allTools + } + + /** + * Call a tool by its full ID (serverId__toolName format). + * Used by Hub server's runtime. + */ + public async callToolById(toolId: string, params: unknown, callId?: string): Promise<MCPCallToolResponse> { + const parts = toolId.split('__') + if (parts.length < 2) { + throw new Error(`Invalid tool ID format: ${toolId}`) + } + + const serverId = parts[0] + const toolName = parts.slice(1).join('__') + + const servers = await getMCPServersFromRedux() + const server = servers.find((s) => s.id === serverId) + + if (!server) { + throw new Error(`Server not found: ${serverId}`) + } + + logger.debug(`[callToolById] Calling tool ${toolName} on server ${server.name}`) + + return this.callTool(null as unknown as Electron.IpcMainInvokeEvent, { + server, + name: toolName, + args: params, + callId + }) + } + private getServerKey(server: MCPServer): string { return JSON.stringify({ baseUrl: server.baseUrl, diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index 629e67401c..6adf23f450 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -1083,18 +1083,33 @@ export class SelectionService { this.lastCtrlkeyDownTime = -1 } - //check if the key is ctrl key + // Check if the key is ctrl key + // Windows: VK_LCONTROL(162), VK_RCONTROL(163) + // macOS: kVK_Control(59), kVK_RightControl(62) private isCtrlkey(vkCode: number) { + if (isMac) { + return vkCode === 59 || vkCode === 62 + } return vkCode === 162 || vkCode === 163 } - //check if the key is shift key + // Check if the key is shift key + // Windows: VK_LSHIFT(160), VK_RSHIFT(161) + // macOS: kVK_Shift(56), kVK_RightShift(60) private isShiftkey(vkCode: number) { + if (isMac) { + return vkCode === 56 || vkCode === 60 + } return vkCode === 160 || vkCode === 161 } - //check if the key is alt key + // Check if the key is alt/option key + // Windows: VK_LMENU(164), VK_RMENU(165) + // macOS: kVK_Option(58), kVK_RightOption(61) private isAltkey(vkCode: number) { + if (isMac) { + return vkCode === 58 || vkCode === 61 + } return vkCode === 164 || vkCode === 165 } diff --git a/src/main/services/WebviewService.ts b/src/main/services/WebviewService.ts index 7af008bd7a..b1763f3c94 100644 --- a/src/main/services/WebviewService.ts +++ b/src/main/services/WebviewService.ts @@ -1,7 +1,10 @@ +import { t } from '@main/utils/locales' import { IpcChannel } from '@shared/IpcChannel' import { app, dialog, session, shell, webContents } from 'electron' import { promises as fs } from 'fs' +import { configManager } from './ConfigManager' + /** * init the useragent of the webview session * remove the CherryStudio and Electron from the useragent @@ -13,9 +16,11 @@ export function initSessionUserAgent() { wvSession.setUserAgent(newUA) wvSession.webRequest.onBeforeSendHeaders((details, cb) => { + const language = configManager.getLanguage() const headers = { ...details.requestHeaders, - 'User-Agent': details.url.includes('google.com') ? originUA : newUA + 'User-Agent': details.url.includes('google.com') ? originUA : newUA, + 'Accept-Language': `${language}, en;q=0.9, *;q=0.5` } cb({ requestHeaders: headers }) }) @@ -137,9 +142,9 @@ export async function printWebviewToPDF(webviewId: number): Promise<string | nul // Show save dialog const { canceled, filePath } = await dialog.showSaveDialog({ - title: 'Save as PDF', + title: t('dialog.save_as_pdf'), defaultPath: defaultFilename, - filters: [{ name: 'PDF Files', extensions: ['pdf'] }] + filters: [{ name: t('dialog.pdf_files'), extensions: ['pdf'] }] }) if (canceled || !filePath) { @@ -186,11 +191,11 @@ export async function saveWebviewAsHTML(webviewId: number): Promise<string | nul // Show save dialog const { canceled, filePath } = await dialog.showSaveDialog({ - title: 'Save as HTML', + title: t('dialog.save_as_html'), defaultPath: defaultFilename, filters: [ - { name: 'HTML Files', extensions: ['html', 'htm'] }, - { name: 'All Files', extensions: ['*'] } + { name: t('dialog.html_files'), extensions: ['html', 'htm'] }, + { name: t('dialog.all_files'), extensions: ['*'] } ] }) diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index cda99cc37a..07837ef960 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -7,11 +7,11 @@ import { isDev, isLinux, isMac, isWin } from '@main/constant' import { getFilesDir } from '@main/utils/file' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { app, BrowserWindow, nativeTheme, screen, shell } from 'electron' +import { app, BrowserWindow, nativeImage, nativeTheme, screen, shell } from 'electron' import windowStateKeeper from 'electron-window-state' import { join } from 'path' -import icon from '../../../build/icon.png?asset' +import iconPath from '../../../build/icon.png?asset' import { titleBarOverlayDark, titleBarOverlayLight } from '../config' import { configManager } from './ConfigManager' import { contextMenu } from './ContextMenu' @@ -23,6 +23,9 @@ const DEFAULT_MINIWINDOW_HEIGHT = 400 // const logger = loggerService.withContext('WindowService') const logger = loggerService.withContext('WindowService') +// Create nativeImage for Linux window icon (required for Wayland) +const linuxIcon = isLinux ? nativeImage.createFromPath(iconPath) : undefined + export class WindowService { private static instance: WindowService | null = null private mainWindow: BrowserWindow | null = null @@ -75,11 +78,12 @@ export class WindowService { trafficLightPosition: { x: 8, y: 13 } } : { - frame: false // Frameless window for Windows and Linux + // On Linux, allow using system title bar if setting is enabled + frame: isLinux && configManager.getUseSystemTitleBar() ? true : false }), backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF', darkTheme: nativeTheme.shouldUseDarkColors, - ...(isLinux ? { icon } : {}), + ...(isLinux ? { icon: linuxIcon } : {}), webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, @@ -415,6 +419,23 @@ export class WindowService { return } + /** + * [Linux] Special handling for window activation + * When the window is visible but covered by other windows, simply calling show() and focus() + * is not enough to bring it to the front. We need to hide it first, then show it again. + * This mimics the "close to tray and reopen" behavior which works correctly. + */ + if (isLinux && this.mainWindow.isVisible() && !this.mainWindow.isFocused()) { + this.mainWindow.hide() + setImmediate(() => { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.show() + this.mainWindow.focus() + } + }) + return + } + /** * About setVisibleOnAllWorkspaces * diff --git a/src/main/services/__tests__/DxtService.test.ts b/src/main/services/__tests__/DxtService.test.ts new file mode 100644 index 0000000000..90873152fa --- /dev/null +++ b/src/main/services/__tests__/DxtService.test.ts @@ -0,0 +1,202 @@ +import path from 'path' +import { describe, expect, it } from 'vitest' + +import { ensurePathWithin, validateArgs, validateCommand } from '../DxtService' + +describe('ensurePathWithin', () => { + const baseDir = '/home/user/mcp' + + describe('valid paths', () => { + it('should accept direct child paths', () => { + expect(ensurePathWithin(baseDir, '/home/user/mcp/server-test')).toBe('/home/user/mcp/server-test') + expect(ensurePathWithin(baseDir, '/home/user/mcp/my-server')).toBe('/home/user/mcp/my-server') + }) + + it('should accept paths with unicode characters', () => { + expect(ensurePathWithin(baseDir, '/home/user/mcp/服务器')).toBe('/home/user/mcp/服务器') + expect(ensurePathWithin(baseDir, '/home/user/mcp/サーバー')).toBe('/home/user/mcp/サーバー') + }) + }) + + describe('path traversal prevention', () => { + it('should reject paths that escape base directory', () => { + expect(() => ensurePathWithin(baseDir, '/home/user/mcp/../../../etc')).toThrow('Path traversal detected') + expect(() => ensurePathWithin(baseDir, '/etc/passwd')).toThrow('Path traversal detected') + expect(() => ensurePathWithin(baseDir, '/home/user')).toThrow('Path traversal detected') + }) + + it('should reject subdirectories', () => { + expect(() => ensurePathWithin(baseDir, '/home/user/mcp/sub/dir')).toThrow('Path traversal detected') + expect(() => ensurePathWithin(baseDir, '/home/user/mcp/a/b/c')).toThrow('Path traversal detected') + }) + + it('should reject Windows-style path traversal', () => { + const winBase = 'C:\\Users\\user\\mcp' + expect(() => ensurePathWithin(winBase, 'C:\\Users\\user\\mcp\\..\\..\\Windows\\System32')).toThrow( + 'Path traversal detected' + ) + }) + + it('should reject null byte attacks', () => { + const maliciousPath = path.join(baseDir, 'server\x00/../../../etc/passwd') + expect(() => ensurePathWithin(baseDir, maliciousPath)).toThrow('Path traversal detected') + }) + + it('should handle encoded traversal attempts', () => { + expect(() => ensurePathWithin(baseDir, '/home/user/mcp/../escape')).toThrow('Path traversal detected') + }) + }) + + describe('edge cases', () => { + it('should reject base directory itself', () => { + expect(() => ensurePathWithin(baseDir, '/home/user/mcp')).toThrow('Path traversal detected') + }) + + it('should handle relative path construction', () => { + const target = path.join(baseDir, 'server-name') + expect(ensurePathWithin(baseDir, target)).toBe('/home/user/mcp/server-name') + }) + }) +}) + +describe('validateCommand', () => { + describe('valid commands', () => { + it('should accept simple command names', () => { + expect(validateCommand('node')).toBe('node') + expect(validateCommand('python')).toBe('python') + expect(validateCommand('npx')).toBe('npx') + expect(validateCommand('uvx')).toBe('uvx') + }) + + it('should accept absolute paths', () => { + expect(validateCommand('/usr/bin/node')).toBe('/usr/bin/node') + expect(validateCommand('/usr/local/bin/python3')).toBe('/usr/local/bin/python3') + expect(validateCommand('C:\\Program Files\\nodejs\\node.exe')).toBe('C:\\Program Files\\nodejs\\node.exe') + }) + + it('should accept relative paths starting with ./', () => { + expect(validateCommand('./node_modules/.bin/tsc')).toBe('./node_modules/.bin/tsc') + expect(validateCommand('.\\scripts\\run.bat')).toBe('.\\scripts\\run.bat') + }) + + it('should trim whitespace', () => { + expect(validateCommand(' node ')).toBe('node') + expect(validateCommand('\tpython\n')).toBe('python') + }) + }) + + describe('path traversal prevention', () => { + it('should reject commands with path traversal (Unix style)', () => { + expect(() => validateCommand('../../../bin/sh')).toThrow('path traversal detected') + expect(() => validateCommand('../../etc/passwd')).toThrow('path traversal detected') + expect(() => validateCommand('/usr/../../../bin/sh')).toThrow('path traversal detected') + }) + + it('should reject commands with path traversal (Windows style)', () => { + expect(() => validateCommand('..\\..\\..\\Windows\\System32\\cmd.exe')).toThrow('path traversal detected') + expect(() => validateCommand('..\\..\\Windows\\System32\\calc.exe')).toThrow('path traversal detected') + expect(() => validateCommand('C:\\..\\..\\Windows\\System32\\cmd.exe')).toThrow('path traversal detected') + }) + + it('should reject just ".."', () => { + expect(() => validateCommand('..')).toThrow('path traversal detected') + }) + + it('should reject mixed style path traversal', () => { + expect(() => validateCommand('../..\\mixed/..\\attack')).toThrow('path traversal detected') + }) + }) + + describe('null byte injection', () => { + it('should reject commands with null bytes', () => { + expect(() => validateCommand('node\x00.exe')).toThrow('null byte detected') + expect(() => validateCommand('python\0')).toThrow('null byte detected') + }) + }) + + describe('edge cases', () => { + it('should reject empty strings', () => { + expect(() => validateCommand('')).toThrow('command must be a non-empty string') + expect(() => validateCommand(' ')).toThrow('command cannot be empty') + }) + + it('should reject non-string input', () => { + // @ts-expect-error - testing runtime behavior + expect(() => validateCommand(null)).toThrow('command must be a non-empty string') + // @ts-expect-error - testing runtime behavior + expect(() => validateCommand(undefined)).toThrow('command must be a non-empty string') + // @ts-expect-error - testing runtime behavior + expect(() => validateCommand(123)).toThrow('command must be a non-empty string') + }) + }) + + describe('real-world attack scenarios', () => { + it('should prevent Windows system32 command injection', () => { + expect(() => validateCommand('../../../../Windows/System32/cmd.exe')).toThrow('path traversal detected') + expect(() => validateCommand('..\\..\\..\\..\\Windows\\System32\\powershell.exe')).toThrow( + 'path traversal detected' + ) + }) + + it('should prevent Unix bin injection', () => { + expect(() => validateCommand('../../../../bin/bash')).toThrow('path traversal detected') + expect(() => validateCommand('../../../usr/bin/curl')).toThrow('path traversal detected') + }) + }) +}) + +describe('validateArgs', () => { + describe('valid arguments', () => { + it('should accept normal arguments', () => { + expect(validateArgs(['--version'])).toEqual(['--version']) + expect(validateArgs(['-y', '@anthropic/mcp-server'])).toEqual(['-y', '@anthropic/mcp-server']) + expect(validateArgs(['install', 'package-name'])).toEqual(['install', 'package-name']) + }) + + it('should accept arguments with safe paths', () => { + expect(validateArgs(['./src/index.ts'])).toEqual(['./src/index.ts']) + expect(validateArgs(['/absolute/path/file.js'])).toEqual(['/absolute/path/file.js']) + }) + + it('should accept empty array', () => { + expect(validateArgs([])).toEqual([]) + }) + }) + + describe('path traversal prevention', () => { + it('should reject arguments with path traversal', () => { + expect(() => validateArgs(['../../../etc/passwd'])).toThrow('path traversal detected') + expect(() => validateArgs(['--config', '../../secrets.json'])).toThrow('path traversal detected') + expect(() => validateArgs(['..\\..\\Windows\\System32\\config'])).toThrow('path traversal detected') + }) + + it('should only check path-like arguments', () => { + // Arguments without path separators should pass even with dots + expect(validateArgs(['..version'])).toEqual(['..version']) + expect(validateArgs(['test..name'])).toEqual(['test..name']) + }) + }) + + describe('null byte injection', () => { + it('should reject arguments with null bytes', () => { + expect(() => validateArgs(['file\x00.txt'])).toThrow('null byte detected') + expect(() => validateArgs(['--config', 'path\0name'])).toThrow('null byte detected') + }) + }) + + describe('edge cases', () => { + it('should reject non-array input', () => { + // @ts-expect-error - testing runtime behavior + expect(() => validateArgs('not an array')).toThrow('must be an array') + // @ts-expect-error - testing runtime behavior + expect(() => validateArgs(null)).toThrow('must be an array') + }) + + it('should reject non-string elements', () => { + // @ts-expect-error - testing runtime behavior + expect(() => validateArgs([123])).toThrow('must be a string') + // @ts-expect-error - testing runtime behavior + expect(() => validateArgs(['valid', null])).toThrow('must be a string') + }) + }) +}) diff --git a/src/main/services/__tests__/MCPService.test.ts b/src/main/services/__tests__/MCPService.test.ts new file mode 100644 index 0000000000..4757d20cff --- /dev/null +++ b/src/main/services/__tests__/MCPService.test.ts @@ -0,0 +1,75 @@ +import type { MCPServer, MCPTool } from '@types' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@main/apiServer/utils/mcp', () => ({ + getMCPServersFromRedux: vi.fn() +})) + +vi.mock('@main/services/WindowService', () => ({ + windowService: { + getMainWindow: vi.fn(() => null) + } +})) + +import { getMCPServersFromRedux } from '@main/apiServer/utils/mcp' +import mcpService from '@main/services/MCPService' + +const baseInputSchema: { type: 'object'; properties: Record<string, unknown>; required: string[] } = { + type: 'object', + properties: {}, + required: [] +} + +const createTool = (overrides: Partial<MCPTool>): MCPTool => ({ + id: `${overrides.serverId}__${overrides.name}`, + name: overrides.name ?? 'tool', + description: overrides.description, + serverId: overrides.serverId ?? 'server', + serverName: overrides.serverName ?? 'server', + inputSchema: baseInputSchema, + type: 'mcp', + ...overrides +}) + +describe('MCPService.listAllActiveServerTools', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('filters disabled tools per server', async () => { + const servers: MCPServer[] = [ + { + id: 'alpha', + name: 'Alpha', + isActive: true, + disabledTools: ['disabled_tool'] + }, + { + id: 'beta', + name: 'Beta', + isActive: true + } + ] + + vi.mocked(getMCPServersFromRedux).mockResolvedValue(servers) + + const listToolsSpy = vi.spyOn(mcpService as any, 'listToolsImpl').mockImplementation(async (server: any) => { + if (server.id === 'alpha') { + return [ + createTool({ name: 'enabled_tool', serverId: server.id, serverName: server.name }), + createTool({ name: 'disabled_tool', serverId: server.id, serverName: server.name }) + ] + } + return [createTool({ name: 'beta_tool', serverId: server.id, serverName: server.name })] + }) + + const tools = await mcpService.listAllActiveServerTools() + + expect(listToolsSpy).toHaveBeenCalledTimes(2) + expect(tools.map((tool) => tool.name)).toEqual(['enabled_tool', 'beta_tool']) + }) +}) diff --git a/src/main/services/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index e30814bb6f..bf1fb6ddbe 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -2,7 +2,7 @@ import { loggerService } from '@logger' import { mcpApiService } from '@main/apiServer/services/mcp' import type { ModelValidationError } from '@main/apiServer/utils' import { validateModelId } from '@main/apiServer/utils' -import { buildFunctionCallToolName } from '@main/utils/mcp' +import { buildFunctionCallToolName } from '@shared/mcp' import type { AgentType, MCPTool, SlashCommand, Tool } from '@types' import { objectKeys } from '@types' import fs from 'fs' diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 69266f5a61..23bed50518 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -16,6 +16,7 @@ import { loggerService } from '@logger' import { config as apiConfigService } from '@main/apiServer/config' import { validateModelId } from '@main/apiServer/utils' import { isWin } from '@main/constant' +import { configManager } from '@main/services/ConfigManager' import { autoDiscoverGitBash } from '@main/utils/process' import getLoginShellEnvironment from '@main/utils/shell-env' import { withoutTrailingApiVersion } from '@shared/utils' @@ -34,6 +35,9 @@ const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep']) const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1' const NO_RESUME_COMMANDS = ['/clear'] +const getLanguageInstruction = () => + `IMPORTANT: You MUST use ${configManager.getLanguage()} language for ALL your outputs, including: (1) text responses, (2) tool call parameters like "description" fields, and (3) any user-facing content. Never use English unless the content is code, file paths, or technical identifiers.` + type UserInputMessage = { type: 'user' parent_tool_use_id: string | null @@ -255,9 +259,13 @@ class ClaudeCodeService implements AgentServiceInterface { ? { type: 'preset', preset: 'claude_code', - append: session.instructions + append: `${session.instructions}\n\n${getLanguageInstruction()}` } - : { type: 'preset', preset: 'claude_code' }, + : { + type: 'preset', + preset: 'claude_code', + append: getLanguageInstruction() + }, settingSources: ['project'], includePartialMessages: true, permissionMode: session.configuration?.permission_mode, diff --git a/src/main/services/agents/services/claudecode/transform.ts b/src/main/services/agents/services/claudecode/transform.ts index 0a4f50552c..b83ae09237 100644 --- a/src/main/services/agents/services/claudecode/transform.ts +++ b/src/main/services/agents/services/claudecode/transform.ts @@ -72,7 +72,10 @@ const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}` * Removes any local command stdout/stderr XML wrappers that should never surface to the UI. */ export const stripLocalCommandTags = (text: string): string => { - return text.replace(/<local-command-(stdout|stderr)>(.*?)<\/local-command-\1>/gs, '$2') + return text + .replace(/<local-command-(stdout|stderr)>(.*?)<\/local-command-\1>/gs, '$2') + .replace('(no content)', '') + .trim() } /** @@ -316,7 +319,46 @@ function handleUserMessage( const chunks: AgentStreamPart[] = [] const providerMetadata = sdkMessageToProviderMetadata(message) const content = message.message.content - const isSynthetic = message.isSynthetic ?? false + + // Check if content contains tool_result blocks (synthetic tool result messages) + // This handles both SDK-flagged messages and standard tool_result content + const contentArray = Array.isArray(content) ? content : [] + const hasToolResults = contentArray.some((block: any) => block.type === 'tool_result') + + if (hasToolResults || message.tool_use_result || message.parent_tool_use_id) { + if (!Array.isArray(content)) { + return chunks + } + for (const block of content) { + if (block.type === 'tool_result') { + const toolResult = block as ToolResultContent + const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id) + const toolCallId = pendingCall?.toolCallId ?? state.getNamespacedToolCallId(toolResult.tool_use_id) + if (toolResult.is_error) { + chunks.push({ + type: 'tool-error', + toolCallId, + toolName: pendingCall?.toolName ?? 'unknown', + input: pendingCall?.input, + error: toolResult.content, + providerExecuted: true + } as AgentStreamPart) + } else { + chunks.push({ + type: 'tool-result', + toolCallId, + toolName: pendingCall?.toolName ?? 'unknown', + input: pendingCall?.input, + output: toolResult.content, + providerExecuted: true + }) + } + } + } + return chunks + } + + // For non-synthetic messages (user-initiated content), render text content if (typeof content === 'string') { if (!content) { return chunks @@ -347,39 +389,12 @@ function handleUserMessage( return chunks } - if (!Array.isArray(content)) { - return chunks - } - + // For non-synthetic array content, render text blocks for (const block of content) { - if (block.type === 'tool_result') { - const toolResult = block as ToolResultContent - const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id) - const toolCallId = pendingCall?.toolCallId ?? state.getNamespacedToolCallId(toolResult.tool_use_id) - if (toolResult.is_error) { - chunks.push({ - type: 'tool-error', - toolCallId, - toolName: pendingCall?.toolName ?? 'unknown', - input: pendingCall?.input, - error: toolResult.content, - providerExecuted: true - } as AgentStreamPart) - } else { - chunks.push({ - type: 'tool-result', - toolCallId, - toolName: pendingCall?.toolName ?? 'unknown', - input: pendingCall?.input, - output: toolResult.content, - providerExecuted: true - }) - } - } else if (block.type === 'text' && !isSynthetic) { + if (block.type === 'text') { const rawText = (block as { text: string }).text const filteredText = filterCommandTags(rawText) - // Only push text chunks if there's content after filtering if (filteredText) { const id = message.uuid?.toString() || generateMessageId() chunks.push({ @@ -399,8 +414,6 @@ function handleUserMessage( providerMetadata }) } - } else { - logger.warn('Unhandled user content block', { type: (block as any).type }) } } diff --git a/src/main/services/ocr/builtin/PpocrService.ts b/src/main/services/ocr/builtin/PpocrService.ts index 61c923c542..1b0ddf5438 100644 --- a/src/main/services/ocr/builtin/PpocrService.ts +++ b/src/main/services/ocr/builtin/PpocrService.ts @@ -67,7 +67,8 @@ export class PpocrService extends OcrBaseService { } satisfies OcrPayload const headers: Record<string, string> = { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'Client-Platform': 'cherry-studio' } if (options.accessToken) { diff --git a/src/main/utils/__tests__/mcp.test.ts b/src/main/utils/__tests__/mcp.test.ts deleted file mode 100644 index 706a44bc84..0000000000 --- a/src/main/utils/__tests__/mcp.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { buildFunctionCallToolName } from '../mcp' - -describe('buildFunctionCallToolName', () => { - describe('basic format', () => { - it('should return format mcp__{server}__{tool}', () => { - const result = buildFunctionCallToolName('github', 'search_issues') - expect(result).toBe('mcp__github__search_issues') - }) - - it('should handle simple server and tool names', () => { - expect(buildFunctionCallToolName('fetch', 'get_page')).toBe('mcp__fetch__get_page') - expect(buildFunctionCallToolName('database', 'query')).toBe('mcp__database__query') - expect(buildFunctionCallToolName('cherry_studio', 'search')).toBe('mcp__cherry_studio__search') - }) - }) - - describe('valid JavaScript identifier', () => { - it('should always start with mcp__ prefix (valid JS identifier start)', () => { - const result = buildFunctionCallToolName('123server', '456tool') - expect(result).toMatch(/^mcp__/) - expect(result).toBe('mcp__123server__456tool') - }) - - it('should only contain alphanumeric chars and underscores', () => { - const result = buildFunctionCallToolName('my-server', 'my-tool') - expect(result).toBe('mcp__my_server__my_tool') - expect(result).toMatch(/^[a-zA-Z][a-zA-Z0-9_]*$/) - }) - - it('should be a valid JavaScript identifier', () => { - const testCases = [ - ['github', 'create_issue'], - ['my-server', 'fetch-data'], - ['test@server', 'tool#name'], - ['server.name', 'tool.action'], - ['123abc', 'def456'] - ] - - for (const [server, tool] of testCases) { - const result = buildFunctionCallToolName(server, tool) - // Valid JS identifiers match this pattern - expect(result).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/) - } - }) - }) - - describe('character sanitization', () => { - it('should replace dashes with underscores', () => { - const result = buildFunctionCallToolName('my-server', 'my-tool-name') - expect(result).toBe('mcp__my_server__my_tool_name') - }) - - it('should replace special characters with underscores', () => { - const result = buildFunctionCallToolName('test@server!', 'tool#name$') - expect(result).toBe('mcp__test_server__tool_name') - }) - - it('should replace dots with underscores', () => { - const result = buildFunctionCallToolName('server.name', 'tool.action') - expect(result).toBe('mcp__server_name__tool_action') - }) - - it('should replace spaces with underscores', () => { - const result = buildFunctionCallToolName('my server', 'my tool') - expect(result).toBe('mcp__my_server__my_tool') - }) - - it('should collapse consecutive underscores', () => { - const result = buildFunctionCallToolName('my--server', 'my___tool') - expect(result).toBe('mcp__my_server__my_tool') - expect(result).not.toMatch(/_{3,}/) - }) - - it('should trim leading and trailing underscores from parts', () => { - const result = buildFunctionCallToolName('_server_', '_tool_') - expect(result).toBe('mcp__server__tool') - }) - - it('should handle names with only special characters', () => { - const result = buildFunctionCallToolName('---', '###') - expect(result).toBe('mcp____') - }) - }) - - describe('length constraints', () => { - it('should not exceed 63 characters', () => { - const longServerName = 'a'.repeat(50) - const longToolName = 'b'.repeat(50) - const result = buildFunctionCallToolName(longServerName, longToolName) - - expect(result.length).toBeLessThanOrEqual(63) - }) - - it('should truncate server name to max 20 chars', () => { - const longServerName = 'abcdefghijklmnopqrstuvwxyz' // 26 chars - const result = buildFunctionCallToolName(longServerName, 'tool') - - expect(result).toBe('mcp__abcdefghijklmnopqrst__tool') - expect(result).toContain('abcdefghijklmnopqrst') // First 20 chars - expect(result).not.toContain('uvwxyz') // Truncated - }) - - it('should truncate tool name to max 35 chars', () => { - const longToolName = 'a'.repeat(40) - const result = buildFunctionCallToolName('server', longToolName) - - const expectedTool = 'a'.repeat(35) - expect(result).toBe(`mcp__server__${expectedTool}`) - }) - - it('should not end with underscores after truncation', () => { - // Create a name that would end with underscores after truncation - const longServerName = 'a'.repeat(20) - const longToolName = 'b'.repeat(35) + '___extra' - const result = buildFunctionCallToolName(longServerName, longToolName) - - expect(result).not.toMatch(/_+$/) - expect(result.length).toBeLessThanOrEqual(63) - }) - - it('should handle max length edge case exactly', () => { - // mcp__ (5) + server (20) + __ (2) + tool (35) = 62 chars - const server = 'a'.repeat(20) - const tool = 'b'.repeat(35) - const result = buildFunctionCallToolName(server, tool) - - expect(result.length).toBe(62) - expect(result).toBe(`mcp__${'a'.repeat(20)}__${'b'.repeat(35)}`) - }) - }) - - describe('edge cases', () => { - it('should handle empty server name', () => { - const result = buildFunctionCallToolName('', 'tool') - expect(result).toBe('mcp____tool') - }) - - it('should handle empty tool name', () => { - const result = buildFunctionCallToolName('server', '') - expect(result).toBe('mcp__server__') - }) - - it('should handle both empty names', () => { - const result = buildFunctionCallToolName('', '') - expect(result).toBe('mcp____') - }) - - it('should handle whitespace-only names', () => { - const result = buildFunctionCallToolName(' ', ' ') - expect(result).toBe('mcp____') - }) - - it('should trim whitespace from names', () => { - const result = buildFunctionCallToolName(' server ', ' tool ') - expect(result).toBe('mcp__server__tool') - }) - - it('should handle unicode characters', () => { - const result = buildFunctionCallToolName('服务器', '工具') - // Unicode chars are replaced with underscores, then collapsed - expect(result).toMatch(/^mcp__/) - }) - - it('should handle mixed case', () => { - const result = buildFunctionCallToolName('MyServer', 'MyTool') - expect(result).toBe('mcp__MyServer__MyTool') - }) - }) - - describe('deterministic output', () => { - it('should produce consistent results for same input', () => { - const serverName = 'github' - const toolName = 'search_repos' - - const result1 = buildFunctionCallToolName(serverName, toolName) - const result2 = buildFunctionCallToolName(serverName, toolName) - const result3 = buildFunctionCallToolName(serverName, toolName) - - expect(result1).toBe(result2) - expect(result2).toBe(result3) - }) - - it('should produce different results for different inputs', () => { - const result1 = buildFunctionCallToolName('server1', 'tool') - const result2 = buildFunctionCallToolName('server2', 'tool') - const result3 = buildFunctionCallToolName('server', 'tool1') - const result4 = buildFunctionCallToolName('server', 'tool2') - - expect(result1).not.toBe(result2) - expect(result3).not.toBe(result4) - }) - }) - - describe('real-world scenarios', () => { - it('should handle GitHub MCP server', () => { - expect(buildFunctionCallToolName('github', 'create_issue')).toBe('mcp__github__create_issue') - expect(buildFunctionCallToolName('github', 'search_repositories')).toBe('mcp__github__search_repositories') - expect(buildFunctionCallToolName('github', 'get_pull_request')).toBe('mcp__github__get_pull_request') - }) - - it('should handle filesystem MCP server', () => { - expect(buildFunctionCallToolName('filesystem', 'read_file')).toBe('mcp__filesystem__read_file') - expect(buildFunctionCallToolName('filesystem', 'write_file')).toBe('mcp__filesystem__write_file') - expect(buildFunctionCallToolName('filesystem', 'list_directory')).toBe('mcp__filesystem__list_directory') - }) - - it('should handle hyphenated server names (common in npm packages)', () => { - expect(buildFunctionCallToolName('cherry-fetch', 'get_page')).toBe('mcp__cherry_fetch__get_page') - expect(buildFunctionCallToolName('mcp-server-github', 'search')).toBe('mcp__mcp_server_github__search') - }) - - it('should handle scoped npm package style names', () => { - const result = buildFunctionCallToolName('@anthropic/mcp-server', 'chat') - expect(result).toBe('mcp__anthropic_mcp_server__chat') - }) - - it('should handle tools with long descriptive names', () => { - const result = buildFunctionCallToolName('github', 'search_repositories_by_language_and_stars') - expect(result.length).toBeLessThanOrEqual(63) - expect(result).toMatch(/^mcp__github__search_repositories_by_lan/) - }) - }) -}) diff --git a/src/main/utils/locales.ts b/src/main/utils/locales.ts index afaf48b20f..35cbcb0ae8 100644 --- a/src/main/utils/locales.ts +++ b/src/main/utils/locales.ts @@ -1,3 +1,5 @@ +import { configManager } from '@main/services/ConfigManager' + import EnUs from '../../renderer/src/i18n/locales/en-us.json' import ZhCn from '../../renderer/src/i18n/locales/zh-cn.json' import ZhTw from '../../renderer/src/i18n/locales/zh-tw.json' @@ -27,4 +29,21 @@ const locales = Object.fromEntries( ].map(([locale, translation]) => [locale, { translation }]) ) -export { locales } +/** + * Get translation by key path (e.g., 'dialog.save_file') + * This is a simplified version for main process, similar to i18next's t() function + */ +const t = (key: string): string => { + const locale = locales[configManager.getLanguage()] + const keys = key.split('.') + let result: any = locale.translation + for (const k of keys) { + result = result?.[k] + if (result === undefined) { + return key + } + } + return typeof result === 'string' ? result : key +} + +export { locales, t } diff --git a/src/main/utils/mcp.ts b/src/main/utils/mcp.ts deleted file mode 100644 index 34eb0e63e7..0000000000 --- a/src/main/utils/mcp.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Builds a valid JavaScript function name for MCP tool calls. - * Format: mcp__{server_name}__{tool_name} - * - * @param serverName - The MCP server name - * @param toolName - The tool name from the server - * @returns A valid JS identifier in format mcp__{server}__{tool}, max 63 chars - */ -export function buildFunctionCallToolName(serverName: string, toolName: string): string { - // Sanitize to valid JS identifier chars (alphanumeric + underscore only) - const sanitize = (str: string): string => - str - .trim() - .replace(/[^a-zA-Z0-9]/g, '_') // Replace all non-alphanumeric with underscore - .replace(/_{2,}/g, '_') // Collapse multiple underscores - .replace(/^_+|_+$/g, '') // Trim leading/trailing underscores - - const server = sanitize(serverName).slice(0, 20) // Keep server name short - const tool = sanitize(toolName).slice(0, 35) // More room for tool name - - let name = `mcp__${server}__${tool}` - - // Ensure max 63 chars and clean trailing underscores - if (name.length > 63) { - name = name.slice(0, 63).replace(/_+$/, '') - } - - return name -} diff --git a/src/preload/index.ts b/src/preload/index.ts index cb8b0f6919..f2a892142e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -503,6 +503,7 @@ const api = { quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text), setDisableHardwareAcceleration: (isDisable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable), + setUseSystemTitleBar: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetUseSystemTitleBar, isActive), trace: { saveData: (topicId: string) => ipcRenderer.invoke(IpcChannel.TRACE_SAVE_DATA, topicId), getData: (topicId: string, traceId: string, modelName?: string) => diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 5d418de08b..78f5d6b36c 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -238,15 +238,16 @@ export class AiSdkToChunkAdapter { // === 工具调用相关事件(原始 AI SDK 事件,如果没有被中间件处理) === - // case 'tool-input-start': - // case 'tool-input-delta': - // case 'tool-input-end': - // this.toolCallHandler.handleToolCallCreated(chunk) - // break + case 'tool-input-start': + this.toolCallHandler.handleToolInputStart(chunk) + break + case 'tool-input-delta': + this.toolCallHandler.handleToolInputDelta(chunk) + break + case 'tool-input-end': + this.toolCallHandler.handleToolInputEnd(chunk) + break - // case 'tool-input-delta': - // this.toolCallHandler.handleToolCallCreated(chunk) - // break case 'tool-call': this.toolCallHandler.handleToolCall(chunk) break diff --git a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts index b5acbb690b..61ec029957 100644 --- a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts +++ b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts @@ -5,18 +5,12 @@ */ import { loggerService } from '@logger' +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js' import { processKnowledgeReferences } from '@renderer/services/KnowledgeService' -import type { - BaseTool, - MCPCallToolResponse, - MCPTool, - MCPToolResponse, - MCPToolResultContent, - NormalToolResponse -} from '@renderer/types' +import type { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' import { ChunkType } from '@renderer/types/chunk' -import type { ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai' +import type { ProviderMetadata, ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai' const logger = loggerService.withContext('ToolCallChunkHandler') @@ -26,6 +20,8 @@ export type ToolcallsMap = { args: any // mcpTool 现在可以是 MCPTool 或我们为 Provider 工具创建的通用类型 tool: BaseTool + // Streaming arguments buffer + streamingArgs?: string } /** * 工具调用处理器类 @@ -71,110 +67,169 @@ export class ToolCallChunkHandler { return ToolCallChunkHandler.addActiveToolCallImpl(toolCallId, map) } - // /** - // * 设置 onChunk 回调 - // */ - // public setOnChunk(callback: (chunk: Chunk) => void): void { - // this.onChunk = callback - // } + /** + * 根据工具名称确定工具类型 + */ + private determineToolType(toolName: string, toolCallId: string): BaseTool { + let mcpTool: MCPTool | undefined + if (toolName.startsWith('builtin_')) { + return { + id: toolCallId, + name: toolName, + description: toolName, + type: 'builtin' + } as BaseTool + } else if ((mcpTool = this.mcpTools.find((t) => t.id === toolName) as MCPTool)) { + return mcpTool + } else { + return { + id: toolCallId, + name: toolName, + description: toolName, + type: 'provider' + } + } + } - // handleToolCallCreated( - // chunk: - // | { - // type: 'tool-input-start' - // id: string - // toolName: string - // providerMetadata?: ProviderMetadata - // providerExecuted?: boolean - // } - // | { - // type: 'tool-input-end' - // id: string - // providerMetadata?: ProviderMetadata - // } - // | { - // type: 'tool-input-delta' - // id: string - // delta: string - // providerMetadata?: ProviderMetadata - // } - // ): void { - // switch (chunk.type) { - // case 'tool-input-start': { - // // 能拿到说明是mcpTool - // // if (this.activeToolCalls.get(chunk.id)) return + /** + * 处理工具输入开始事件 - 流式参数开始 + */ + public handleToolInputStart(chunk: { + type: 'tool-input-start' + id: string + toolName: string + providerMetadata?: ProviderMetadata + providerExecuted?: boolean + }): void { + const { id: toolCallId, toolName, providerExecuted } = chunk - // const tool: BaseTool | MCPTool = { - // id: chunk.id, - // name: chunk.toolName, - // description: chunk.toolName, - // type: chunk.toolName.startsWith('builtin_') ? 'builtin' : 'provider' - // } - // this.activeToolCalls.set(chunk.id, { - // toolCallId: chunk.id, - // toolName: chunk.toolName, - // args: '', - // tool - // }) - // const toolResponse: MCPToolResponse | NormalToolResponse = { - // id: chunk.id, - // tool: tool, - // arguments: {}, - // status: 'pending', - // toolCallId: chunk.id - // } - // this.onChunk({ - // type: ChunkType.MCP_TOOL_PENDING, - // responses: [toolResponse] - // }) - // break - // } - // case 'tool-input-delta': { - // const toolCall = this.activeToolCalls.get(chunk.id) - // if (!toolCall) { - // logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`) - // return - // } - // toolCall.args += chunk.delta - // break - // } - // case 'tool-input-end': { - // const toolCall = this.activeToolCalls.get(chunk.id) - // this.activeToolCalls.delete(chunk.id) - // if (!toolCall) { - // logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`) - // return - // } - // // const toolResponse: ToolCallResponse = { - // // id: toolCall.toolCallId, - // // tool: toolCall.tool, - // // arguments: toolCall.args, - // // status: 'pending', - // // toolCallId: toolCall.toolCallId - // // } - // // logger.debug('toolResponse', toolResponse) - // // this.onChunk({ - // // type: ChunkType.MCP_TOOL_PENDING, - // // responses: [toolResponse] - // // }) - // break - // } - // } - // // if (!toolCall) { - // // Logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`) - // // return - // // } - // // this.onChunk({ - // // type: ChunkType.MCP_TOOL_CREATED, - // // tool_calls: [ - // // { - // // id: chunk.id, - // // name: chunk.toolName, - // // status: 'pending' - // // } - // // ] - // // }) - // } + if (!toolCallId || !toolName) { + logger.warn(`🔧 [ToolCallChunkHandler] Invalid tool-input-start chunk: missing id or toolName`) + return + } + + // 如果已存在,跳过 + if (this.activeToolCalls.has(toolCallId)) { + return + } + + let tool: BaseTool + if (providerExecuted) { + tool = { + id: toolCallId, + name: toolName, + description: toolName, + type: 'provider' + } as BaseTool + } else { + tool = this.determineToolType(toolName, toolCallId) + } + + // 初始化流式工具调用 + this.addActiveToolCall(toolCallId, { + toolCallId, + toolName, + args: undefined, + tool, + streamingArgs: '' + }) + + logger.info(`🔧 [ToolCallChunkHandler] Tool input streaming started: ${toolName} (${toolCallId})`) + + // 发送初始 streaming chunk + const toolResponse: MCPToolResponse | NormalToolResponse = { + id: toolCallId, + tool: tool, + arguments: undefined, + status: 'streaming', + toolCallId: toolCallId, + partialArguments: '' + } + + this.onChunk({ + type: ChunkType.MCP_TOOL_STREAMING, + responses: [toolResponse] + }) + } + + /** + * 处理工具输入增量事件 - 流式参数片段 + */ + public handleToolInputDelta(chunk: { + type: 'tool-input-delta' + id: string + delta: string + providerMetadata?: ProviderMetadata + }): void { + const { id: toolCallId, delta } = chunk + + const toolCall = this.activeToolCalls.get(toolCallId) + if (!toolCall) { + logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found for delta: ${toolCallId}`) + return + } + + // 累积流式参数 + toolCall.streamingArgs = (toolCall.streamingArgs || '') + delta + + // 发送 streaming chunk 更新 + const toolResponse: MCPToolResponse | NormalToolResponse = { + id: toolCallId, + tool: toolCall.tool, + arguments: undefined, + status: 'streaming', + toolCallId: toolCallId, + partialArguments: toolCall.streamingArgs + } + + this.onChunk({ + type: ChunkType.MCP_TOOL_STREAMING, + responses: [toolResponse] + }) + } + + /** + * 处理工具输入结束事件 - 流式参数完成 + */ + public handleToolInputEnd(chunk: { type: 'tool-input-end'; id: string; providerMetadata?: ProviderMetadata }): void { + const { id: toolCallId } = chunk + + const toolCall = this.activeToolCalls.get(toolCallId) + if (!toolCall) { + logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found for end: ${toolCallId}`) + return + } + + // 尝试解析完整的 JSON 参数 + let parsedArgs: any = undefined + if (toolCall.streamingArgs) { + try { + parsedArgs = JSON.parse(toolCall.streamingArgs) + toolCall.args = parsedArgs + } catch (e) { + logger.warn(`🔧 [ToolCallChunkHandler] Failed to parse streaming args for ${toolCallId}:`, e as Error) + // 保留原始字符串 + toolCall.args = toolCall.streamingArgs + } + } + + logger.info(`🔧 [ToolCallChunkHandler] Tool input streaming completed: ${toolCall.toolName} (${toolCallId})`) + + // 发送 streaming 完成 chunk + const toolResponse: MCPToolResponse | NormalToolResponse = { + id: toolCallId, + tool: toolCall.tool, + arguments: parsedArgs, + status: 'pending', + toolCallId: toolCallId, + partialArguments: toolCall.streamingArgs + } + + this.onChunk({ + type: ChunkType.MCP_TOOL_STREAMING, + responses: [toolResponse] + }) + } /** * 处理工具调用事件 @@ -191,6 +246,15 @@ export class ToolCallChunkHandler { return } + // Check if this tool call was already processed via streaming events + const existingToolCall = this.activeToolCalls.get(toolCallId) + if (existingToolCall?.streamingArgs !== undefined) { + // Tool call was already processed via streaming events (tool-input-start/delta/end) + // Update args if needed, but don't emit duplicate pending chunk + existingToolCall.args = args + return + } + let tool: BaseTool let mcpTool: MCPTool | undefined // 根据 providerExecuted 标志区分处理逻辑 @@ -216,11 +280,6 @@ export class ToolCallChunkHandler { // 如果是客户端执行的 MCP 工具,沿用现有逻辑 // toolName is mcpTool.id (registered with id as key in convertMcpToolsToAiSdkTools) logger.info(`[ToolCallChunkHandler] Handling client-side MCP tool: ${toolName}`) - // mcpTool = this.mcpTools.find((t) => t.name === toolName) as MCPTool - // if (!mcpTool) { - // logger.warn(`[ToolCallChunkHandler] MCP tool not found: ${toolName}`) - // return - // } tool = mcpTool } else { tool = { @@ -357,40 +416,20 @@ export class ToolCallChunkHandler { export const addActiveToolCall = ToolCallChunkHandler.addActiveToolCall.bind(ToolCallChunkHandler) +/** + * 从工具输出中提取图片(使用 MCP SDK 类型安全验证) + */ 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) + const result = CallToolResultSchema.safeParse(output) + if (result.success) { + return result.data.content + .filter((c) => c.type === 'image') + .map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`) } - 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' + return [] } diff --git a/src/renderer/src/aiCore/plugins/PluginBuilder.ts b/src/renderer/src/aiCore/plugins/PluginBuilder.ts index d154109f4e..13db9de183 100644 --- a/src/renderer/src/aiCore/plugins/PluginBuilder.ts +++ b/src/renderer/src/aiCore/plugins/PluginBuilder.ts @@ -26,7 +26,7 @@ const logger = loggerService.withContext('PluginBuilder') * 根据条件构建插件数组 */ export function buildPlugins( - middlewareConfig: AiSdkMiddlewareConfig & { assistant: Assistant; topicId?: string } + middlewareConfig: AiSdkMiddlewareConfig & { assistant: Assistant; topicId?: string; mcpMode?: string } ): AiPlugin[] { const plugins: AiPlugin<any, any>[] = [] @@ -112,7 +112,8 @@ export function buildPlugins( if (middlewareConfig.isPromptToolUse) { plugins.push( createPromptToolUsePlugin({ - enabled: true + enabled: true, + mcpMode: middlewareConfig.mcpMode }) ) } diff --git a/src/renderer/src/aiCore/plugins/anthropicCacheMiddleware.ts b/src/renderer/src/aiCore/plugins/anthropicCacheMiddleware.ts new file mode 100644 index 0000000000..80a391b863 --- /dev/null +++ b/src/renderer/src/aiCore/plugins/anthropicCacheMiddleware.ts @@ -0,0 +1,85 @@ +/** + * Anthropic Prompt Caching Middleware + * @see https://ai-sdk.dev/providers/ai-sdk-providers/anthropic#cache-control + */ +import type { LanguageModelV2Message } from '@ai-sdk/provider' +import { estimateTextTokens } from '@renderer/services/TokenService' +import type { Provider } from '@renderer/types' +import type { LanguageModelMiddleware } from 'ai' + +const cacheProviderOptions = { + anthropic: { cacheControl: { type: 'ephemeral' } } +} + +function estimateContentTokens(content: LanguageModelV2Message['content']): number { + if (typeof content === 'string') return estimateTextTokens(content) + if (Array.isArray(content)) { + return content.reduce((acc, part) => { + if (part.type === 'text') { + return acc + estimateTextTokens(part.text as string) + } + return acc + }, 0) + } + return 0 +} + +export function anthropicCacheMiddleware(provider: Provider): LanguageModelMiddleware { + return { + middlewareVersion: 'v2', + transformParams: async ({ params }) => { + const settings = provider.anthropicCacheControl + if (!settings?.tokenThreshold || !Array.isArray(params.prompt) || params.prompt.length === 0) { + return params + } + + const { tokenThreshold, cacheSystemMessage, cacheLastNMessages } = settings + const messages = [...params.prompt] + let cachedCount = 0 + + // Cache system message (providerOptions on message object) + if (cacheSystemMessage) { + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] as LanguageModelV2Message + if (msg.role === 'system' && estimateContentTokens(msg.content) >= tokenThreshold) { + messages[i] = { ...msg, providerOptions: cacheProviderOptions } + break + } + } + } + + // Cache last N non-system messages (providerOptions on content parts) + if (cacheLastNMessages > 0) { + const cumsumTokens = [] as Array<number> + let tokenSum = 0 as number + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] as LanguageModelV2Message + tokenSum += estimateContentTokens(msg.content) + cumsumTokens.push(tokenSum) + } + + for (let i = messages.length - 1; i >= 0 && cachedCount < cacheLastNMessages; i--) { + const msg = messages[i] as LanguageModelV2Message + if (msg.role === 'system' || cumsumTokens[i] < tokenThreshold || msg.content.length === 0) { + continue + } + + const newContent = [...msg.content] + const lastIndex = newContent.length - 1 + newContent[lastIndex] = { + ...newContent[lastIndex], + providerOptions: cacheProviderOptions + } + + messages[i] = { + ...msg, + content: newContent + } as LanguageModelV2Message + cachedCount++ + } + } + + return { ...params, prompt: messages } + } + } +} diff --git a/src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts b/src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts index 2a69f3bcef..4f76cbee7b 100644 --- a/src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts +++ b/src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts @@ -263,7 +263,7 @@ describe('messageConverter', () => { }) describe('convertMessagesToSdkMessages', () => { - it('collapses to [system?, user(image)] for image enhancement models', async () => { + it('preserves conversation history and merges images for image enhancement models', async () => { const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) const initialUser = createMessage('user') initialUser.__mockContent = 'Start editing' @@ -277,7 +277,16 @@ describe('messageConverter', () => { const result = await convertMessagesToSdkMessages([initialUser, assistant, finalUser], model) + // Preserves all conversation history, only merges images into the last user message expect(result).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'Start editing' }] + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Here is the current preview' }] + }, { role: 'user', content: [ @@ -288,7 +297,7 @@ describe('messageConverter', () => { ]) }) - it('preserves system messages and collapses others for enhancement payloads', async () => { + it('preserves system messages and conversation history for enhancement payloads', async () => { const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) const fileUser = createMessage('user') fileUser.__mockContent = 'Use this document as inspiration' @@ -309,8 +318,17 @@ describe('messageConverter', () => { const result = await convertMessagesToSdkMessages([fileUser, assistant, finalUser], model) + // Preserves system message, conversation history, and merges images into the last user message expect(result).toEqual([ { role: 'system', content: 'fileid://reference' }, + { + role: 'user', + content: [{ type: 'text', text: 'Use this document as inspiration' }] + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Generated previews ready' }] + }, { role: 'user', content: [ @@ -321,7 +339,7 @@ describe('messageConverter', () => { ]) }) - it('handles no previous assistant message with images', async () => { + it('returns messages as-is when no previous assistant message with images', async () => { const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) const user1 = createMessage('user') user1.__mockContent = 'Start' @@ -331,7 +349,12 @@ describe('messageConverter', () => { const result = await convertMessagesToSdkMessages([user1, user2], model) + // No images to merge, returns all messages as-is expect(result).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'Start' }] + }, { role: 'user', content: [{ type: 'text', text: 'Continue without images' }] @@ -339,7 +362,7 @@ describe('messageConverter', () => { ]) }) - it('handles assistant message without images', async () => { + it('returns messages as-is when assistant message has no images', async () => { const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) const user1 = createMessage('user') user1.__mockContent = 'Start' @@ -353,7 +376,16 @@ describe('messageConverter', () => { const result = await convertMessagesToSdkMessages([user1, assistant, user2], model) + // No images to merge, returns all messages as-is expect(result).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'Start' }] + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Text only response' }] + }, { role: 'user', content: [{ type: 'text', text: 'Follow up' }] @@ -361,7 +393,7 @@ describe('messageConverter', () => { ]) }) - it('handles multiple assistant messages by using the most recent one', async () => { + it('merges images from the most recent assistant message', async () => { const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) const user1 = createMessage('user') user1.__mockContent = 'Start' @@ -382,7 +414,24 @@ describe('messageConverter', () => { const result = await convertMessagesToSdkMessages([user1, assistant1, user2, assistant2, user3], model) + // Preserves all history, merges only the most recent assistant's images expect(result).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'Start' }] + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'First response' }] + }, + { + role: 'user', + content: [{ type: 'text', text: 'Continue' }] + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Second response' }] + }, { role: 'user', content: [ @@ -393,7 +442,7 @@ describe('messageConverter', () => { ]) }) - it('handles conversation ending with assistant message', async () => { + it('returns messages as-is when conversation ends with assistant message', async () => { const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) const user = createMessage('user') user.__mockContent = 'Start' @@ -406,15 +455,20 @@ describe('messageConverter', () => { // The user message is the last user message, but since the assistant comes after, // there's no "previous" assistant message (search starts from messages.length-2 backwards) + // So no images to merge, returns all messages as-is expect(result).toEqual([ { role: 'user', content: [{ type: 'text', text: 'Start' }] + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Response with image' }] } ]) }) - it('handles empty content in last user message', async () => { + it('merges images even when last user message has empty content', async () => { const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) const user1 = createMessage('user') user1.__mockContent = 'Start' @@ -428,12 +482,79 @@ describe('messageConverter', () => { const result = await convertMessagesToSdkMessages([user1, assistant, user2], model) + // Preserves history, merges images into last user message (even if empty) expect(result).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'Start' }] + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Here is the preview' }] + }, { role: 'user', content: [{ type: 'image', image: 'https://example.com/preview.png' }] } ]) }) + + it('allows using LLM conversation context for image generation', async () => { + // This test verifies the key use case: switching from LLM to image enhancement model + // and using the previous conversation as context for image generation + const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) + + // Simulate a conversation that started with a regular LLM + const user1 = createMessage('user') + user1.__mockContent = 'Help me design a futuristic robot with blue lights' + + const assistant1 = createMessage('assistant') + assistant1.__mockContent = + 'Great idea! The robot could have a sleek metallic body with glowing blue LED strips...' + assistant1.__mockImageBlocks = [] // LLM response, no images + + const user2 = createMessage('user') + user2.__mockContent = 'Yes, and add some chrome accents' + + const assistant2 = createMessage('assistant') + assistant2.__mockContent = 'Perfect! Chrome accents would complement the blue lights beautifully...' + assistant2.__mockImageBlocks = [] // Still LLM response, no images + + // User switches to image enhancement model and asks for image generation + const user3 = createMessage('user') + user3.__mockContent = 'Now generate an image based on our discussion' + + const result = await convertMessagesToSdkMessages([user1, assistant1, user2, assistant2, user3], model) + + // All conversation history should be preserved for context + // No images to merge since previous assistant had no images + expect(result).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'Help me design a futuristic robot with blue lights' }] + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Great idea! The robot could have a sleek metallic body with glowing blue LED strips...' + } + ] + }, + { + role: 'user', + content: [{ type: 'text', text: 'Yes, and add some chrome accents' }] + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Perfect! Chrome accents would complement the blue lights beautifully...' }] + }, + { + role: 'user', + content: [{ type: 'text', text: 'Now generate an image based on our discussion' }] + } + ]) + }) }) }) diff --git a/src/renderer/src/aiCore/prepareParams/messageConverter.ts b/src/renderer/src/aiCore/prepareParams/messageConverter.ts index 56c5f6a4e7..eba16c6619 100644 --- a/src/renderer/src/aiCore/prepareParams/messageConverter.ts +++ b/src/renderer/src/aiCore/prepareParams/messageConverter.ts @@ -229,23 +229,15 @@ export async function convertMessagesToSdkMessages(messages: Message[], model: M sdkMessages.push(...(Array.isArray(sdkMessage) ? sdkMessage : [sdkMessage])) } // Special handling for image enhancement models - // Target behavior: Collapse the conversation into [system?, user(image)]. - // Explanation of why we don't simply use slice: - // 1) We need to preserve all system messages: During the convertMessageToSdkParam process, native file uploads may insert `system(fileid://...)`. - // Directly slicing the original messages or already converted sdkMessages could easily result in missing these system instructions. - // Therefore, we first perform a full conversion and then aggregate the system messages afterward. - // 2) The conversion process may split messages: A single user message might be broken into two SDK messages—[system, user]. - // Slicing either side could lead to obtaining semantically incorrect fragments (e.g., only the split-out system message). - // 3) The “previous assistant message” is not necessarily the second-to-last one: There might be system messages or other message blocks inserted in between, - // making a simple slice(-2) assumption too rigid. Here, we trace back from the end of the original messages to locate the most recent assistant message, which better aligns with business semantics. - // 4) This is a “collapse” rather than a simple “slice”: Ultimately, we need to synthesize a new user message - // (with text from the last user message and images from the previous assistant message). Using slice can only extract subarrays, - // which still require reassembly; constructing directly according to the target structure is clearer and more reliable. + // These models support multi-turn conversations but need images from previous assistant messages + // to be merged into the current user message for editing/enhancement operations. + // + // Key behaviors: + // 1. Preserve all conversation history for context + // 2. Find images from the previous assistant message and merge them into the last user message + // 3. This allows users to switch from LLM conversations and use that context for image generation if (isImageEnhancementModel(model)) { - // Collect all system messages (including ones generated from file uploads) - const systemMessages = sdkMessages.filter((m): m is SystemModelMessage => m.role === 'system') - - // Find the last user message (SDK converted) + // Find the last user SDK message index const lastUserSdkIndex = (() => { for (let i = sdkMessages.length - 1; i >= 0; i--) { if (sdkMessages[i].role === 'user') return i @@ -253,7 +245,10 @@ export async function convertMessagesToSdkMessages(messages: Message[], model: M return -1 })() - const lastUserSdk = lastUserSdkIndex >= 0 ? (sdkMessages[lastUserSdkIndex] as UserModelMessage) : null + // If no user message found, return messages as-is + if (lastUserSdkIndex < 0) { + return sdkMessages + } // Find the nearest preceding assistant message in original messages let prevAssistant: Message | null = null @@ -264,31 +259,33 @@ export async function convertMessagesToSdkMessages(messages: Message[], model: M } } - // Build the final user content parts + // Check if there are images from the previous assistant message + const imageBlocks = prevAssistant ? findImageBlocks(prevAssistant) : [] + const imageParts = await convertImageBlockToImagePart(imageBlocks) + + // If no images to merge, return messages as-is + if (imageParts.length === 0) { + return sdkMessages + } + + // Build the new last user message with merged images + const lastUserSdk = sdkMessages[lastUserSdkIndex] as UserModelMessage let finalUserParts: Array<TextPart | FilePart | ImagePart> = [] - if (lastUserSdk) { - if (typeof lastUserSdk.content === 'string') { - finalUserParts.push({ type: 'text', text: lastUserSdk.content }) - } else if (Array.isArray(lastUserSdk.content)) { - finalUserParts = [...lastUserSdk.content] - } + + if (typeof lastUserSdk.content === 'string') { + finalUserParts.push({ type: 'text', text: lastUserSdk.content }) + } else if (Array.isArray(lastUserSdk.content)) { + finalUserParts = [...lastUserSdk.content] } - // Append images from the previous assistant message if any - if (prevAssistant) { - const imageBlocks = findImageBlocks(prevAssistant) - const imageParts = await convertImageBlockToImagePart(imageBlocks) - if (imageParts.length > 0) { - finalUserParts.push(...imageParts) - } - } + // Append images from the previous assistant message + finalUserParts.push(...imageParts) - // If we couldn't find a last user message, fall back to returning collected system messages only - if (!lastUserSdk) { - return systemMessages - } + // Replace the last user message with the merged version + const result = [...sdkMessages] + result[lastUserSdkIndex] = { role: 'user', content: finalUserParts } - return [...systemMessages, { role: 'user', content: finalUserParts }] + return result } return sdkMessages diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index 3db524616a..80b310662f 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -26,11 +26,13 @@ import { isSupportedThinkingTokenModel, isWebSearchModel } from '@renderer/config/models' +import { getHubModeSystemPrompt } from '@renderer/config/prompts-code-mode' +import { fetchAllActiveServerTools } from '@renderer/services/ApiService' import { getDefaultModel } from '@renderer/services/AssistantService' import store from '@renderer/store' import type { CherryWebSearchConfig } from '@renderer/store/websearch' import type { Model } from '@renderer/types' -import { type Assistant, type MCPTool, type Provider, SystemProviderIds } from '@renderer/types' +import { type Assistant, getEffectiveMcpMode, type MCPTool, type Provider, SystemProviderIds } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' import { replacePromptVariables } from '@renderer/utils/prompt' @@ -241,8 +243,18 @@ export async function buildStreamTextParams( params.tools = tools } - if (assistant.prompt) { - params.system = await replacePromptVariables(assistant.prompt, model.name) + let systemPrompt = assistant.prompt ? await replacePromptVariables(assistant.prompt, model.name) : '' + + if (getEffectiveMcpMode(assistant) === 'auto') { + const allActiveTools = await fetchAllActiveServerTools() + const autoModePrompt = getHubModeSystemPrompt(allActiveTools) + if (autoModePrompt) { + systemPrompt = systemPrompt ? `${systemPrompt}\n\n${autoModePrompt}` : autoModePrompt + } + } + + if (systemPrompt) { + params.system = systemPrompt } logger.debug('params', params) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 0ad15ea895..3ab329eafe 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -1,5 +1,5 @@ import { formatPrivateKey, hasProviderConfig, ProviderConfigFactory } from '@cherrystudio/ai-core/provider' -import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models' +import { isOpenAIChatCompletionOnlyModel, isOpenAIReasoningModel } from '@renderer/config/models' import { getAwsBedrockAccessKeyId, getAwsBedrockApiKey, @@ -11,6 +11,7 @@ import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useV import { getProviderByModel } from '@renderer/services/AssistantService' import { getProviderById } from '@renderer/services/ProviderService' import store from '@renderer/store' +import type { EndpointType } from '@renderer/types' import { isSystemProvider, type Model, type Provider, SystemProviderIds } from '@renderer/types' import type { OpenAICompletionsStreamOptions } from '@renderer/types/aiCoreTypes' import { @@ -29,6 +30,7 @@ import { isNewApiProvider, isOllamaProvider, isPerplexityProvider, + isSupportDeveloperRoleProvider, isSupportStreamOptionsProvider, isVertexProvider } from '@renderer/utils/provider' @@ -139,6 +141,48 @@ export function adaptProvider({ provider, model }: { provider: Provider; model?: return adaptedProvider } +interface BaseExtraOptions { + fetch?: typeof fetch + endpoint: string + mode?: 'responses' | 'chat' + headers: Record<string, string> +} + +interface AzureOpenAIExtraOptions extends BaseExtraOptions { + apiVersion: string + useDeploymentBasedUrls: true | undefined +} + +interface BedrockApiKeyExtraOptions extends BaseExtraOptions { + region: string + apiKey: string +} + +interface BedrockAccessKeyExtraOptions extends BaseExtraOptions { + region: string + accessKeyId: string + secretAccessKey: string +} + +type BedrockExtraOptions = BedrockApiKeyExtraOptions | BedrockAccessKeyExtraOptions + +interface VertexExtraOptions extends BaseExtraOptions { + project: string + location: string + googleCredentials: { + privateKey: string + clientEmail: string + } +} + +interface CherryInExtraOptions extends BaseExtraOptions { + endpointType?: EndpointType + anthropicBaseURL?: string + geminiBaseURL?: string +} + +type ExtraOptions = BedrockExtraOptions | AzureOpenAIExtraOptions | VertexExtraOptions | CherryInExtraOptions + /** * 将 Provider 配置转换为新 AI SDK 格式 * 简化版:利用新的别名映射系统 @@ -157,6 +201,8 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A includeUsage = store.getState().settings.openAI?.streamOptions?.includeUsage } + // Specially, some providers which need to early return + // Copilot const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot if (isCopilotProvider) { const storedHeaders = store.getState().copilot.defaultHeaders ?? {} @@ -176,6 +222,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A } } + // Ollama if (isOllamaProvider(actualProvider)) { return { providerId: 'ollama', @@ -189,98 +236,142 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A } } - // 处理OpenAI模式 - const extraOptions: any = {} - extraOptions.endpoint = endpoint - if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) { - extraOptions.mode = 'responses' - } else if (aiSdkProviderId === 'openai' || (aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai')) { - extraOptions.mode = 'chat' + // Generally, construct extraOptions according to provider & model + // Consider as OpenAI like provider + + // Construct baseExtraOptions first + // About mode of azure: + // https://learn.microsoft.com/en-us/azure/ai-foundry/openai/latest + // https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#responses-api + let mode: BaseExtraOptions['mode'] + if ( + (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) || + aiSdkProviderId === 'azure-responses' + ) { + mode = 'responses' + } else if ( + aiSdkProviderId === 'openai' || + (aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai') || + aiSdkProviderId === 'azure' + ) { + mode = 'chat' } - extraOptions.headers = { + const headers: BaseExtraOptions['headers'] = { ...defaultAppHeaders(), ...actualProvider.extra_headers } - if (aiSdkProviderId === 'openai') { - extraOptions.headers['X-Api-Key'] = baseConfig.apiKey + if (actualProvider.extra_headers?.['X-Api-Key'] === undefined) { + headers['X-Api-Key'] = baseConfig.apiKey + } } - // azure - // https://learn.microsoft.com/en-us/azure/ai-foundry/openai/latest - // https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#responses-api - if (aiSdkProviderId === 'azure-responses') { - extraOptions.mode = 'responses' - } else if (aiSdkProviderId === 'azure') { - extraOptions.mode = 'chat' + + let _fetch: typeof fetch | undefined + + // Apply developer-to-system role conversion for providers that don't support developer role + // bug: https://github.com/vercel/ai/issues/10982 + // fixPR: https://github.com/vercel/ai/pull/11127 + // TODO: but the PR don't backport to v5, the code will be removed when upgrading to v6 + if (!isSupportDeveloperRoleProvider(actualProvider) || !isOpenAIReasoningModel(model)) { + _fetch = createDeveloperToSystemFetch(fetch) } + + const baseExtraOptions = { + fetch: _fetch, + endpoint, + mode, + headers + } as const satisfies BaseExtraOptions + + // Create specifical fields in extraOptions for different provider + let extraOptions: ExtraOptions | undefined if (isAzureOpenAIProvider(actualProvider)) { const apiVersion = actualProvider.apiVersion?.trim() + let useDeploymentBasedUrls: true | undefined if (apiVersion) { - extraOptions.apiVersion = apiVersion if (!['preview', 'v1'].includes(apiVersion)) { - extraOptions.useDeploymentBasedUrls = true + useDeploymentBasedUrls = true } } - } - - // bedrock - if (aiSdkProviderId === 'bedrock') { + extraOptions = { + ...baseExtraOptions, + apiVersion, + useDeploymentBasedUrls + } satisfies AzureOpenAIExtraOptions + } else if (aiSdkProviderId === 'bedrock') { + // bedrock const authType = getAwsBedrockAuthType() - extraOptions.region = getAwsBedrockRegion() + const region = getAwsBedrockRegion() if (authType === 'apiKey') { - extraOptions.apiKey = getAwsBedrockApiKey() + extraOptions = { + ...baseExtraOptions, + region, + apiKey: getAwsBedrockApiKey() + } satisfies BedrockApiKeyExtraOptions } else { - extraOptions.accessKeyId = getAwsBedrockAccessKeyId() - extraOptions.secretAccessKey = getAwsBedrockSecretAccessKey() + extraOptions = { + ...baseExtraOptions, + region, + accessKeyId: getAwsBedrockAccessKeyId(), + secretAccessKey: getAwsBedrockSecretAccessKey() + } satisfies BedrockAccessKeyExtraOptions } - } - // google-vertex - if (aiSdkProviderId === 'google-vertex' || aiSdkProviderId === 'google-vertex-anthropic') { + } else if (aiSdkProviderId === 'google-vertex' || aiSdkProviderId === 'google-vertex-anthropic') { + // google-vertex if (!isVertexAIConfigured()) { throw new Error('VertexAI is not configured. Please configure project, location and service account credentials.') } const { project, location, googleCredentials } = createVertexProvider(actualProvider) - extraOptions.project = project - extraOptions.location = location - extraOptions.googleCredentials = { - ...googleCredentials, - privateKey: formatPrivateKey(googleCredentials.privateKey) - } + extraOptions = { + ...baseExtraOptions, + project, + location, + googleCredentials: { + ...googleCredentials, + privateKey: formatPrivateKey(googleCredentials.privateKey) + } + } satisfies VertexExtraOptions baseConfig.baseURL += aiSdkProviderId === 'google-vertex' ? '/publishers/google' : '/publishers/anthropic/models' - } - - // cherryin - if (aiSdkProviderId === 'cherryin') { - if (model.endpoint_type) { - extraOptions.endpointType = model.endpoint_type - } + } else if (aiSdkProviderId === 'cherryin') { // CherryIN API Host const cherryinProvider = getProviderById(SystemProviderIds.cherryin) + const endpointType: EndpointType | undefined = model.endpoint_type + let anthropicBaseURL: string | undefined + let geminiBaseURL: string | undefined if (cherryinProvider) { - extraOptions.anthropicBaseURL = cherryinProvider.anthropicApiHost + '/v1' - extraOptions.geminiBaseURL = cherryinProvider.apiHost + '/v1beta/models' + anthropicBaseURL = cherryinProvider.anthropicApiHost + '/v1' + geminiBaseURL = cherryinProvider.apiHost + '/v1beta/models' } + extraOptions = { + ...baseExtraOptions, + endpointType, + anthropicBaseURL, + geminiBaseURL + } satisfies CherryInExtraOptions + } else { + extraOptions = baseExtraOptions } if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') { + // if the provider has a specific aisdk provider const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions) return { providerId: aiSdkProviderId, options } - } - - // 否则fallback到openai-compatible - const options = ProviderConfigFactory.createOpenAICompatible(baseConfig.baseURL, baseConfig.apiKey) - return { - providerId: 'openai-compatible', - options: { - ...options, - name: actualProvider.id, - ...extraOptions, - includeUsage + } else { + // otherwise, fallback to openai-compatible + const options = ProviderConfigFactory.createOpenAICompatible(baseConfig.baseURL, baseConfig.apiKey) + return { + providerId: 'openai-compatible', + options: { + ...options, + name: actualProvider.id, + ...extraOptions, + includeUsage + } } } } @@ -302,6 +393,44 @@ export function isModernSdkSupported(provider: Provider): boolean { return hasProviderConfig(aiSdkProviderId) } +/** + * Creates a custom fetch wrapper that converts 'developer' role to 'system' role in request body. + * This is needed for providers that don't support the 'developer' role (e.g., Azure DeepSeek R1). + * + * @param originalFetch - Optional original fetch function to wrap + * @returns A fetch function that transforms the request body + */ +function createDeveloperToSystemFetch(originalFetch?: typeof fetch): typeof fetch { + const baseFetch = originalFetch ?? fetch + return async (input: RequestInfo | URL, init?: RequestInit) => { + let options = init + if (options?.body && typeof options.body === 'string') { + try { + const body = JSON.parse(options.body) + if (body.input && Array.isArray(body.input)) { + let hasChanges = false + body.input = body.input.map((msg: { role: string }) => { + if (msg.role === 'developer') { + hasChanges = true + return { ...msg, role: 'system' } + } + return msg + }) + if (hasChanges) { + options = { + ...options, + body: JSON.stringify(body) + } + } + } + } catch { + // If parsing fails, just use original body + } + } + return baseFetch(input, options) + } +} + /** * 准备特殊provider的配置,主要用于异步处理的配置 */ @@ -360,5 +489,6 @@ export async function prepareSpecialProviderConfig( } } } + return config } diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index 8dc7a10af9..b6bd63e4e7 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -3,7 +3,7 @@ import { type AnthropicProviderOptions } from '@ai-sdk/anthropic' import type { GoogleGenerativeAIProviderOptions } from '@ai-sdk/google' import type { OpenAIResponsesProviderOptions } from '@ai-sdk/openai' import type { XaiProviderOptions } from '@ai-sdk/xai' -import { baseProviderIdSchema, customProviderIdSchema } from '@cherrystudio/ai-core/provider' +import { baseProviderIdSchema, customProviderIdSchema, hasProviderConfig } from '@cherrystudio/ai-core/provider' import { loggerService } from '@logger' import { getModelSupportedVerbosity, @@ -616,9 +616,14 @@ function buildGenericProviderOptions( } if (enableReasoning) { if (isInterleavedThinkingModel(model)) { - providerOptions = { - ...providerOptions, - sendReasoning: true + // sendReasoning is a patch specific to @ai-sdk/openai-compatible + // Only apply when provider will actually use openai-compatible SDK + // (i.e., no dedicated SDK registered OR explicitly openai-compatible) + if (!hasProviderConfig(providerId) || providerId === 'openai-compatible') { + providerOptions = { + ...providerOptions, + sendReasoning: true + } } } } @@ -648,6 +653,10 @@ function buildGenericProviderOptions( } } + if (isOpenAIModel(model)) { + providerOptions.strictJsonSchema = false + } + return { [providerId]: providerOptions } diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 469a9c5dfb..08db320351 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -28,6 +28,7 @@ import { isSupportedThinkingTokenDoubaoModel, isSupportedThinkingTokenGeminiModel, isSupportedThinkingTokenHunyuanModel, + isSupportedThinkingTokenKimiModel, isSupportedThinkingTokenMiMoModel, isSupportedThinkingTokenModel, isSupportedThinkingTokenQwenModel, @@ -82,10 +83,10 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin // providers that use enable_thinking if ( - isSupportEnableThinkingProvider(provider) && - (isSupportedThinkingTokenQwenModel(model) || - isSupportedThinkingTokenHunyuanModel(model) || - (provider.id === SystemProviderIds.dashscope && isDeepSeekHybridInferenceModel(model))) + (isSupportEnableThinkingProvider(provider) && + (isSupportedThinkingTokenQwenModel(model) || isSupportedThinkingTokenHunyuanModel(model))) || + (provider.id === SystemProviderIds.dashscope && + (isDeepSeekHybridInferenceModel(model) || isSupportedThinkingTokenZhipuModel(model))) ) { return { enable_thinking: false } } @@ -109,7 +110,11 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin } // use thinking, doubao, zhipu, etc. - if (isSupportedThinkingTokenDoubaoModel(model) || isSupportedThinkingTokenZhipuModel(model)) { + if ( + isSupportedThinkingTokenDoubaoModel(model) || + isSupportedThinkingTokenZhipuModel(model) || + isSupportedThinkingTokenKimiModel(model) + ) { if (provider.id === SystemProviderIds.cerebras) { return { disable_reasoning: true @@ -309,18 +314,24 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin } } + // https://help.aliyun.com/zh/model-studio/deep-thinking + if (provider.id === SystemProviderIds.dashscope) { + // For dashscope: Qwen, DeepSeek, and GLM models use enable_thinking to control thinking + // No effort, only on/off + if (isQwenReasoningModel(model) || isSupportedThinkingTokenZhipuModel(model)) { + return { + enable_thinking: true, + thinking_budget: budgetTokens + } + } + } + // Qwen models, use enable_thinking if (isQwenReasoningModel(model)) { const thinkConfig = { enable_thinking: isQwenAlwaysThinkModel(model) || !isSupportEnableThinkingProvider(provider) ? undefined : true, thinking_budget: budgetTokens } - if (provider.id === SystemProviderIds.dashscope) { - return { - ...thinkConfig, - incremental_output: true - } - } return thinkConfig } @@ -413,7 +424,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin return { thinking: { type: 'enabled' } } } - if (isSupportedThinkingTokenMiMoModel(model)) { + if (isSupportedThinkingTokenMiMoModel(model) || isSupportedThinkingTokenKimiModel(model)) { return { thinking: { type: 'enabled' } } diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index a51b718ecf..2c5deff213 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -182,7 +182,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht ref={previewFrameRef} key={html} // Force recreate iframe when preview content changes srcDoc={html} - title="HTML Preview" + title={t('common.html_preview')} sandbox="allow-scripts allow-same-origin allow-forms" /> ) : ( diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index 8b251b9603..6433638248 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -128,7 +128,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }, []) const handleCopySource = useCallback(() => { - navigator.clipboard.writeText(children) + navigator.clipboard.writeText(children.trimEnd()) window.toast.success(t('code_block.copy.success')) }, [children, t]) diff --git a/src/renderer/src/components/ErrorDetailModal/index.tsx b/src/renderer/src/components/ErrorDetailModal/index.tsx new file mode 100644 index 0000000000..0cbd77ab49 --- /dev/null +++ b/src/renderer/src/components/ErrorDetailModal/index.tsx @@ -0,0 +1,555 @@ +import CodeViewer from '@renderer/components/CodeViewer' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' +import type { SerializedAiSdkError, SerializedAiSdkErrorUnion, SerializedError } from '@renderer/types/error' +import { + isSerializedAiSdkAPICallError, + isSerializedAiSdkDownloadError, + isSerializedAiSdkError, + isSerializedAiSdkErrorUnion, + isSerializedAiSdkInvalidArgumentError, + isSerializedAiSdkInvalidDataContentError, + isSerializedAiSdkInvalidMessageRoleError, + isSerializedAiSdkInvalidPromptError, + isSerializedAiSdkInvalidToolInputError, + isSerializedAiSdkJSONParseError, + isSerializedAiSdkMessageConversionError, + isSerializedAiSdkNoObjectGeneratedError, + isSerializedAiSdkNoSpeechGeneratedError, + isSerializedAiSdkNoSuchModelError, + isSerializedAiSdkNoSuchProviderError, + isSerializedAiSdkNoSuchToolError, + isSerializedAiSdkRetryError, + isSerializedAiSdkToolCallRepairError, + isSerializedAiSdkTooManyEmbeddingValuesForCallError, + isSerializedAiSdkTypeValidationError, + isSerializedAiSdkUnsupportedFunctionalityError, + isSerializedError +} from '@renderer/types/error' +import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error' +import { parseDataUrl } from '@shared/utils' +import { Button } from 'antd' +import { Modal } from 'antd' +import React, { memo, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ErrorDetailModalProps { + open: boolean + onClose: () => void + error?: SerializedError +} + +const truncateLargeData = ( + data: string, + t: (key: string) => string +): { content: string; truncated: boolean; isLikelyBase64: boolean } => { + const parsed = parseDataUrl(data) + const isLikelyBase64 = parsed?.isBase64 ?? false + + if (!data || data.length <= 100_000) { + return { content: data, truncated: false, isLikelyBase64 } + } + + if (isLikelyBase64) { + return { + content: `[${t('error.base64DataTruncated')}]`, + truncated: true, + isLikelyBase64: true + } + } + + return { + content: data.slice(0, 100_000) + `\n\n... [${t('error.truncated')}]`, + truncated: true, + isLikelyBase64: false + } +} + +// --- Styled Components --- + +const ErrorDetailContainer = styled.div` + max-height: 60vh; + overflow-y: auto; +` + +const ErrorDetailList = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +` + +const ErrorDetailItem = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +` + +const ErrorDetailLabel = styled.div` + font-weight: 600; + color: var(--color-text); + font-size: 14px; +` + +const ErrorDetailValue = styled.div` + font-family: var(--code-font-family); + font-size: 12px; + padding: 8px; + background: var(--color-code-background); + border-radius: 4px; + border: 1px solid var(--color-border); + word-break: break-word; + color: var(--color-text); +` + +const StackTrace = styled.div` + background: var(--color-background-soft); + border: 1px solid var(--color-error); + border-radius: 6px; + padding: 12px; + + pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--code-font-family); + font-size: 12px; + line-height: 1.4; + color: var(--color-error); + } +` + +const TruncatedBadge = styled.span` + margin-left: 8px; + padding: 2px 6px; + font-size: 10px; + font-weight: normal; + color: var(--color-warning); + background: var(--color-warning-bg, rgba(250, 173, 20, 0.1)); + border-radius: 4px; +` + +// --- Sub-Components --- + +const BuiltinError = memo(({ error }: { error: SerializedError }) => { + const { t } = useTranslation() + return ( + <> + {error.name && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.name')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.name}</ErrorDetailValue> + </ErrorDetailItem> + )} + {error.message && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.message')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.message}</ErrorDetailValue> + </ErrorDetailItem> + )} + {error.stack && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.stack')}:</ErrorDetailLabel> + <StackTrace> + <pre>{error.stack}</pre> + </StackTrace> + </ErrorDetailItem> + )} + </> + ) +}) + +const AiSdkErrorBase = memo(({ error }: { error: SerializedAiSdkError }) => { + const { t } = useTranslation() + const tRef = useRef(t) + useEffect(() => { + tRef.current = t + }, [t]) + + const { highlightCode } = useCodeStyle() + const [highlightedString, setHighlightedString] = useState('') + const [isTruncated, setIsTruncated] = useState(false) + const cause = error.cause + + useEffect(() => { + const highlight = async () => { + try { + const { content: truncatedCause, truncated, isLikelyBase64 } = truncateLargeData(cause || '', tRef.current) + setIsTruncated(truncated) + + if (isLikelyBase64) { + setHighlightedString(truncatedCause) + return + } + + try { + const parsed = JSON.parse(truncatedCause || '{}') + const formatted = JSON.stringify(parsed, null, 2) + const result = await highlightCode(formatted, 'json') + setHighlightedString(result) + } catch { + setHighlightedString(truncatedCause || '') + } + } catch { + setHighlightedString(cause || '') + } + } + const timer = setTimeout(highlight, 0) + + return () => clearTimeout(timer) + }, [highlightCode, cause]) + + return ( + <> + <BuiltinError error={error} /> + {cause && ( + <ErrorDetailItem> + <ErrorDetailLabel> + {t('error.cause')}:{isTruncated && <TruncatedBadge>{t('error.truncatedBadge')}</TruncatedBadge>} + </ErrorDetailLabel> + <ErrorDetailValue> + <div + className="markdown [&_pre]:!bg-transparent [&_pre_span]:whitespace-pre-wrap" + dangerouslySetInnerHTML={{ __html: highlightedString }} + /> + </ErrorDetailValue> + </ErrorDetailItem> + )} + </> + ) +}) + +const TruncatedCodeViewer = memo( + ({ value, label, language = 'json' }: { value: string; label: string; language?: string }) => { + const { t } = useTranslation() + const { content, truncated, isLikelyBase64 } = truncateLargeData(value, t) + + return ( + <ErrorDetailItem> + <ErrorDetailLabel> + {label}:{truncated && <TruncatedBadge>{t('error.truncatedBadge')}</TruncatedBadge>} + </ErrorDetailLabel> + {isLikelyBase64 ? ( + <ErrorDetailValue>{content}</ErrorDetailValue> + ) : ( + <CodeViewer value={content} className="source-view" language={language} expanded /> + )} + </ErrorDetailItem> + ) + } +) + +const AiSdkError = memo(({ error }: { error: SerializedAiSdkErrorUnion }) => { + const { t } = useTranslation() + + return ( + <ErrorDetailList> + {(isSerializedAiSdkAPICallError(error) || isSerializedAiSdkDownloadError(error)) && error.url && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.requestUrl')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.url}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkAPICallError(error) && error.responseBody && ( + <TruncatedCodeViewer value={error.responseBody} label={t('error.responseBody')} /> + )} + + {(isSerializedAiSdkAPICallError(error) || isSerializedAiSdkDownloadError(error)) && error.statusCode && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.statusCode')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.statusCode}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkAPICallError(error) && ( + <> + {error.responseHeaders && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.responseHeaders')}:</ErrorDetailLabel> + <CodeViewer + value={JSON.stringify(error.responseHeaders, null, 2)} + className="source-view" + language="json" + expanded + /> + </ErrorDetailItem> + )} + + {error.requestBodyValues && ( + <TruncatedCodeViewer value={safeToString(error.requestBodyValues)} label={t('error.requestBodyValues')} /> + )} + + {error.data && <TruncatedCodeViewer value={safeToString(error.data)} label={t('error.data')} />} + </> + )} + + {isSerializedAiSdkDownloadError(error) && error.statusText && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.statusText')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.statusText}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkInvalidArgumentError(error) && error.parameter && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.parameter')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.parameter}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {(isSerializedAiSdkInvalidArgumentError(error) || isSerializedAiSdkTypeValidationError(error)) && error.value && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.value')}:</ErrorDetailLabel> + <ErrorDetailValue>{safeToString(error.value)}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkInvalidDataContentError(error) && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.content')}:</ErrorDetailLabel> + <ErrorDetailValue>{safeToString(error.content)}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkInvalidMessageRoleError(error) && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.role')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.role}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkInvalidPromptError(error) && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.prompt')}:</ErrorDetailLabel> + <ErrorDetailValue>{safeToString(error.prompt)}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkInvalidToolInputError(error) && ( + <> + {error.toolName && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.toolName')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.toolName}</ErrorDetailValue> + </ErrorDetailItem> + )} + {error.toolInput && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.toolInput')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.toolInput}</ErrorDetailValue> + </ErrorDetailItem> + )} + </> + )} + + {(isSerializedAiSdkJSONParseError(error) || isSerializedAiSdkNoObjectGeneratedError(error)) && error.text && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.text')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.text}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkMessageConversionError(error) && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.originalMessage')}:</ErrorDetailLabel> + <ErrorDetailValue>{safeToString(error.originalMessage)}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkNoSpeechGeneratedError(error) && error.responses && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.responses')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.responses.join(', ')}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkNoObjectGeneratedError(error) && ( + <> + {error.response && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.response')}:</ErrorDetailLabel> + <ErrorDetailValue>{safeToString(error.response)}</ErrorDetailValue> + </ErrorDetailItem> + )} + {error.usage && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.usage')}:</ErrorDetailLabel> + <ErrorDetailValue>{safeToString(error.usage)}</ErrorDetailValue> + </ErrorDetailItem> + )} + {error.finishReason && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.finishReason')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.finishReason}</ErrorDetailValue> + </ErrorDetailItem> + )} + </> + )} + + {(isSerializedAiSdkNoSuchModelError(error) || + isSerializedAiSdkNoSuchProviderError(error) || + isSerializedAiSdkTooManyEmbeddingValuesForCallError(error)) && + error.modelId && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.modelId')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.modelId}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {(isSerializedAiSdkNoSuchModelError(error) || isSerializedAiSdkNoSuchProviderError(error)) && error.modelType && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.modelType')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.modelType}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkNoSuchProviderError(error) && ( + <> + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.providerId')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.providerId}</ErrorDetailValue> + </ErrorDetailItem> + + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.availableProviders')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.availableProviders.join(', ')}</ErrorDetailValue> + </ErrorDetailItem> + </> + )} + + {isSerializedAiSdkNoSuchToolError(error) && ( + <> + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.toolName')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.toolName}</ErrorDetailValue> + </ErrorDetailItem> + {error.availableTools && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.availableTools')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.availableTools?.join(', ') || t('common.none')}</ErrorDetailValue> + </ErrorDetailItem> + )} + </> + )} + + {isSerializedAiSdkRetryError(error) && ( + <> + {error.reason && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.reason')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.reason}</ErrorDetailValue> + </ErrorDetailItem> + )} + {error.lastError && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.lastError')}:</ErrorDetailLabel> + <ErrorDetailValue>{safeToString(error.lastError)}</ErrorDetailValue> + </ErrorDetailItem> + )} + {error.errors && error.errors.length > 0 && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.errors')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.errors.map((e) => safeToString(e)).join('\n\n')}</ErrorDetailValue> + </ErrorDetailItem> + )} + </> + )} + + {isSerializedAiSdkTooManyEmbeddingValuesForCallError(error) && ( + <> + {error.provider && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.provider')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.provider}</ErrorDetailValue> + </ErrorDetailItem> + )} + {error.maxEmbeddingsPerCall && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.maxEmbeddingsPerCall')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.maxEmbeddingsPerCall}</ErrorDetailValue> + </ErrorDetailItem> + )} + {error.values && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.values')}:</ErrorDetailLabel> + <ErrorDetailValue>{safeToString(error.values)}</ErrorDetailValue> + </ErrorDetailItem> + )} + </> + )} + + {isSerializedAiSdkToolCallRepairError(error) && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.originalError')}:</ErrorDetailLabel> + <ErrorDetailValue>{safeToString(error.originalError)}</ErrorDetailValue> + </ErrorDetailItem> + )} + + {isSerializedAiSdkUnsupportedFunctionalityError(error) && ( + <ErrorDetailItem> + <ErrorDetailLabel>{t('error.functionality')}:</ErrorDetailLabel> + <ErrorDetailValue>{error.functionality}</ErrorDetailValue> + </ErrorDetailItem> + )} + + <AiSdkErrorBase error={error} /> + </ErrorDetailList> + ) +}) + +// --- Main Component --- + +const ErrorDetailModal: React.FC<ErrorDetailModalProps> = ({ open, onClose, error }) => { + const { t } = useTranslation() + + const copyErrorDetails = useCallback(() => { + if (!error) return + let errorText: string + if (isSerializedAiSdkError(error)) { + errorText = formatAiSdkError(error) + } else if (isSerializedError(error)) { + errorText = formatError(error) + } else { + errorText = safeToString(error) + } + + navigator.clipboard.writeText(errorText) + window.toast.addToast({ title: t('message.copied') }) + }, [error, t]) + + const renderErrorDetails = (error?: SerializedError) => { + if (!error) return <div>{t('error.unknown')}</div> + if (isSerializedAiSdkErrorUnion(error)) { + return <AiSdkError error={error} /> + } + return ( + <ErrorDetailList> + <BuiltinError error={error} /> + </ErrorDetailList> + ) + } + + return ( + <Modal + centered + title={t('error.detail')} + open={open} + onCancel={onClose} + footer={[ + <Button key="copy" variant="text" color="default" onClick={copyErrorDetails}> + {t('common.copy')} + </Button>, + <Button key="close" variant="text" color="default" onClick={onClose}> + {t('common.close')} + </Button> + ]} + width="80%" + style={{ maxWidth: '1200px', minWidth: '600px' }}> + <ErrorDetailContainer>{renderErrorDetails(error)}</ErrorDetailContainer> + </Modal> + ) +} + +export { ErrorDetailModal } +export default ErrorDetailModal +export type { ErrorDetailModalProps } diff --git a/src/renderer/src/components/FreeTrialModelTag.tsx b/src/renderer/src/components/FreeTrialModelTag.tsx index ad142ae0cf..88a4295716 100644 --- a/src/renderer/src/components/FreeTrialModelTag.tsx +++ b/src/renderer/src/components/FreeTrialModelTag.tsx @@ -3,6 +3,7 @@ import NavigationService from '@renderer/services/NavigationService' import type { Model } from '@renderer/types' import { ArrowUpRight } from 'lucide-react' import type { FC, MouseEvent } from 'react' +import { useTranslation } from 'react-i18next' import styled from 'styled-components' import IndicatorLight from './IndicatorLight' @@ -15,6 +16,8 @@ interface Props { } export const FreeTrialModelTag: FC<Props> = ({ model, showLabel = true }) => { + const { t } = useTranslation() + if (model.provider !== 'cherryai') { return null } @@ -57,7 +60,7 @@ export const FreeTrialModelTag: FC<Props> = ({ model, showLabel = true }) => { return ( <Container> <IndicatorLight size={6} color="var(--color-primary)" animation={false} shadow={false} /> - <PoweredBy>Powered by </PoweredBy> + <PoweredBy>{t('common.powered_by')}</PoweredBy> <LinkText onClick={onSelectProvider}>{getProviderLabel(providerId)}</LinkText> </Container> ) diff --git a/src/renderer/src/components/HealthStatusIndicator/indicator.tsx b/src/renderer/src/components/HealthStatusIndicator/indicator.tsx index 2807549e28..ff59325de8 100644 --- a/src/renderer/src/components/HealthStatusIndicator/indicator.tsx +++ b/src/renderer/src/components/HealthStatusIndicator/indicator.tsx @@ -1,27 +1,38 @@ import { CheckCircleFilled, CloseCircleFilled, ExclamationCircleFilled, LoadingOutlined } from '@ant-design/icons' +import { HealthStatus } from '@renderer/types/healthCheck' import { Flex, Tooltip, Typography } from 'antd' -import React, { memo } from 'react' +import React, { memo, useCallback } from 'react' import styled from 'styled-components' import type { HealthResult } from './types' import { useHealthStatus } from './useHealthStatus' -export interface HealthStatusIndicatorProps { +interface HealthStatusIndicatorProps { results: HealthResult[] loading?: boolean showLatency?: boolean + onErrorClick?: (result: HealthResult) => void } const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({ results, loading = false, - showLatency = false + showLatency = false, + onErrorClick }) => { const { overallStatus, tooltip, latencyText } = useHealthStatus({ results, showLatency }) + const handleClick = useCallback(() => { + if (!onErrorClick) return + const failedResult = results.find((r) => r.status === HealthStatus.FAILED) + if (failedResult) { + onErrorClick(failedResult) + } + }, [onErrorClick, results]) + if (loading) { return ( <IndicatorWrapper $type="checking"> @@ -32,17 +43,19 @@ const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({ if (overallStatus === 'not_checked') return null + const isClickable = onErrorClick && results.some((r) => r.status === HealthStatus.FAILED) + let icon: React.ReactNode = null switch (overallStatus) { case 'success': icon = <CheckCircleFilled /> break case 'error': - icon = <CloseCircleFilled /> - break - case 'partial': - icon = <ExclamationCircleFilled /> + case 'partial': { + const IconComponent = overallStatus === 'error' ? CloseCircleFilled : ExclamationCircleFilled + icon = <IconComponent /> break + } default: return null } @@ -51,19 +64,25 @@ const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({ <Flex align="center" gap={6}> {latencyText && <LatencyText type="secondary">{latencyText}</LatencyText>} <Tooltip title={tooltip} styles={{ body: { userSelect: 'text' } }}> - <IndicatorWrapper $type={overallStatus}>{icon}</IndicatorWrapper> + <IndicatorWrapper + $type={overallStatus} + $clickable={isClickable} + onClick={isClickable ? handleClick : undefined}> + {icon} + </IndicatorWrapper> </Tooltip> </Flex> ) } -const IndicatorWrapper = styled.div<{ $type: string }>` +const IndicatorWrapper = styled.div<{ $type: string; $clickable?: boolean }>` display: flex; align-items: center; justify-content: center; font-size: 14px; - color: ${(props) => { - switch (props.$type) { + cursor: ${({ $clickable }) => ($clickable ? 'pointer' : 'auto')}; + color: ${({ $type }) => { + switch ($type) { case 'success': return 'var(--color-status-success)' case 'error': diff --git a/src/renderer/src/components/HealthStatusIndicator/types.ts b/src/renderer/src/components/HealthStatusIndicator/types.ts index 87bff48c1c..cac8a529c7 100644 --- a/src/renderer/src/components/HealthStatusIndicator/types.ts +++ b/src/renderer/src/components/HealthStatusIndicator/types.ts @@ -1,3 +1,4 @@ +import type { SerializedError } from '@renderer/types/error' import type { HealthStatus } from '@renderer/types/healthCheck' /** @@ -6,7 +7,7 @@ import type { HealthStatus } from '@renderer/types/healthCheck' export interface HealthResult { status: HealthStatus latency?: number - error?: string + error?: SerializedError // 用于在 Tooltip 中显示额外上下文信息,例如 API Key 或模型名称 label?: string } diff --git a/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx b/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx index 1027324eeb..3fffd92707 100644 --- a/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx +++ b/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx @@ -77,7 +77,7 @@ export const useHealthStatus = ({ results, showLatency = false }: UseHealthStatu return ( <li key={idx} style={{ marginBottom: idx === results.length - 1 ? 0 : '10px' }}> - <Flex align="center" justify="space-between"> + <Flex align="flex-start" gap={5}> <strong style={{ color: statusColor }}>{statusText}</strong> {result.label} </Flex> @@ -86,8 +86,8 @@ export const useHealthStatus = ({ results, showLatency = false }: UseHealthStatu {t('settings.provider.api.key.check.latency')}: {formatLatency(result.latency)} </div> )} - {result.error && result.status === HealthStatus.FAILED && ( - <div style={{ marginTop: 2 }}>{result.error}</div> + {result.status === HealthStatus.FAILED && result.error?.message && ( + <div style={{ marginTop: 2 }}>{result.error.message}</div> )} </li> ) diff --git a/src/renderer/src/components/Icons/MinAppIcon.tsx b/src/renderer/src/components/Icons/MinAppIcon.tsx index 58da46a723..a55d598dde 100644 --- a/src/renderer/src/components/Icons/MinAppIcon.tsx +++ b/src/renderer/src/components/Icons/MinAppIcon.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { allMinApps } from '@renderer/config/minapps' import type { MinAppType } from '@renderer/types' import type { FC } from 'react' @@ -10,10 +10,10 @@ interface Props { } const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => { - // First try to find in DEFAULT_MIN_APPS for predefined styling - const _app = DEFAULT_MIN_APPS.find((item) => item.id === app.id) + // First try to find in allMinApps for predefined styling + const _app = allMinApps.find((item) => item.id === app.id) - // If found in DEFAULT_MIN_APPS, use predefined styling + // If found in allMinApps, use predefined styling if (_app) { return ( <img @@ -34,7 +34,7 @@ const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => { ) } - // If not found in DEFAULT_MIN_APPS but app has logo, use it (for temporary apps) + // If not found in allMinApps but app has logo, use it (for temporary apps) if (app.logo) { return ( <img diff --git a/src/renderer/src/components/Icons/__tests__/MinAppIcon.test.tsx b/src/renderer/src/components/Icons/__tests__/MinAppIcon.test.tsx index a06c19a840..0b36165a99 100644 --- a/src/renderer/src/components/Icons/__tests__/MinAppIcon.test.tsx +++ b/src/renderer/src/components/Icons/__tests__/MinAppIcon.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import MinAppIcon from '../MinAppIcon' vi.mock('@renderer/config/minapps', () => ({ - DEFAULT_MIN_APPS: [ + allMinApps: [ { id: 'test-app-1', name: 'Test App 1', @@ -52,7 +52,7 @@ describe('MinAppIcon', () => { }) }) - it('should return null when app is not found in DEFAULT_MIN_APPS', () => { + it('should return null when app is not found in allMinApps', () => { const unknownApp = { id: 'unknown-app', name: 'Unknown App', diff --git a/src/renderer/src/components/InputEmbeddingDimension.tsx b/src/renderer/src/components/InputEmbeddingDimension.tsx index baa316324b..a18bab604d 100644 --- a/src/renderer/src/components/InputEmbeddingDimension.tsx +++ b/src/renderer/src/components/InputEmbeddingDimension.tsx @@ -77,7 +77,7 @@ const InputEmbeddingDimension = ({ <Tooltip title={t('knowledge.dimensions_auto_set')}> <Button role="button" - aria-label="Get embedding dimension" + aria-label={t('common.get_embedding_dimension')} disabled={disabled || loading} onClick={handleFetchDimension} icon={<RefreshIcon size={16} className={loading ? 'animation-rotate' : ''} />} diff --git a/src/renderer/src/components/MinApp/MinApp.tsx b/src/renderer/src/components/MinApp/MinApp.tsx index b6a623d309..896592ef16 100644 --- a/src/renderer/src/components/MinApp/MinApp.tsx +++ b/src/renderer/src/components/MinApp/MinApp.tsx @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import MinAppIcon from '@renderer/components/Icons/MinAppIcon' import IndicatorLight from '@renderer/components/IndicatorLight' -import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps } from '@renderer/config/minapps' +import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateAllMinApps } from '@renderer/config/minapps' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinapps } from '@renderer/hooks/useMinapps' import { useRuntime } from '@renderer/hooks/useRuntime' @@ -93,7 +93,7 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => { await window.api.file.writeWithId('custom-minapps.json', JSON.stringify(updatedApps, null, 2)) window.toast.success(t('settings.miniapps.custom.remove_success')) const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())] - updateDefaultMinApps(reloadedApps) + updateAllMinApps(reloadedApps) updateMinapps(minapps.filter((item) => item.id !== app.id)) updatePinnedMinapps(pinned.filter((item) => item.id !== app.id)) updateDisabledMinapps(disabled.filter((item) => item.id !== app.id)) @@ -122,7 +122,7 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => { </StyledIndicator> )} </IconContainer> - <AppTitle>{isLast ? t('settings.miniapps.custom.title') : app.name}</AppTitle> + <AppTitle>{isLast ? t('settings.miniapps.custom.title') : app.nameKey ? t(app.nameKey) : app.name}</AppTitle> </Container> </Dropdown> ) diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 8e361f0bc7..f4eaaaf4da 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -13,7 +13,7 @@ import { import { loggerService } from '@logger' import WindowControls from '@renderer/components/WindowControls' import { isDev, isLinux, isMac, isWin } from '@renderer/config/constant' -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { allMinApps } from '@renderer/config/minapps' import { useBridge } from '@renderer/hooks/useBridge' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinapps } from '@renderer/hooks/useMinapps' @@ -246,7 +246,7 @@ const MinappPopupContainer: React.FC = () => { (acc, app) => ({ ...acc, [app.id]: { - canPinned: DEFAULT_MIN_APPS.some((item) => item.id === app.id), + canPinned: allMinApps.some((item) => item.id === app.id), isPinned: pinned.some((item) => item.id === app.id), canOpenExternalLink: app.url.startsWith('http://') || app.url.startsWith('https://') } diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts index f28e034bda..d3ee8a7ea0 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts @@ -8,7 +8,7 @@ import { isPreprocessProviderId, isWebSearchProviderId } from '@renderer/types' import type { ApiKeyConnectivity, ApiKeyWithStatus } from '@renderer/types/healthCheck' import { HealthStatus } from '@renderer/types/healthCheck' import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api' -import { formatErrorMessage } from '@renderer/utils/error' +import { serializeHealthCheckError } from '@renderer/utils/error' import type { TFunction } from 'i18next' import { isEmpty } from 'lodash' import { useCallback, useMemo, useState } from 'react' @@ -218,17 +218,19 @@ export function useApiKeys({ provider, updateProvider }: UseApiKeysProps) { latency, error: undefined }) - } catch (error: any) { + } catch (error: unknown) { // 连通性检查失败 + const serializedError = serializeHealthCheckError(error) + updateConnectivityState(keyToCheck, { checking: false, status: HealthStatus.FAILED, - error: formatErrorMessage(error), + error: serializedError, model: undefined, latency: undefined }) - logger.error('failed to validate the connectivity of the api key', error) + logger.error('failed to validate the connectivity of the api key', error as Error) } }, [keys, connectivityStates, updateConnectivityState, provider] diff --git a/src/renderer/src/components/Popups/TextEditPopup.tsx b/src/renderer/src/components/Popups/TextEditPopup.tsx index 6ae6cb575b..556f3dc660 100644 --- a/src/renderer/src/components/Popups/TextEditPopup.tsx +++ b/src/renderer/src/components/Popups/TextEditPopup.tsx @@ -151,7 +151,7 @@ const PopupContainer: React.FC<Props> = ({ {showTranslate && ( <TranslateButton onClick={handleTranslate} - aria-label="Translate text" + aria-label={t('common.translate_text')} disabled={isTranslating || !textValue.trim()}> {isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />} </TranslateButton> diff --git a/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx b/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx index fafd1e447b..7420420ff5 100644 --- a/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx +++ b/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx @@ -4,9 +4,11 @@ import { NodeViewContent, NodeViewWrapper, type ReactNodeViewProps, ReactNodeVie import { Button, Select, Tooltip } from 'antd' import type { FC } from 'react' import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' const CodeBlockNodeView: FC<ReactNodeViewProps> = (props) => { const { node, updateAttributes } = props + const { t } = useTranslation() const [languageOptions, setLanguageOptions] = useState<string[]>(DEFAULT_LANGUAGES) // Detect language from node attrs or fallback @@ -65,7 +67,7 @@ const CodeBlockNodeView: FC<ReactNodeViewProps> = (props) => { options={languageOptions.map((lang) => ({ value: lang, label: lang }))} style={{ minWidth: 90 }} /> - <Tooltip title="Copy"> + <Tooltip title={t('common.copy')}> <Button size="small" type="text" diff --git a/src/renderer/src/components/RichEditor/index.tsx b/src/renderer/src/components/RichEditor/index.tsx index 793ccda1ae..30d12f2ad0 100644 --- a/src/renderer/src/components/RichEditor/index.tsx +++ b/src/renderer/src/components/RichEditor/index.tsx @@ -580,7 +580,7 @@ const RichEditor = ({ <GripVertical /> </Tooltip> </DragHandle> - <EditorContent style={{ height: '100%' }} editor={editor} /> + <EditorContent style={{ minHeight: '100%' }} editor={editor} /> </StyledEditorContent> </Scrollbar> {enableContentSearch && ( diff --git a/src/renderer/src/components/Tab/TabContainer.tsx b/src/renderer/src/components/Tab/TabContainer.tsx index 5ac1353be8..0b75b67e44 100644 --- a/src/renderer/src/components/Tab/TabContainer.tsx +++ b/src/renderer/src/components/Tab/TabContainer.tsx @@ -2,12 +2,13 @@ import { PlusOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { Sortable, useDndReorder } from '@renderer/components/dnd' import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer' -import { isMac } from '@renderer/config/constant' -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { isLinux, isMac } from '@renderer/config/constant' +import { allMinApps } from '@renderer/config/minapps' import { useTheme } from '@renderer/context/ThemeProvider' import { useFullscreen } from '@renderer/hooks/useFullscreen' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinapps } from '@renderer/hooks/useMinapps' +import { useSettings } from '@renderer/hooks/useSettings' import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label' import tabsService from '@renderer/services/TabsService' import { useAppDispatch, useAppSelector } from '@renderer/store' @@ -57,7 +58,7 @@ const getTabIcon = ( // Check if it's a minapp tab (format: apps:appId) if (tabId.startsWith('apps:')) { const appId = tabId.replace('apps:', '') - let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) + let app = [...allMinApps, ...minapps].find((app) => app.id === appId) // If not found in permanent apps, search in temporary apps cache // The cache stores apps opened via openSmartMinapp() for top navbar mode @@ -122,6 +123,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => { const { settedTheme, toggleTheme } = useTheme() const { hideMinappPopup, minAppsCache } = useMinappPopup() const { minapps } = useMinapps() + const { useSystemTitleBar } = useSettings() const { t } = useTranslation() const getTabId = (path: string): string => { @@ -138,7 +140,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => { // Check if it's a minapp tab if (tabId.startsWith('apps:')) { const appId = tabId.replace('apps:', '') - let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) + let app = [...allMinApps, ...minapps].find((app) => app.id === appId) // If not found in permanent apps, search in temporary apps cache // This ensures temporary MinApps display proper titles while being used @@ -268,7 +270,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => { <PlusOutlined /> </AddTabButton> </HorizontalScrollContainer> - <RightButtonsContainer> + <RightButtonsContainer style={{ paddingRight: isLinux && useSystemTitleBar ? '12px' : undefined }}> <Tooltip title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)} mouseEnterDelay={0.8} diff --git a/src/renderer/src/components/ThinkingEffect.tsx b/src/renderer/src/components/ThinkingEffect.tsx index 5013332a17..975a6a9d6c 100644 --- a/src/renderer/src/components/ThinkingEffect.tsx +++ b/src/renderer/src/components/ThinkingEffect.tsx @@ -1,8 +1,7 @@ import { lightbulbVariants } from '@renderer/utils/motionVariants' -import { isEqual } from 'lodash' import { ChevronRight, Lightbulb } from 'lucide-react' import { motion } from 'motion/react' -import React, { useEffect, useMemo, useState } from 'react' +import React, { useMemo } from 'react' import styled from 'styled-components' interface Props { @@ -13,17 +12,11 @@ interface Props { } const ThinkingEffect: React.FC<Props> = ({ isThinking, thinkingTimeText, content, expanded }) => { - const [messages, setMessages] = useState<string[]>([]) - - useEffect(() => { + const messages = useMemo(() => { const allLines = (content || '').split('\n') const newMessages = isThinking ? allLines.slice(0, -1) : allLines - const validMessages = newMessages.filter((line) => line.trim() !== '') - - if (!isEqual(messages, validMessages)) { - setMessages(validMessages) - } - }, [content, isThinking, messages]) + return newMessages.filter((line) => line.trim() !== '') + }, [content, isThinking]) const showThinking = useMemo(() => { return isThinking && !expanded diff --git a/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap b/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap index 7bf4582822..8886b8f470 100644 --- a/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap +++ b/src/renderer/src/components/VirtualList/__tests__/__snapshots__/DynamicVirtualList.test.tsx.snap @@ -14,7 +14,6 @@ exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = ` <div> <div aria-hidden="false" - aria-label="Dynamic Virtual List" class="c0 dynamic-virtual-list" role="region" style="overflow: auto; height: 100%;" diff --git a/src/renderer/src/components/VirtualList/dynamic.tsx b/src/renderer/src/components/VirtualList/dynamic.tsx index d1244ecedb..42b6cfe5ca 100644 --- a/src/renderer/src/components/VirtualList/dynamic.tsx +++ b/src/renderer/src/components/VirtualList/dynamic.tsx @@ -243,7 +243,6 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) { ref={scrollerRef} className={className ? `dynamic-virtual-list ${className}` : 'dynamic-virtual-list'} role="region" - aria-label="Dynamic Virtual List" aria-hidden={!showScrollbar} $autoHide={autoHideScrollbar} $show={showScrollbar} diff --git a/src/renderer/src/components/WindowControls/WindowControls.styled.ts b/src/renderer/src/components/WindowControls/WindowControls.styled.ts index c962e139e5..1e56471243 100644 --- a/src/renderer/src/components/WindowControls/WindowControls.styled.ts +++ b/src/renderer/src/components/WindowControls/WindowControls.styled.ts @@ -1,9 +1,6 @@ import styled from 'styled-components' export const WindowControlsContainer = styled.div` - position: fixed; - top: 0; - right: 0; display: flex; align-items: center; height: var(--navbar-height); diff --git a/src/renderer/src/components/WindowControls/index.tsx b/src/renderer/src/components/WindowControls/index.tsx index bebabb6523..e2b9aa76b9 100644 --- a/src/renderer/src/components/WindowControls/index.tsx +++ b/src/renderer/src/components/WindowControls/index.tsx @@ -1,4 +1,5 @@ import { isLinux, isWin } from '@renderer/config/constant' +import { useSettings } from '@renderer/hooks/useSettings' import { Tooltip } from 'antd' import { Minus, Square, X } from 'lucide-react' import type { SVGProps } from 'react' @@ -49,6 +50,7 @@ const DEFAULT_DELAY = 1 const WindowControls: React.FC = () => { const [isMaximized, setIsMaximized] = useState(false) const { t } = useTranslation() + const { useSystemTitleBar } = useSettings() useEffect(() => { // Check initial maximized state @@ -67,6 +69,11 @@ const WindowControls: React.FC = () => { return null } + // Hide on Linux if using system title bar + if (isLinux && useSystemTitleBar) { + return null + } + const handleMinimize = () => { window.api.windowControls.minimize() } diff --git a/src/renderer/src/components/__tests__/InputEmbeddingDimension.test.tsx b/src/renderer/src/components/__tests__/InputEmbeddingDimension.test.tsx index 85ec60e459..f961212b7d 100644 --- a/src/renderer/src/components/__tests__/InputEmbeddingDimension.test.tsx +++ b/src/renderer/src/components/__tests__/InputEmbeddingDimension.test.tsx @@ -16,7 +16,8 @@ const mocks = vi.hoisted(() => ({ 'knowledge.provider_not_found': '找不到提供商', 'message.error.get_embedding_dimensions': '获取嵌入维度失败', 'knowledge.dimensions_size_placeholder': '请输入维度大小', - 'knowledge.dimensions_auto_set': '自动设置维度' + 'knowledge.dimensions_auto_set': '自动设置维度', + 'common.get_embedding_dimension': 'Get Embedding Dimension' } return translations[k] || k } diff --git a/src/renderer/src/components/__tests__/__snapshots__/InputEmbeddingDimension.test.tsx.snap b/src/renderer/src/components/__tests__/__snapshots__/InputEmbeddingDimension.test.tsx.snap index 694f031577..32989df9f8 100644 --- a/src/renderer/src/components/__tests__/__snapshots__/InputEmbeddingDimension.test.tsx.snap +++ b/src/renderer/src/components/__tests__/__snapshots__/InputEmbeddingDimension.test.tsx.snap @@ -17,7 +17,7 @@ exports[`InputEmbeddingDimension > basic rendering > should match snapshot with data-title="自动设置维度" > <button - aria-label="Get embedding dimension" + aria-label="Get Embedding Dimension" role="button" type="button" > @@ -52,7 +52,7 @@ exports[`InputEmbeddingDimension > basic rendering > should match snapshot with data-title="自动设置维度" > <button - aria-label="Get embedding dimension" + aria-label="Get Embedding Dimension" disabled="" role="button" type="button" diff --git a/src/renderer/src/components/app/Navbar.tsx b/src/renderer/src/components/app/Navbar.tsx index bfad371f1b..2a5a4e7864 100644 --- a/src/renderer/src/components/app/Navbar.tsx +++ b/src/renderer/src/components/app/Navbar.tsx @@ -22,12 +22,10 @@ export const Navbar: FC<Props> = ({ children, ...props }) => { } return ( - <> - <NavbarContainer {...props} style={{ backgroundColor }} $isFullScreen={isFullscreen}> - {children} - </NavbarContainer> - {!isTopNavbar && !minappShow && <WindowControls />} - </> + <NavbarContainer {...props} style={{ backgroundColor }} $isFullScreen={isFullscreen}> + {children} + {!minappShow && <WindowControls />} + </NavbarContainer> ) } diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index eeefb218d2..bbdc8f8222 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -56,7 +56,6 @@ import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url' import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url' import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url' import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png?url' -import i18n from '@renderer/i18n' import type { MinAppType } from '@renderer/types' const logger = loggerService.withContext('Config:minapps') @@ -118,14 +117,18 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ }, { id: 'yi', - name: i18n.t('minapps.wanzhi'), + name: 'Wanzhi', + nameKey: 'minapps.wanzhi', + locales: ['zh-CN', 'zh-TW'], url: 'https://www.wanzhi.com/', logo: WanZhiAppLogo, bodered: true }, { id: 'zhipu', - name: i18n.t('minapps.chatglm'), + name: 'ChatGLM', + nameKey: 'minapps.chatglm', + locales: ['zh-CN', 'zh-TW'], url: 'https://chatglm.cn/main/alltoolsdetail', logo: ZhipuProviderLogo, bodered: true @@ -133,31 +136,40 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ { id: 'moonshot', name: 'Kimi', + locales: ['zh-CN', 'zh-TW'], url: 'https://kimi.moonshot.cn/', logo: KimiAppLogo }, { id: 'baichuan', - name: i18n.t('minapps.baichuan'), + name: 'Baichuan', + nameKey: 'minapps.baichuan', + locales: ['zh-CN', 'zh-TW'], url: 'https://ying.baichuan-ai.com/chat', logo: BaicuanAppLogo }, { id: 'dashscope', - name: i18n.t('minapps.qwen'), - url: 'https://www.tongyi.com/', + name: 'Qwen', + nameKey: 'minapps.qwen', + locales: ['zh-CN', 'zh-TW'], + url: 'https://www.qianwen.com', logo: QwenModelLogo }, { id: 'stepfun', - name: i18n.t('minapps.stepfun'), + name: 'Stepfun', + nameKey: 'minapps.stepfun', + locales: ['zh-CN', 'zh-TW'], url: 'https://stepfun.com', logo: StepfunAppLogo, bodered: true }, { id: 'doubao', - name: i18n.t('minapps.doubao'), + name: 'Doubao', + nameKey: 'minapps.doubao', + locales: ['zh-CN', 'zh-TW'], url: 'https://www.doubao.com/chat/', logo: DoubaoAppLogo }, @@ -169,7 +181,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ }, { id: 'minimax', - name: i18n.t('minapps.hailuo'), + name: 'Hailuo', + nameKey: 'minapps.hailuo', + locales: ['zh-CN', 'zh-TW'], url: 'https://chat.minimaxi.com/', logo: HailuoModelLogo, bodered: true @@ -198,13 +212,17 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ }, { id: 'baidu-ai-chat', - name: i18n.t('minapps.wenxin'), + name: 'Wenxin', + nameKey: 'minapps.wenxin', + locales: ['zh-CN', 'zh-TW'], logo: BaiduAiAppLogo, url: 'https://yiyan.baidu.com/' }, { id: 'baidu-ai-search', - name: i18n.t('minapps.baidu-ai-search'), + name: 'Baidu AI Search', + nameKey: 'minapps.baidu-ai-search', + locales: ['zh-CN', 'zh-TW'], logo: BaiduAiSearchLogo, url: 'https://chat.baidu.com/', bodered: true, @@ -214,14 +232,18 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ }, { id: 'tencent-yuanbao', - name: i18n.t('minapps.tencent-yuanbao'), + name: 'Tencent Yuanbao', + nameKey: 'minapps.tencent-yuanbao', + locales: ['zh-CN', 'zh-TW'], logo: TencentYuanbaoAppLogo, url: 'https://yuanbao.tencent.com/chat', bodered: true }, { id: 'sensetime-chat', - name: i18n.t('minapps.sensechat'), + name: 'Sensechat', + nameKey: 'minapps.sensechat', + locales: ['zh-CN', 'zh-TW'], logo: SensetimeAppLogo, url: 'https://chat.sensetime.com/wb/chat', bodered: true @@ -229,12 +251,15 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ { id: 'spark-desk', name: 'SparkDesk', + locales: ['zh-CN', 'zh-TW'], logo: SparkDeskAppLogo, url: 'https://xinghuo.xfyun.cn/desk' }, { id: 'metaso', - name: i18n.t('minapps.metaso'), + name: 'Metaso', + nameKey: 'minapps.metaso', + locales: ['zh-CN', 'zh-TW'], logo: MetasoAppLogo, url: 'https://metaso.cn/' }, @@ -258,7 +283,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ }, { id: 'tiangong-ai', - name: i18n.t('minapps.tiangong-ai'), + name: 'Tiangong AI', + nameKey: 'minapps.tiangong-ai', + locales: ['zh-CN', 'zh-TW'], logo: TiangongAiLogo, url: 'https://www.tiangong.cn/', bodered: true @@ -285,7 +312,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ }, { id: 'nm', - name: i18n.t('minapps.nami-ai'), + name: 'Nami AI', + nameKey: 'minapps.nami-ai', + locales: ['zh-CN', 'zh-TW'], logo: NamiAiLogo, url: 'https://bot.n.cn/', bodered: true @@ -328,9 +357,10 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ }, { id: 'qwenlm', - name: 'QwenLM', + name: 'QwenChat', + locales: ['zh-CN', 'zh-TW'], logo: QwenlmAppLogo, - url: 'https://qwenlm.ai/' + url: 'https://chat.qwen.ai' }, { id: 'flowith', @@ -354,7 +384,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ }, { id: 'xiaoyi', - name: i18n.t('minapps.xiaoyi'), + name: 'Xiaoyi', + nameKey: 'minapps.xiaoyi', + locales: ['zh-CN', 'zh-TW'], logo: XiaoYiAppLogo, url: 'https://xiaoyi.huawei.com/chat/', bodered: true @@ -384,7 +416,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ }, { id: 'wpslingxi', - name: i18n.t('minapps.wps-copilot'), + name: 'WPS AI', + nameKey: 'minapps.wps-copilot', + locales: ['zh-CN', 'zh-TW'], logo: WPSLingXiLogo, url: 'https://copilot.wps.cn/', bodered: true @@ -425,14 +459,18 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ }, { id: 'zhihu', - name: i18n.t('minapps.zhihu'), + name: 'Zhihu Zhida', + nameKey: 'minapps.zhihu', + locales: ['zh-CN', 'zh-TW'], logo: ZhihuAppLogo, url: 'https://zhida.zhihu.com/', bodered: true }, { id: 'dangbei', - name: i18n.t('minapps.dangbei'), + name: 'Dangbei AI', + nameKey: 'minapps.dangbei', + locales: ['zh-CN', 'zh-TW'], logo: DangbeiLogo, url: 'https://ai.dangbei.com/', bodered: true @@ -460,13 +498,16 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ { id: 'longcat', name: 'LongCat', + locales: ['zh-CN', 'zh-TW'], logo: LongCatAppLogo, url: 'https://longcat.chat/', bodered: true }, { id: 'ling', - name: i18n.t('minapps.ant-ling'), + name: 'Ant Ling', + nameKey: 'minapps.ant-ling', + locales: ['zh-CN', 'zh-TW'], url: 'https://ling.tbox.cn/chat', logo: LingAppLogo, bodered: true, @@ -486,11 +527,11 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ } ] -// 加载自定义小应用并合并到默认应用中 -let DEFAULT_MIN_APPS = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())] +// All mini apps: built-in defaults + custom apps loaded from user config +let allMinApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())] -function updateDefaultMinApps(param) { - DEFAULT_MIN_APPS = param +function updateAllMinApps(apps: MinAppType[]) { + allMinApps = apps } -export { DEFAULT_MIN_APPS, loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps } +export { allMinApps, loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateAllMinApps } diff --git a/src/renderer/src/config/models/__tests__/reasoning.test.ts b/src/renderer/src/config/models/__tests__/reasoning.test.ts index 0f58be4ef0..25c7b53da8 100644 --- a/src/renderer/src/config/models/__tests__/reasoning.test.ts +++ b/src/renderer/src/config/models/__tests__/reasoning.test.ts @@ -18,6 +18,7 @@ import { isGrok4FastReasoningModel, isHunyuanReasoningModel, isInterleavedThinkingModel, + isKimiReasoningModel, isLingReasoningModel, isMiniMaxReasoningModel, isPerplexityReasoningModel, @@ -29,6 +30,7 @@ import { isSupportedReasoningEffortPerplexityModel, isSupportedThinkingTokenDoubaoModel, isSupportedThinkingTokenGeminiModel, + isSupportedThinkingTokenKimiModel, isSupportedThinkingTokenModel, isSupportedThinkingTokenQwenModel, isSupportedThinkingTokenZhipuModel, @@ -341,6 +343,7 @@ describe('Claude & regional providers', () => { }) it('covers zhipu/minimax/step specific classifiers', () => { + expect(isSupportedThinkingTokenZhipuModel(createModel({ id: 'glm-4.5' }))).toBe(true) expect(isSupportedThinkingTokenZhipuModel(createModel({ id: 'glm-4.6-pro' }))).toBe(true) expect(isZhipuReasoningModel(createModel({ id: 'glm-z1' }))).toBe(true) expect(isStepReasoningModel(createModel({ id: 'step-r1-v-mini' }))).toBe(true) @@ -422,12 +425,18 @@ describe('Qwen & Gemini thinking coverage', () => { expect(isSupportedThinkingTokenQwenModel(createModel({ id }))).toBe(true) }) - it.each(['qwen3-thinking', 'qwen3-instruct', 'qwen3-max', 'qwen3-vl-thinking'])( - 'blocks thinking tokens for %s', - (id) => { - expect(isSupportedThinkingTokenQwenModel(createModel({ id }))).toBe(false) - } - ) + it.each(['qwen3-thinking', 'qwen3-instruct', 'qwen3-vl-thinking'])('blocks thinking tokens for %s', (id) => { + expect(isSupportedThinkingTokenQwenModel(createModel({ id }))).toBe(false) + }) + + it('supports thinking tokens for qwen3-max-preview and qwen3-max-2026-01-23', () => { + expect(isSupportedThinkingTokenQwenModel(createModel({ id: 'qwen3-max-preview' }))).toBe(true) + expect(isSupportedThinkingTokenQwenModel(createModel({ id: 'qwen3-max-2026-01-23' }))).toBe(true) + }) + + it('blocks thinking tokens for qwen3-max and other unsupported versions', () => { + expect(isSupportedThinkingTokenQwenModel(createModel({ id: 'qwen3-max' }))).toBe(false) + }) it.each(['qwen3-thinking', 'qwen3-vl-235b-thinking'])('always thinks for %s', (id) => { expect(isQwenAlwaysThinkModel(createModel({ id }))).toBe(true) @@ -1368,7 +1377,11 @@ describe('findTokenLimit', () => { { modelId: 'qwen-plus-ultra', expected: { min: 0, max: 81_920 } }, { modelId: 'qwen-turbo-pro', expected: { min: 0, max: 38_912 } }, { modelId: 'qwen-flash-lite', expected: { min: 0, max: 81_920 } }, - { modelId: 'qwen3-7b', expected: { min: 1_024, max: 38_912 } } + { modelId: 'qwen3-7b', expected: { min: 1_024, max: 38_912 } }, + { modelId: 'Baichuan-M2', expected: { min: 0, max: 30_000 } }, + { modelId: 'baichuan-m2', expected: { min: 0, max: 30_000 } }, + { modelId: 'Baichuan-M3', expected: { min: 0, max: 30_000 } }, + { modelId: 'baichuan-m3', expected: { min: 0, max: 30_000 } } ] it.each(cases)('returns correct limits for $modelId', ({ modelId, expected }) => { @@ -2280,3 +2293,125 @@ describe('isInterleavedThinkingModel', () => { }) }) }) + +describe('Kimi Models', () => { + describe('isKimiReasoningModel', () => { + describe('should return true for Kimi reasoning models', () => { + it('should recognize kimi-k2-thinking', () => { + expect(isKimiReasoningModel(createModel({ id: 'kimi-k2-thinking' }))).toBe(true) + }) + + it('should recognize kimi-k2-thinking-turbo', () => { + expect(isKimiReasoningModel(createModel({ id: 'kimi-k2-thinking-turbo' }))).toBe(true) + }) + + it('should recognize kimi-k2.5', () => { + expect(isKimiReasoningModel(createModel({ id: 'kimi-k2.5' }))).toBe(true) + }) + + it('should handle model IDs with slashes', () => { + expect(isKimiReasoningModel(createModel({ id: 'moonshot/kimi-k2-thinking' }))).toBe(true) + expect(isKimiReasoningModel(createModel({ id: 'moonshot/kimi-k2.5' }))).toBe(true) + }) + + it('should handle case insensitivity', () => { + expect(isKimiReasoningModel(createModel({ id: 'KIMI-K2-THINKING' }))).toBe(true) + expect(isKimiReasoningModel(createModel({ id: 'Kimi-K2.5' }))).toBe(true) + }) + }) + + describe('should return false for non-reasoning models', () => { + it('should reject kimi-chat', () => { + expect(isKimiReasoningModel(createModel({ id: 'kimi-chat' }))).toBe(false) + }) + + it('should reject kimi-k1', () => { + expect(isKimiReasoningModel(createModel({ id: 'kimi-k1' }))).toBe(false) + }) + + it('should reject kimi-k2 (without thinking suffix)', () => { + expect(isKimiReasoningModel(createModel({ id: 'kimi-k2' }))).toBe(false) + }) + + it('should reject other Kimi models', () => { + expect(isKimiReasoningModel(createModel({ id: 'kimi-k2-preview' }))).toBe(false) + expect(isKimiReasoningModel(createModel({ id: 'kimi-k2-turbo' }))).toBe(false) + }) + + it('should reject models from other providers', () => { + expect(isKimiReasoningModel(createModel({ id: 'gpt-4' }))).toBe(false) + expect(isKimiReasoningModel(createModel({ id: 'claude-3-opus' }))).toBe(false) + expect(isKimiReasoningModel(createModel({ id: 'deepseek-chat' }))).toBe(false) + }) + }) + + describe('edge cases', () => { + it('should return false for undefined', () => { + expect(isKimiReasoningModel(undefined)).toBe(false) + }) + + it('should handle model IDs with paths', () => { + expect(isKimiReasoningModel(createModel({ id: 'providers/kimi-k2-thinking' }))).toBe(true) + expect(isKimiReasoningModel(createModel({ id: 'openrouter/kimi-k2.5' }))).toBe(true) + }) + + it('should correctly match model name variants', () => { + // kimi-k2-thinking but not kimi-k2-thinking-something + expect(isKimiReasoningModel(createModel({ id: 'kimi-k2-thinking' }))).toBe(true) + expect(isKimiReasoningModel(createModel({ id: 'kimi-k2-thinking-extra' }))).toBe(false) + }) + }) + }) + + describe('isSupportedThinkingTokenKimiModel', () => { + describe('should return true for Kimi models with thinking token support', () => { + it('should recognize kimi-k2.5', () => { + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'kimi-k2.5' }))).toBe(true) + }) + + it('should handle model IDs with provider prefixes', () => { + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'moonshot/kimi-k2.5' }))).toBe(true) + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'openrouter/kimi-k2.5' }))).toBe(true) + }) + + it('should handle case insensitivity', () => { + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'KIMI-K2.5' }))).toBe(true) + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'Kimi-K2.5' }))).toBe(true) + }) + }) + + describe('should return false for Kimi models without thinking token support', () => { + it('should reject kimi-k2-thinking', () => { + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'kimi-k2-thinking' }))).toBe(false) + }) + + it('should reject kimi-k2-thinking-turbo', () => { + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'kimi-k2-thinking-turbo' }))).toBe(false) + }) + + it('should reject other Kimi models', () => { + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'kimi-chat' }))).toBe(false) + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'kimi-k1' }))).toBe(false) + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'kimi-k2' }))).toBe(false) + }) + + it('should reject models from other providers', () => { + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'gpt-4' }))).toBe(false) + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'claude-3-opus' }))).toBe(false) + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'deepseek-chat' }))).toBe(false) + }) + }) + + describe('edge cases', () => { + it('should handle model IDs with paths', () => { + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'providers/kimi-k2.5' }))).toBe(true) + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'api/kimi-k2.5-preview' }))).toBe(true) + }) + + it('should match models containing kimi-k2.5', () => { + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'kimi-k2.5-preview' }))).toBe(true) + expect(isSupportedThinkingTokenKimiModel(createModel({ id: 'kimi-k2.5-turbo' }))).toBe(true) + }) + }) + }) +}) diff --git a/src/renderer/src/config/models/__tests__/vision.test.ts b/src/renderer/src/config/models/__tests__/vision.test.ts index aaded6b970..71f71f2885 100644 --- a/src/renderer/src/config/models/__tests__/vision.test.ts +++ b/src/renderer/src/config/models/__tests__/vision.test.ts @@ -175,6 +175,7 @@ describe('isVisionModel', () => { const doubao = createModel({ id: 'doubao-standard', provider: 'doubao', name: 'basic' }) expect(isVisionModel(doubao)).toBe(false) }) + describe('Gemini Models', () => { it('should return true for gemini 1.5 models', () => { expect( @@ -308,4 +309,14 @@ describe('isVisionModel', () => { ).toBe(false) }) }) + + describe('Kimi Models', () => { + it('should return true for kimi models', () => { + expect(isVisionModel(createModel({ id: 'kimi-k2.5' }))).toBe(true) + expect(isVisionModel(createModel({ id: 'moonshot/kimi-k2.5' }))).toBe(true) + }) + it('should return false for kimi non-vision models', () => { + expect(isVisionModel(createModel({ id: 'kimi-k2-thinking' }))).toBe(false) + }) + }) }) diff --git a/src/renderer/src/config/models/default.ts b/src/renderer/src/config/models/default.ts index 408c047639..f90a506c47 100644 --- a/src/renderer/src/config/models/default.ts +++ b/src/renderer/src/config/models/default.ts @@ -713,6 +713,42 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> = provider: 'baichuan', name: 'Baichuan3 Turbo 128k', group: 'Baichuan3' + }, + { + id: 'Baichuan4-Turbo', + provider: 'baichuan', + name: 'Baichuan4 Turbo', + group: 'Baichuan4' + }, + { + id: 'Baichuan4-Air', + provider: 'baichuan', + name: 'Baichuan4 Air', + group: 'Baichuan4' + }, + { + id: 'Baichuan-M2', + provider: 'baichuan', + name: 'Baichuan M2', + group: 'Baichuan-M2' + }, + { + id: 'Baichuan-M2-Plus', + provider: 'baichuan', + name: 'Baichuan M2 Plus', + group: 'Baichuan-M2' + }, + { + id: 'Baichuan-M3', + provider: 'baichuan', + name: 'Baichuan M3', + group: 'Baichuan-M3' + }, + { + id: 'Baichuan-M3-Plus', + provider: 'baichuan', + name: 'Baichuan M3 Plus', + group: 'Baichuan-M3' } ], modelscope: [ @@ -753,7 +789,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> = { id: 'qwen-flash', name: 'qwen-flash', provider: 'dashscope', group: 'qwen-flash', owned_by: 'system' }, { id: 'qwen-plus', name: 'qwen-plus', provider: 'dashscope', group: 'qwen-plus', owned_by: 'system' }, { id: 'qwen-max', name: 'qwen-max', provider: 'dashscope', group: 'qwen-max', owned_by: 'system' }, - { id: 'qwen3-max', name: 'qwen3-max', provider: 'dashscope', group: 'qwen-max', owned_by: 'system' } + { id: 'qwen3-max', name: 'qwen3-max', provider: 'dashscope', group: 'qwen-max', owned_by: 'system' }, + { id: 'text-embedding-v4', name: 'text-embedding-v4', provider: 'dashscope', group: 'qwen-text-embedding' }, + { id: 'text-embedding-v3', name: 'text-embedding-v3', provider: 'dashscope', group: 'qwen-text-embedding' }, + { id: 'text-embedding-v2', name: 'text-embedding-v2', provider: 'dashscope', group: 'qwen-text-embedding' }, + { id: 'text-embedding-v1', name: 'text-embedding-v1', provider: 'dashscope', group: 'qwen-text-embedding' }, + { id: 'qwen3-rerank', name: 'qwen3-rerank', provider: 'dashscope', group: 'qwen-rerank' } ], stepfun: [ { diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index b2b6119b76..f6abf89e88 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -58,7 +58,8 @@ export const MODEL_SUPPORTED_REASONING_EFFORT = { mimo: ['auto'] as const, zhipu: ['auto'] as const, perplexity: ['low', 'medium', 'high'] as const, - deepseek_hybrid: ['auto'] as const + deepseek_hybrid: ['auto'] as const, + kimi_k2_5: ['none', 'auto'] as const } as const satisfies ReasoningEffortConfig // 模型类型到支持选项的映射表 @@ -90,7 +91,8 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = { hunyuan: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const, zhipu: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const, perplexity: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.perplexity] as const, - deepseek_hybrid: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_hybrid] as const + deepseek_hybrid: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_hybrid] as const, + kimi_k2_5: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.kimi_k2_5] as const } as const const withModelIdAndNameAsId = <T>(model: Model, fn: (model: Model) => T): { idResult: T; nameResult: T } => { @@ -171,6 +173,8 @@ const _getThinkModelType = (model: Model): ThinkingModelType => { thinkingModelType = 'deepseek_hybrid' } else if (isSupportedThinkingTokenMiMoModel(model)) { thinkingModelType = 'mimo' + } else if (isSupportedThinkingTokenKimiModel(model)) { + thinkingModelType = 'kimi_k2_5' } return thinkingModelType } @@ -281,7 +285,8 @@ function _isSupportedThinkingTokenModel(model: Model): boolean { isSupportedThinkingTokenDoubaoModel(model) || isSupportedThinkingTokenHunyuanModel(model) || isSupportedThinkingTokenZhipuModel(model) || - isSupportedThinkingTokenMiMoModel(model) + isSupportedThinkingTokenMiMoModel(model) || + isSupportedThinkingTokenKimiModel(model) ) } @@ -434,12 +439,15 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean { if (modelId.startsWith('qwen3')) { // instruct 是非思考模型 thinking 是思考模型,二者都不能控制思考 - if (modelId.includes('instruct') || modelId.includes('thinking') || modelId.includes('qwen3-max')) { + if (modelId.includes('instruct') || modelId.includes('thinking')) { return false } - return true + if (!modelId.includes('qwen3-max')) { + return true + } } + // https://help.aliyun.com/zh/model-studio/deep-thinking return [ 'qwen-plus', 'qwen-plus-latest', @@ -456,7 +464,9 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean { 'qwen-turbo-0715', 'qwen-turbo-2025-07-15', 'qwen-flash', - 'qwen-flash-2025-07-28' + 'qwen-flash-2025-07-28', + 'qwen3-max-2026-01-23', + 'qwen3-max-preview' ].includes(modelId) } @@ -584,6 +594,25 @@ export const isSupportedThinkingTokenMiMoModel = (model: Model): boolean => { return ['mimo-v2-flash'].some((id) => modelId.includes(id)) } +/** + * Detects whether a Kimi model supports thinking control + * + * This function identifies Kimi models that support thinking token control. + * Currently only supports Kimi K2.5 and its variants. + * + * @param model - The model object to check + * @returns true if the model supports thinking control, false otherwise + */ +const _isSupportedThinkingTokenKimiModel = (model: Model): boolean => { + const modelId = getLowerBaseModelName(model.id, '/') + return ['kimi-k2.5'].some((id) => modelId.includes(id)) +} + +export const isSupportedThinkingTokenKimiModel = (model: Model): boolean => { + const { idResult, nameResult } = withModelIdAndNameAsId(model, _isSupportedThinkingTokenKimiModel) + return idResult || nameResult +} + export const isDeepSeekHybridInferenceModel = (model: Model) => { const { idResult, nameResult } = withModelIdAndNameAsId(model, (model) => { const modelId = getLowerBaseModelName(model.id) @@ -640,6 +669,42 @@ export const isMiniMaxReasoningModel = (model?: Model): boolean => { return (['minimax-m1', 'minimax-m2', 'minimax-m2.1'] as const).some((id) => modelId.includes(id)) } +export const isBaichuanReasoningModel = (model?: Model): boolean => { + if (!model) { + return false + } + const modelId = getLowerBaseModelName(model.id, '/') + + // Baichuan-M2 和 Baichuan-M3 是推理模型 + return modelId === 'baichuan-m2' || modelId === 'baichuan-m3' +} + +/** + * Check if the model is a Kimi reasoning model + * + * This function identifies Moonshot AI's Kimi series reasoning models. + * Currently should only support: + * - Kimi K2 Thinking and its variants (including -turbo suffix) + * - Kimi K2.5 + * + * @param model - The model object to check, can be undefined + * @returns true if it's a Kimi reasoning model, false otherwise + */ +const _isKimiReasoningModel = (model: Model): boolean => { + const modelId = getLowerBaseModelName(model.id, '/') + // Match kimi-k2-thinking, kimi-k2-thinking-turbo, or kimi-k2.5 + // The regex ensures no extra suffixes after these patterns + return /^kimi-k2-thinking(?:-turbo)?$|^kimi-k2\.5(?:-\w)*$/.test(modelId) +} + +export function isKimiReasoningModel(model?: Model): boolean { + if (!model) { + return false + } + const { idResult, nameResult } = withModelIdAndNameAsId(model, _isKimiReasoningModel) + return idResult || nameResult +} + export function isReasoningModel(model?: Model): boolean { if (!model || isEmbeddingModel(model) || isRerankModel(model) || isTextToImageModel(model)) { return false @@ -675,6 +740,8 @@ export function isReasoningModel(model?: Model): boolean { isLingReasoningModel(model) || isMiniMaxReasoningModel(model) || isMiMoReasoningModel(model) || + isBaichuanReasoningModel(model) || + isKimiReasoningModel(model) || modelId.includes('magistral') || modelId.includes('pangu-pro-moe') || modelId.includes('seed-oss') || @@ -718,7 +785,11 @@ const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> = { '(?:anthropic\\.)?claude-opus-4(?:[.-]0)?(?:[@-](?:\\d{4,}|[a-z][\\w-]*))?(?:-v\\d+:\\d+)?$': { min: 1024, max: 32_000 - } + }, + + // Baichuan models + 'baichuan-m2$': { min: 0, max: 30_000 }, + 'baichuan-m3$': { min: 0, max: 30_000 } } export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => { @@ -748,7 +819,7 @@ export const isFixedReasoningModel = (model: Model) => // https://docs.z.ai/guides/capabilities/thinking-mode // https://platform.moonshot.cn/docs/guide/use-kimi-k2-thinking-model#%E5%A4%9A%E6%AD%A5%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8 const INTERLEAVED_THINKING_MODEL_REGEX = - /minimax-m2(.(\d+))?(?:-[\w-]+)?|mimo-v2-flash|glm-4.(\d+)(?:-[\w-]+)?|kimi-k2-thinking?$/i + /minimax-m2(.(\d+))?(?:-[\w-]+)?|mimo-v2-flash|glm-4.(\d+)(?:-[\w-]+)?|kimi-k2-thinking?|kimi-k2.5$/i /** * Determines whether the given model supports interleaved thinking. diff --git a/src/renderer/src/config/models/vision.ts b/src/renderer/src/config/models/vision.ts index c16c83e2ed..42d12ed4ee 100644 --- a/src/renderer/src/config/models/vision.ts +++ b/src/renderer/src/config/models/vision.ts @@ -43,6 +43,7 @@ const visionAllowedModels = [ 'o3(?:-[\\w-]+)?', 'o4(?:-[\\w-]+)?', 'deepseek-vl(?:[\\w-]+)?', + 'kimi-k2.5', 'kimi-latest', 'gemma-3(?:-[\\w-]+)', 'doubao-seed-1[.-][68](?:-[\\w-]+)?', diff --git a/src/renderer/src/config/preprocessProviders.ts b/src/renderer/src/config/preprocessProviders.ts index 7f41235c19..747dadf24a 100644 --- a/src/renderer/src/config/preprocessProviders.ts +++ b/src/renderer/src/config/preprocessProviders.ts @@ -1,6 +1,7 @@ import Doc2xLogo from '@renderer/assets/images/ocr/doc2x.png' import MinerULogo from '@renderer/assets/images/ocr/mineru.jpg' import MistralLogo from '@renderer/assets/images/providers/mistral.png' +import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png' import type { PreprocessProviderId } from '@renderer/types' export function getPreprocessProviderLogo(providerId: PreprocessProviderId) { @@ -13,6 +14,8 @@ export function getPreprocessProviderLogo(providerId: PreprocessProviderId) { return MinerULogo case 'open-mineru': return MinerULogo + case 'paddleocr': + return PaddleocrLogo default: return undefined } @@ -44,5 +47,11 @@ export const PREPROCESS_PROVIDER_CONFIG: Record<PreprocessProviderId, Preprocess official: 'https://github.com/opendatalab/MinerU/', apiKey: 'https://github.com/opendatalab/MinerU/' } + }, + paddleocr: { + websites: { + official: 'https://aistudio.baidu.com/paddleocr/', + apiKey: 'https://aistudio.baidu.com/paddleocr/' + } } } diff --git a/src/renderer/src/config/prompts-code-mode.ts b/src/renderer/src/config/prompts-code-mode.ts new file mode 100644 index 0000000000..9cd9b55721 --- /dev/null +++ b/src/renderer/src/config/prompts-code-mode.ts @@ -0,0 +1,174 @@ +import { generateMcpToolFunctionName } from '@shared/mcp' + +export interface ToolInfo { + name: string + serverName?: string + description?: string +} + +/** + * Hub Mode System Prompt - For native MCP tool calling + * Used when model supports native function calling via MCP protocol + */ +const HUB_MODE_SYSTEM_PROMPT_BASE = ` +## Hub MCP Tools – Code Execution Mode + +You can discover and call MCP tools through the hub server using **ONLY two meta-tools**: **search** and **exec**. + +### ⚠️ IMPORTANT: You can ONLY call these two tools directly + +| Tool | Purpose | +|------|---------| +| \`search\` | Discover available tools and their signatures | +| \`exec\` | Execute JavaScript code that calls the discovered tools | + +**All other tools (listed in "Discoverable Tools" below) can ONLY be called from INSIDE \`exec\` code.** +You CANNOT call them directly as tool calls. They are async functions available within the \`exec\` runtime. + +### Critical Rules (Read First) + +1. **ONLY \`search\` and \`exec\` are callable as tools.** All other tools must be used inside \`exec\` code. +2. You MUST explicitly \`return\` the final value from your \`exec\` code. If you do not return a value, the result will be \`undefined\`. +3. All MCP tools inside \`exec\` are async functions. Always call them as \`await ToolName(params)\`. +4. Use the exact function names and parameter shapes returned by \`search\`. +5. You CANNOT call \`search\` or \`exec\` from inside \`exec\` code—use them only as direct tool calls. +6. \`console.log\` output is NOT the result. Logs are separate; the final answer must come from \`return\`. + +### Workflow + +1. Call \`search\` with relevant keywords to discover tools. +2. Read the returned JavaScript function declarations and JSDoc to understand names and parameters. +3. Call \`exec\` with JavaScript code that uses the discovered tools and ends with an explicit \`return\`. +4. Use the \`exec\` result as your answer. + +### What \`search\` Does + +- Input: keyword string (comma-separated for OR-matching), plus optional \`limit\`. +- Output: JavaScript async function declarations with JSDoc showing exact function names, parameters, and return types. + +### What \`exec\` Does + +- Runs JavaScript code in an isolated async context (wrapped as \`(async () => { your code })())\`. +- All discovered tools are exposed as async functions: \`await ToolName(params)\`. +- Available helpers: + - \`parallel(...promises)\` → \`Promise.all(promises)\` + - \`settle(...promises)\` → \`Promise.allSettled(promises)\` + - \`console.log/info/warn/error/debug\` +- Returns JSON with: \`result\` (your returned value), \`logs\` (optional), \`error\` (optional), \`isError\` (optional). + +### Example: Single Tool Call + +\`\`\`javascript +// Step 1: search({ query: "browser,fetch" }) +// Step 2: exec with: +const page = await CherryBrowser_fetch({ url: "https://example.com" }) +return page +\`\`\` + +### Example: Multiple Tools with Parallel + +\`\`\`javascript +const [forecast, time] = await parallel( + Weather_getForecast({ city: "Paris" }), + Time_getLocalTime({ city: "Paris" }) +) +return { city: "Paris", forecast, time } +\`\`\` + +### Example: Handle Partial Failures with Settle + +\`\`\`javascript +const results = await settle( + Weather_getForecast({ city: "Paris" }), + Weather_getForecast({ city: "Tokyo" }) +) +const successful = results.filter(r => r.status === "fulfilled").map(r => r.value) +return { results, successful } +\`\`\` + +### Example: Error Handling + +\`\`\`javascript +try { + const user = await User_lookup({ email: "user@example.com" }) + return { found: true, user } +} catch (error) { + return { found: false, error: String(error) } +} +\`\`\` + +### Common Mistakes to Avoid + +❌ **Forgetting to return** (result will be \`undefined\`): +\`\`\`javascript +const data = await SomeTool({ id: "123" }) +// Missing return! +\`\`\` + +✅ **Always return**: +\`\`\`javascript +const data = await SomeTool({ id: "123" }) +return data +\`\`\` + +❌ **Only logging, not returning**: +\`\`\`javascript +const data = await SomeTool({ id: "123" }) +console.log(data) // Logs are NOT the result! +\`\`\` + +❌ **Missing await**: +\`\`\`javascript +const data = SomeTool({ id: "123" }) // Returns Promise, not value! +return data +\`\`\` + +❌ **Awaiting before parallel**: +\`\`\`javascript +await parallel(await ToolA(), await ToolB()) // Wrong: runs sequentially +\`\`\` + +✅ **Pass promises directly to parallel**: +\`\`\`javascript +await parallel(ToolA(), ToolB()) // Correct: runs in parallel +\`\`\` + +### Best Practices + +- Always call \`search\` first to discover tools and confirm signatures. +- Always use an explicit \`return\` at the end of \`exec\` code. +- Use \`parallel\` for independent operations that can run at the same time. +- Use \`settle\` when some calls may fail but you still want partial results. +- Prefer a single \`exec\` call for multi-step flows. +- Treat \`console.*\` as debugging only, never as the primary result. +` + +function buildToolsSection(tools: ToolInfo[]): string { + const existingNames = new Set<string>() + return tools + .map((t) => { + const functionName = generateMcpToolFunctionName(t.serverName, t.name, existingNames) + const desc = t.description || '' + const normalizedDesc = desc.replace(/\s+/g, ' ').trim() + const truncatedDesc = normalizedDesc.length > 50 ? `${normalizedDesc.slice(0, 50)}...` : normalizedDesc + return `- ${functionName}: ${truncatedDesc}` + }) + .join('\n') +} + +export function getHubModeSystemPrompt(tools: ToolInfo[] = []): string { + if (tools.length === 0) { + return '' + } + + const toolsSection = buildToolsSection(tools) + + return `${HUB_MODE_SYSTEM_PROMPT_BASE} +## Discoverable Tools (ONLY usable inside \`exec\` code, NOT as direct tool calls) + +The following tools are available inside \`exec\`. Use \`search\` to get their full signatures. +Do NOT call these directly—wrap them in \`exec\` code. + +${toolsSection} +` +} diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index f49794aaa7..2e387ce2d0 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -932,7 +932,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = { official: 'https://www.together.ai/', apiKey: 'https://api.together.ai/settings/api-keys', docs: 'https://docs.together.ai/docs/introduction', - models: 'https://docs.together.ai/docs/chat-models' + models: 'https://docs.together.ai/docs/serverless-models' } }, dmxapi: { @@ -1025,7 +1025,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = { official: 'https://www.baichuan-ai.com/', apiKey: 'https://platform.baichuan-ai.com/console/apikey', docs: 'https://platform.baichuan-ai.com/docs', - models: 'https://platform.baichuan-ai.com/price' + models: 'https://platform.baichuan-ai.com/prices' } }, modelscope: { diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 360f8a5e2a..3a4b7fdd86 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -3,7 +3,7 @@ import { isMac } from '@renderer/config/constant' import { isLocalAi } from '@renderer/config/env' import { useTheme } from '@renderer/context/ThemeProvider' import db from '@renderer/databases' -import i18n from '@renderer/i18n' +import i18n, { setDayjsLocale } from '@renderer/i18n' import KnowledgeQueue from '@renderer/queue/KnowledgeQueue' import MemoryService from '@renderer/services/MemoryService' import { useAppDispatch } from '@renderer/store' @@ -122,7 +122,9 @@ export function useAppInit() { }, [proxyUrl, proxyMode, proxyBypassRules]) useEffect(() => { - i18n.changeLanguage(language || navigator.language || defaultLanguage) + const currentLanguage = language || navigator.language || defaultLanguage + i18n.changeLanguage(currentLanguage) + setDayjsLocale(currentLanguage) }, [language]) useEffect(() => { @@ -183,17 +185,13 @@ export function useAppInit() { suggestionCount: payload.suggestions.length, autoApprove: payload.autoApprove }) - dispatch(toolPermissionsActions.requestReceived(payload)) - // Auto-approve if requested if (payload.autoApprove) { logger.debug('Auto-approving tool permission request', { requestId: payload.requestId, toolName: payload.toolName }) - dispatch(toolPermissionsActions.submissionSent({ requestId: payload.requestId, behavior: 'allow' })) - try { const response = await window.api.agentTools.respondToPermission({ requestId: payload.requestId, @@ -212,9 +210,13 @@ export function useAppInit() { }) } catch (error) { logger.error('Failed to send auto-approval response', error as Error) - dispatch(toolPermissionsActions.submissionFailed({ requestId: payload.requestId })) + // Fall through to add to store for manual approval + dispatch(toolPermissionsActions.requestReceived(payload)) } + return } + + dispatch(toolPermissionsActions.requestReceived(payload)) } const resultListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionResultPayload) => { diff --git a/src/renderer/src/hooks/useAssistant.ts b/src/renderer/src/hooks/useAssistant.ts index 0571092012..a1b501d0b7 100644 --- a/src/renderer/src/hooks/useAssistant.ts +++ b/src/renderer/src/hooks/useAssistant.ts @@ -83,7 +83,14 @@ export function useAssistant(id: string) { throw new Error(`Assistant model is not set for assistant with name: ${assistant?.name ?? 'unknown'}`) } - const assistantWithModel = useMemo(() => ({ ...assistant, model }), [assistant, model]) + const normalizedTopics = useMemo( + () => (Array.isArray(assistant?.topics) ? assistant.topics : []), + [assistant?.topics] + ) + const assistantWithModel = useMemo( + () => ({ ...assistant, model, topics: normalizedTopics }), + [assistant, model, normalizedTopics] + ) const settingsRef = useRef(assistant?.settings) diff --git a/src/renderer/src/hooks/useKnowledgeBaseForm.ts b/src/renderer/src/hooks/useKnowledgeBaseForm.ts index a42fc90fa0..5459f94a71 100644 --- a/src/renderer/src/hooks/useKnowledgeBaseForm.ts +++ b/src/renderer/src/hooks/useKnowledgeBaseForm.ts @@ -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 !== '' || ['mineru', 'open-mineru', 'paddleocr'].includes(p.id)) .map((p) => ({ value: p.id, label: p.name })) } return [preprocessOptions] diff --git a/src/renderer/src/hooks/useMinappPopup.ts b/src/renderer/src/hooks/useMinappPopup.ts index 9d372a6b3c..6c0734e047 100644 --- a/src/renderer/src/hooks/useMinappPopup.ts +++ b/src/renderer/src/hooks/useMinappPopup.ts @@ -1,4 +1,4 @@ -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { allMinApps } from '@renderer/config/minapps' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值 import NavigationService from '@renderer/services/NavigationService' @@ -120,10 +120,10 @@ export const useMinappPopup = () => { [openMinapp] ) - /** Open a minapp by id (look up the minapp in DEFAULT_MIN_APPS) */ + /** Open a minapp by id (look up the minapp in allMinApps) */ const openMinappById = useCallback( (id: string, keepAlive: boolean = false) => { - const app = DEFAULT_MIN_APPS.find((app) => app?.id === id) + const app = allMinApps.find((app) => app?.id === id) if (app) { openMinapp(app, keepAlive) } diff --git a/src/renderer/src/hooks/useMinapps.ts b/src/renderer/src/hooks/useMinapps.ts index 77ea3cb89e..75262f2b8d 100644 --- a/src/renderer/src/hooks/useMinapps.ts +++ b/src/renderer/src/hooks/useMinapps.ts @@ -1,25 +1,129 @@ -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { allMinApps } from '@renderer/config/minapps' import type { RootState } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setDisabledMinApps, setMinApps, setPinnedMinApps } from '@renderer/store/minapps' -import type { MinAppType } from '@renderer/types' +import type { LanguageVarious, MinAppType } from '@renderer/types' +import { useCallback, useMemo } from 'react' + +/** + * Data Flow Design: + * + * PRINCIPLE: Locale filtering is a VIEW concern, not a DATA concern. + * + * - Redux stores ALL apps (including locale-restricted ones) to preserve user preferences + * - allMinApps is the template data source containing locale definitions + * - This hook applies locale filtering only when READING for UI display + * - When WRITING, locale-hidden apps are merged back to prevent data loss + */ + +// Check if app should be visible for the given locale +const isVisibleForLocale = (app: MinAppType, language: LanguageVarious): boolean => { + if (!app.locales) return true + return app.locales.includes(language) +} + +// Filter apps by locale - only show apps that match current language +const filterByLocale = (apps: MinAppType[], language: LanguageVarious): MinAppType[] => { + return apps.filter((app) => isVisibleForLocale(app, language)) +} + +// Get locale-hidden apps from allMinApps for the current language +// This uses allMinApps as source of truth for locale definitions +const getLocaleHiddenApps = (language: LanguageVarious): MinAppType[] => { + return allMinApps.filter((app) => !isVisibleForLocale(app, language)) +} export const useMinapps = () => { const { enabled, disabled, pinned } = useAppSelector((state: RootState) => state.minapps) + const language = useAppSelector((state: RootState) => state.settings.language) const dispatch = useAppDispatch() + const mapApps = useCallback( + (apps: MinAppType[]) => apps.map((app) => allMinApps.find((item) => item.id === app.id) || app), + [] + ) + + const getAllApps = useCallback( + (apps: MinAppType[], disabledApps: MinAppType[]) => { + const mappedApps = mapApps(apps) + const existingIds = new Set(mappedApps.map((app) => app.id)) + const disabledIds = new Set(disabledApps.map((app) => app.id)) + const missingApps = allMinApps.filter((app) => !existingIds.has(app.id) && !disabledIds.has(app.id)) + return [...mappedApps, ...missingApps] + }, + [mapApps] + ) + + // READ: Get apps filtered by locale for UI display + const minapps = useMemo(() => { + const allApps = getAllApps(enabled, disabled) + const disabledIds = new Set(disabled.map((app) => app.id)) + const withoutDisabled = allApps.filter((app) => !disabledIds.has(app.id)) + return filterByLocale(withoutDisabled, language) + }, [enabled, disabled, language, getAllApps]) + + const disabledApps = useMemo(() => filterByLocale(mapApps(disabled), language), [disabled, language, mapApps]) + const pinnedApps = useMemo(() => filterByLocale(mapApps(pinned), language), [pinned, language, mapApps]) + + const updateMinapps = useCallback( + (visibleApps: MinAppType[]) => { + const disabledIds = new Set(disabled.map((app) => app.id)) + + const withoutDisabled = visibleApps.filter((app) => !disabledIds.has(app.id)) + + const localeHiddenApps = getLocaleHiddenApps(language) + + const localeHiddenIds = new Set(localeHiddenApps.map((app) => app.id)) + const preservedLocaleHidden = enabled.filter((app) => localeHiddenIds.has(app.id) && !disabledIds.has(app.id)) + + const visibleIds = new Set(withoutDisabled.map((app) => app.id)) + const toAppend = preservedLocaleHidden.filter((app) => !visibleIds.has(app.id)) + const merged = [...withoutDisabled, ...toAppend] + + const existingIds = new Set(merged.map((app) => app.id)) + const missingApps = allMinApps.filter((app) => !existingIds.has(app.id) && !disabledIds.has(app.id)) + + dispatch(setMinApps([...merged, ...missingApps])) + }, + [dispatch, enabled, disabled, language] + ) + + // WRITE: Update disabled apps, preserving locale-hidden disabled apps + const updateDisabledMinapps = useCallback( + (visibleDisabledApps: MinAppType[]) => { + const localeHiddenApps = getLocaleHiddenApps(language) + const localeHiddenIds = new Set(localeHiddenApps.map((app) => app.id)) + const preservedLocaleHidden = disabled.filter((app) => localeHiddenIds.has(app.id)) + + const visibleIds = new Set(visibleDisabledApps.map((app) => app.id)) + const toAppend = preservedLocaleHidden.filter((app) => !visibleIds.has(app.id)) + + dispatch(setDisabledMinApps([...visibleDisabledApps, ...toAppend])) + }, + [dispatch, disabled, language] + ) + + // WRITE: Update pinned apps, preserving locale-hidden pinned apps + const updatePinnedMinapps = useCallback( + (visiblePinnedApps: MinAppType[]) => { + const localeHiddenApps = getLocaleHiddenApps(language) + const localeHiddenIds = new Set(localeHiddenApps.map((app) => app.id)) + const preservedLocaleHidden = pinned.filter((app) => localeHiddenIds.has(app.id)) + + const visibleIds = new Set(visiblePinnedApps.map((app) => app.id)) + const toAppend = preservedLocaleHidden.filter((app) => !visibleIds.has(app.id)) + + dispatch(setPinnedMinApps([...visiblePinnedApps, ...toAppend])) + }, + [dispatch, pinned, language] + ) + return { - minapps: enabled.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app), - disabled: disabled.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app), - pinned: pinned.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app), - updateMinapps: (minapps: MinAppType[]) => { - dispatch(setMinApps(minapps)) - }, - updateDisabledMinapps: (minapps: MinAppType[]) => { - dispatch(setDisabledMinApps(minapps)) - }, - updatePinnedMinapps: (minapps: MinAppType[]) => { - dispatch(setPinnedMinApps(minapps)) - } + minapps, + disabled: disabledApps, + pinned: pinnedApps, + updateMinapps, + updateDisabledMinapps, + updatePinnedMinapps } } diff --git a/src/renderer/src/hooks/useProvider.ts b/src/renderer/src/hooks/useProvider.ts index be4ed772fb..082a252002 100644 --- a/src/renderer/src/hooks/useProvider.ts +++ b/src/renderer/src/hooks/useProvider.ts @@ -1,7 +1,7 @@ import { createSelector } from '@reduxjs/toolkit' import { CHERRYAI_PROVIDER } from '@renderer/config/providers' import { getDefaultProvider } from '@renderer/services/AssistantService' -import { useAppDispatch, useAppSelector } from '@renderer/store' +import { type RootState, useAppDispatch, useAppSelector } from '@renderer/store' import { addModel, addProvider, @@ -13,12 +13,43 @@ import { } from '@renderer/store/llm' import type { Assistant, Model, Provider } from '@renderer/types' import { isSystemProvider } from '@renderer/types' +import { withoutTrailingSlash } from '@renderer/utils/api' +import { useMemo } from 'react' import { useDefaultModel } from './useAssistant' -const selectEnabledProviders = createSelector( - (state) => state.llm.providers, - (providers) => providers.filter((p) => p.enabled).concat(CHERRYAI_PROVIDER) +/** + * Normalizes provider apiHost by removing trailing slashes. + * This ensures consistent URL concatenation across the application. + */ +function normalizeProvider<T extends Provider>(provider: T): T { + return { + ...provider, + apiHost: withoutTrailingSlash(provider.apiHost) + } +} + +const selectProviders = (state: RootState) => state.llm.providers + +const selectEnabledProviders = createSelector(selectProviders, (providers) => + providers + .map(normalizeProvider) + .filter((p) => p.enabled) + .concat(CHERRYAI_PROVIDER) +) + +const selectSystemProviders = createSelector(selectProviders, (providers) => + providers.filter((p) => isSystemProvider(p)).map(normalizeProvider) +) + +const selectUserProviders = createSelector(selectProviders, (providers) => + providers.filter((p) => !isSystemProvider(p)).map(normalizeProvider) +) + +const selectAllProviders = createSelector(selectProviders, (providers) => providers.map(normalizeProvider)) + +const selectAllProvidersWithCherryAI = createSelector(selectProviders, (providers) => + [...providers, CHERRYAI_PROVIDER].map(normalizeProvider) ) export function useProviders() { @@ -35,21 +66,20 @@ export function useProviders() { } export function useSystemProviders() { - return useAppSelector((state) => state.llm.providers.filter((p) => isSystemProvider(p))) + return useAppSelector(selectSystemProviders) } export function useUserProviders() { - return useAppSelector((state) => state.llm.providers.filter((p) => !isSystemProvider(p))) + return useAppSelector(selectUserProviders) } export function useAllProviders() { - return useAppSelector((state) => state.llm.providers) + return useAppSelector(selectAllProviders) } export function useProvider(id: string) { - const provider = - useAppSelector((state) => state.llm.providers.concat([CHERRYAI_PROVIDER]).find((p) => p.id === id)) || - getDefaultProvider() + const allProviders = useAppSelector(selectAllProvidersWithCherryAI) + const provider = useMemo(() => allProviders.find((p) => p.id === id) || getDefaultProvider(), [allProviders, id]) const dispatch = useAppDispatch() return { diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index 3a3de7f89a..84549d40de 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -34,6 +34,7 @@ import { setTopicPosition, setTray as _setTray, setTrayOnClose, + setUseSystemTitleBar as _setUseSystemTitleBar, setWindowStyle } from '@renderer/store/settings' import type { SidebarIcon, ThemeMode, TranslateLanguageCode } from '@renderer/types' @@ -117,6 +118,10 @@ export function useSettings() { setDisableHardwareAcceleration(disableHardwareAcceleration: boolean) { dispatch(setDisableHardwareAcceleration(disableHardwareAcceleration)) window.api.setDisableHardwareAcceleration(disableHardwareAcceleration) + }, + setUseSystemTitleBar(useSystemTitleBar: boolean) { + dispatch(_setUseSystemTitleBar(useSystemTitleBar)) + window.api.setUseSystemTitleBar(useSystemTitleBar) } } } diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index 5b8e1cc7ac..f110612873 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -1,5 +1,17 @@ +import 'dayjs/locale/de' +import 'dayjs/locale/el' +import 'dayjs/locale/es' +import 'dayjs/locale/fr' +import 'dayjs/locale/ja' +import 'dayjs/locale/pt' +import 'dayjs/locale/ro' +import 'dayjs/locale/ru' +import 'dayjs/locale/zh-cn' +import 'dayjs/locale/zh-tw' + import { loggerService } from '@logger' import { defaultLanguage } from '@shared/config/constant' +import dayjs from 'dayjs' import i18n from 'i18next' import { initReactI18next } from 'react-i18next' @@ -43,6 +55,26 @@ export const getLanguageCode = () => { return getLanguage().split('-')[0] } +// Map i18n language codes to dayjs locale codes +const dayjsLocaleMap: Record<string, string> = { + 'en-US': 'en', + 'ja-JP': 'ja', + 'ru-RU': 'ru', + 'zh-CN': 'zh-cn', + 'zh-TW': 'zh-tw', + 'de-DE': 'de', + 'el-GR': 'el', + 'es-ES': 'es', + 'fr-FR': 'fr', + 'pt-PT': 'pt', + 'ro-RO': 'ro' +} + +export const setDayjsLocale = (language: string) => { + const dayjsLocale = dayjsLocaleMap[language] || 'en' + dayjs.locale(dayjsLocale) +} + i18n.use(initReactI18next).init({ resources, lng: getLanguage(), diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 2e6f84026e..dc79466f65 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -286,6 +286,19 @@ export const getMcpTypeLabel = (key: string): string => { return getLabel(mcpTypeKeyMap, key) } +const mcpProviderDescriptionKeyMap = { + '302ai': 'settings.mcp.sync.providerDescriptions.302ai', + bailian: 'settings.mcp.sync.providerDescriptions.bailian', + lanyun: 'settings.mcp.sync.providerDescriptions.lanyun', + mcprouter: 'settings.mcp.sync.providerDescriptions.mcprouter', + modelscope: 'settings.mcp.sync.providerDescriptions.modelscope', + tokenflux: 'settings.mcp.sync.providerDescriptions.tokenflux' +} as const + +export const getMcpProviderDescriptionLabel = (key: string): string => { + return getLabel(mcpProviderDescriptionKeyMap, key) +} + const miniappsStatusKeyMap = { visible: 'settings.miniapps.visible', disabled: 'settings.miniapps.disabled' @@ -332,7 +345,8 @@ const builtInMcpDescriptionKeyMap: Record<BuiltinMCPServerName, string> = { [BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python', [BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp', [BuiltinMCPServerNames.browser]: 'settings.mcp.builtinServersDescriptions.browser', - [BuiltinMCPServerNames.nowledgeMem]: 'settings.mcp.builtinServersDescriptions.nowledge_mem' + [BuiltinMCPServerNames.nowledgeMem]: 'settings.mcp.builtinServersDescriptions.nowledge_mem', + [BuiltinMCPServerNames.hub]: 'settings.mcp.builtinServersDescriptions.hub' } as const export const getBuiltInMcpServerDescriptionLabel = (key: string): string => { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 7ac425c54a..5e471b5f77 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -271,6 +271,7 @@ }, "toolPermission": { "aria": { + "allowAllRequest": "Always allow this tool", "allowRequest": "Allow tool request", "denyRequest": "Deny tool request", "hideDetails": "Hide tool details", @@ -278,6 +279,7 @@ "showDetails": "Show tool details" }, "button": { + "allowAll": "Always Allow", "cancel": "Cancel", "run": "Run" }, @@ -544,6 +546,20 @@ "description": "Default enabled MCP servers", "enableFirst": "Enable this server in MCP settings first", "label": "MCP Servers", + "mode": { + "auto": { + "description": "AI discovers and uses tools automatically", + "label": "Auto" + }, + "disabled": { + "description": "No MCP tools", + "label": "Disabled" + }, + "manual": { + "description": "Select specific MCP servers", + "label": "Manual" + } + }, "noServersAvailable": "No MCP servers available. Add servers in settings", "title": "MCP Settings" }, @@ -653,6 +669,10 @@ "title": "New Topic" } }, + "alerts": { + "create_session": "Create a session", + "select_agent": "Select an agent" + }, "artifacts": { "button": { "download": "Download", @@ -1186,6 +1206,8 @@ "cancel": "Cancel", "chat": "Chat", "clear": "Clear", + "clear_all": "Clear All", + "click_to_replace": "Click to replace", "close": "Close", "collapse": "Collapse", "completed": "Completed", @@ -1220,9 +1242,15 @@ "footnote": "Reference content", "footnotes": "References", "fullscreen": "Entered fullscreen mode. Press F11 to exit", + "generate_random_seed": "Generate random seed", + "get_embedding_dimension": "Get embedding dimension", "go_to_settings": "Go to settings", + "html_preview": "HTML Preview", "i_know": "I know", "ignore": "Ignore", + "image_preview": "Image preview", + "image_url": "Image URL", + "image_url_or_upload": "Enter image URL or upload file", "inspect": "Inspect", "invalid_value": "Invalid Value", "knowledge_base": "Knowledge Base", @@ -1232,6 +1260,7 @@ "models": "Models", "more": "More", "name": "Name", + "next_match": "Next match", "no_results": "No results", "none": "None", "off": "Off", @@ -1243,13 +1272,17 @@ "model": "Select a model" } }, + "powered_by": "Powered by ", "preview": "Preview", + "previous_match": "Previous match", "prompt": "Prompt", "provider": "Provider", "reasoning_content": "Deep reasoning", "refresh": "Refresh", "regenerate": "Regenerate", + "remove_image": "Remove image", "rename": "Rename", + "required_field": "Required field", "reset": "Reset", "save": "Save", "saved": "Saved", @@ -1272,14 +1305,29 @@ "success": "Success", "swap": "Swap", "topics": "Topics", + "translate_text": "Translate text", "unknown": "Unknown", "unnamed": "Unnamed", "unsubscribe": "Unsubscribe", "update_success": "Update successfully", "upload_files": "Upload file", + "upload_image": "Upload image file", + "uploaded_image": "Uploaded image", "warning": "Warning", "you": "You" }, + "dialog": { + "all_files": "All Files", + "html_files": "HTML Files", + "open_file": "Open File", + "pdf_files": "PDF Files", + "png_image": "PNG Image", + "save_as_html": "Save as HTML", + "save_as_pdf": "Save as PDF", + "save_file": "Save File", + "select_folder": "Select Folder", + "word_document": "Word Document" + }, "docs": { "title": "Docs" }, @@ -2001,10 +2049,65 @@ "cancelled": "Cancelled", "completed": "Completed", "error": "Error occurred", + "groupHeader": "{{count}} tool calls", "invoking": "Invoking", + "labels": { + "bash": "Bash", + "edit": "Edit", + "exitPlanMode": "ExitPlanMode", + "glob": "Glob", + "grep": "Grep", + "mcpServerTool": "MCP Server Tool", + "multiEdit": "MultiEdit", + "notebookEdit": "NotebookEdit", + "readFile": "Read File", + "search": "Search", + "skill": "Skill", + "task": "Task", + "todoWrite": "Todo Write", + "tool": "Tool", + "webFetch": "Web Fetch", + "webSearch": "Web Search", + "write": "Write" + }, + "noData": "No data available for this tool", "pending": "Pending", "preview": "Preview", - "raw": "Raw" + "raw": "Raw", + "runningCount": "{{count}} tools running", + "sections": { + "command": "Command", + "exitCode": "Exit Code", + "input": "Input", + "output": "Output", + "prompt": "Prompt", + "searchQuery": "Search Query", + "searchResults": "Search Results", + "stderr": "stderr", + "stdout": "stdout" + }, + "status": { + "done": "Done", + "error": "Error", + "failed": "Failed", + "running": "Running", + "success": "Success" + }, + "truncated": "Output truncated (original: {{size}})", + "units": { + "done_one": "{{count}} Done", + "done_other": "{{count}} Done", + "file_one": "{{count}} file", + "file_other": "{{count}} files", + "item_one": "{{count}} item", + "item_other": "{{count}} items", + "line_one": "{{count}} line", + "line_other": "{{count}} lines", + "plan_one": "{{count}} plan", + "plan_other": "{{count}} plans", + "result_one": "{{count}} result", + "result_other": "{{count}} results" + } }, "topic": { "added": "New topic added" @@ -2212,14 +2315,18 @@ "drop_markdown_hint": "Drop .md files or folders here to import", "empty": "No notes available yet", "expand": "unfold", + "exportToWord": "Export to Word", "export_failed": "Failed to export to knowledge base", "export_knowledge": "Export notes to knowledge base", "export_success": "Successfully exported to the knowledge base", + "export_to_word_failed": "Failed to export to Word", "folder": "folder", "new_folder": "New Folder", "new_note": "Create a new note", "no_content_to_copy": "No content to copy", + "no_content_to_export": "No content to export", "no_file_selected": "Please select the file to upload", + "no_note_selected": "Please select a note first", "no_valid_files": "No valid file was uploaded", "open_folder": "Open an external folder", "open_outside": "Open from external", @@ -2425,6 +2532,39 @@ } }, "custom_size": "Custom Size", + "dmxapi": { + "generating_tip": "Generating with the official model, estimated wait time is 2-5 minutes for best results. Please check DMXAPI backend logs for the cost of this operation.", + "style": ", Style: ", + "style_types": { + "25d_animation": "2.5D Animation", + "3d_cartoon": "3D Cartoon", + "american_retro": "American Retro", + "baroque": "Baroque", + "cartoon_illustration": "Cartoon Illustration", + "chinese_gongbi": "Chinese Gongbi", + "clay": "Clay", + "cyberpunk": "Cyberpunk", + "felt": "Felt", + "flat": "Flat", + "fresh_anime": "Fresh Anime", + "ghibli": "Ghibli", + "japanese_anime": "Japanese Anime", + "little_people_book": "Little People Book", + "monet_garden": "Monet Garden", + "oil_painting": "Oil Painting", + "pixar": "Pixar", + "pixel_art": "Pixel Art", + "poetic_ancient": "Poetic Ancient", + "psychedelic": "Psychedelic", + "sketch": "Sketch", + "street_art": "Street Art", + "texture": "Texture", + "ukiyo_e": "Ukiyo-e", + "watercolor": "Watercolor", + "wood_carving": "Wood Carving", + "yarn_doll": "Yarn Doll" + } + }, "edit": { "image_file": "Edited Image", "magic_prompt_option_tip": "Intelligently enhances editing prompts", @@ -2449,6 +2589,7 @@ "style_type_tip": "Image generation style for V_2 and above", "width": "Width" }, + "generate_failed": "Failed to generate image", "generated_image": "Generated Image", "go_to_settings": "Go to Settings", "guidance_scale": "Guidance Scale", @@ -2459,6 +2600,7 @@ "image_file_required": "Please upload an image first", "image_file_retry": "Please re-upload an image first", "image_handle_required": "Please upload an image first.", + "image_mix_failed": "Failed to mix images", "image_placeholder": "No image available", "image_retry": "Retry", "image_size_options": { @@ -2468,6 +2610,7 @@ "inference_steps_tip": "The number of inference steps to perform. More steps produce higher quality but take longer", "input_image": "Input Image", "input_parameters": "Input Parameters", + "invalid_image_url": "Invalid image URL format", "learn_more": "Learn More", "magic_prompt_option": "Magic Prompt", "mode": { @@ -2489,6 +2632,7 @@ "no_image_generation_model": "No available image generation model, please add a model and set the endpoint type to {{endpoint_type}}", "number_images": "Number Images", "number_images_tip": "Number of images to generate (1-4)", + "operation_failed": "Operation failed, please try again later", "paint_course": "tutorial", "per_image": "per image", "per_images": "per images", @@ -2564,6 +2708,26 @@ "resemblance": "Similarity", "resemblance_tip": "Controls similarity to original image", "seed_tip": "Controls upscaling randomness" + }, + "zhipu": { + "custom_size_divisible": "Custom size must be divisible by 16", + "custom_size_hint": "Width and height must be between 512px-2048px, divisible by 16, and total pixels cannot exceed 2^21px", + "custom_size_pixels": "Total pixels of custom size cannot exceed 2,097,152", + "custom_size_range": "Custom size must be between 512px-2048px", + "custom_size_required": "Please set custom width and height", + "image_sizes": { + "1024x1024_default": "1024x1024 (Default)", + "1152x864": "1152x864", + "1344x768": "1344x768", + "1440x720": "1440x720", + "720x1440": "720x1440", + "768x1344": "768x1344", + "864x1152": "864x1152" + }, + "quality_options": { + "hd": "HD", + "standard_default": "Standard (Default)" + } } }, "plugins": { @@ -2574,6 +2738,20 @@ "category": "Category", "commands": "Commands", "confirm_uninstall": "Are you sure you want to uninstall {{name}}?", + "content_saved": "Plugin content saved successfully", + "detail": { + "allowed_tools": "Allowed Tools", + "author": "Author", + "content": "Content", + "description": "Description", + "file": "File", + "installed": "Installed", + "metadata": "Metadata", + "size": "Size", + "source": "Source", + "tags": "Tags", + "tools": "Tools" + }, "install": "Install", "install_plugins_from_browser": "Browse available plugins to get started", "installing": "Installing...", @@ -2924,6 +3102,11 @@ "summary": "Summarize", "translate": "Translate" }, + "prompt": { + "explain": "Please explain the following content. Requirements: Reply in {{language}}; do not include any explanation of this prompt, just give the response directly: \n\n", + "refine": "Please optimize or polish the user input content wrapped in the XML tag <INPUT>, while maintaining the meaning and integrity of the original content. Requirements: Your output should be in the same language as the user input; do not include any explanation of this prompt, just give the response directly; do not output XML tags, output the optimized content directly: \n\n<INPUT>{{text}}</INPUT>", + "summary": "Please summarize the following content. Requirements: Reply in {{language}}; do not include any explanation of this prompt, just give the response directly: \n\n" + }, "translate": { "smart_translate_tips": "Smart Translation: Content will be translated to the target language first; content already in the target language will be translated to the alternative language" }, @@ -3101,6 +3284,10 @@ }, "settings": { "about": { + "careers": { + "button": "View", + "title": "Careers" + }, "checkUpdate": { "available": "Update", "label": "Check Update" @@ -4013,6 +4200,10 @@ "jsonModeHint": "Edit the JSON representation of the MCP server configuration. Please ensure the format is correct before saving.", "jsonSaveError": "Failed to save JSON configuration.", "jsonSaveSuccess": "JSON configuration has been saved.", + "lanyun": { + "description": "Lanyun Technology Cloud Platform MCP Service", + "name": "Lanyun Technology" + }, "logoUrl": "Logo URL", "logs": "Logs", "longRunning": "Long Running Mode", @@ -4109,6 +4300,14 @@ "getToken": "Get API Token", "getTokenDescription": "Retrieve your personal API token from your account", "noServersAvailable": "No MCP servers available", + "providerDescriptions": { + "302ai": "302.AI Platform MCP Service", + "bailian": "Alibaba Cloud Bailian Platform MCP Service", + "lanyun": "Lanyun Technology Cloud Platform MCP Service", + "mcprouter": "MCP Router Platform MCP Service", + "modelscope": "ModelScope Platform MCP Service", + "tokenflux": "TokenFlux Platform MCP Service" + }, "selectProvider": "Select Provider:", "setToken": "Enter Your Token", "success": "Sync MCP Servers successful", @@ -4201,9 +4400,6 @@ "title": "Message Settings", "use_serif_font": "Use serif font" }, - "mineru": { - "api_key": "Mineru now offers a daily free quota of 500 pages, and you do not need to enter a key." - }, "miniapps": { "cache_change_notice": "Changes will take effect when the number of open mini apps reaches the set value", "cache_description": "Set the maximum number of active mini apps to keep in memory", @@ -4461,6 +4657,14 @@ } }, "options": { + "anthropic_cache": { + "cache_last_n": "Cache Last N Messages", + "cache_last_n_help": "Cache the last N conversation messages (excluding system messages)", + "cache_system": "Cache System Message", + "cache_system_help": "Whether to cache the system prompt", + "token_threshold": "Cache Token Threshold", + "token_threshold_help": "Messages exceeding this token count will be cached. Set to 0 to disable caching." + }, "array_content": { "help": "Does the provider support the content field of the message being of array type?", "label": "Supports array format message content" @@ -4581,6 +4785,9 @@ "title": "Delete Provider" }, "dmxapi": { + "platform_enterprise": "ssvip.DMXAPI.com (Enterprise)", + "platform_international": "www.DMXAPI.com (International)", + "platform_official": "www.DMXAPI.cn (CNY)", "select_platform": "Select the platform" }, "docs_check": "Check", @@ -4740,10 +4947,8 @@ "image_provider": "OCR service provider", "paddleocr": { "aistudio_access_token": "Access token of AI Studio Community", - "aistudio_url_label": "AI Studio Community", "api_url": "API URL", - "serving_doc_url_label": "PaddleOCR Serving Documentation", - "tip": "You can refer to the official PaddleOCR documentation to deploy a local service, or deploy a cloud service on the PaddlePaddle AI Studio Community. For the latter case, please provide the access token of the AI Studio Community." + "api_url_label": "Obtain Access Token and API URL" }, "system": { "win": { @@ -4756,6 +4961,12 @@ "title": "OCR service" }, "preprocess": { + "paddleocr": { + "aistudio_access_token": "Access token of AI Studio Community", + "api_url": "API URL", + "api_url_label": "Obtain Access Token and API URL", + "paddleocr_url_label": "PaddleOCR Official Website" + }, "provider": "Document Processing Provider", "provider_placeholder": "Choose a document processing provider", "title": "Document Processing", @@ -4917,6 +5128,13 @@ "show": "Show Tray Icon", "title": "Tray" }, + "use_system_title_bar": { + "confirm": { + "content": "Changing the title bar style requires restarting the app to take effect. Do you want to restart now?", + "title": "Restart Required" + }, + "title": "Use System Title Bar (Linux)" + }, "zoom": { "reset": "Reset", "title": "Page Zoom" @@ -4994,6 +5212,8 @@ "detected": { "language": "Auto Detect" }, + "detected_source": "Detected", + "detecting": "Detecting...", "empty": "Translation content is empty", "error": { "chat_qwen_mt": "Qwen MT model cannot be used in chat. Please go to the translation page.", @@ -5046,6 +5266,7 @@ "not_pair": "Source language is different from the set language", "same": "Source and target languages are the same" }, + "language_settings": "Language Settings", "menu": { "description": "Translate the content of the current input box" }, @@ -5055,6 +5276,7 @@ "output": { "placeholder": "Translation" }, + "preferred_target": "Preferred Target", "processing": "Translation in progress...", "settings": { "autoCopy": "Copy after translation ", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index e598016fe2..021d587ce0 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -271,6 +271,7 @@ }, "toolPermission": { "aria": { + "allowAllRequest": "总是允许此工具", "allowRequest": "允许工具请求", "denyRequest": "拒绝工具请求", "hideDetails": "隐藏工具详情", @@ -278,6 +279,7 @@ "showDetails": "显示工具详情" }, "button": { + "allowAll": "总是允许", "cancel": "取消", "run": "运行" }, @@ -544,6 +546,20 @@ "description": "默认启用的 MCP 服务器", "enableFirst": "请先在 MCP 设置中启用此服务器", "label": "MCP 服务器", + "mode": { + "auto": { + "description": "AI 自动发现和使用工具", + "label": "自动" + }, + "disabled": { + "description": "不使用 MCP 工具", + "label": "禁用" + }, + "manual": { + "description": "选择特定的 MCP 服务器", + "label": "手动" + } + }, "noServersAvailable": "无可用 MCP 服务器。请在设置中添加服务器", "title": "MCP 服务器" }, @@ -653,6 +669,10 @@ "title": "新建话题" } }, + "alerts": { + "create_session": "请创建会话", + "select_agent": "请选择智能体" + }, "artifacts": { "button": { "download": "下载", @@ -1186,6 +1206,8 @@ "cancel": "取消", "chat": "聊天", "clear": "清除", + "clear_all": "清除全部", + "click_to_replace": "点击替换", "close": "关闭", "collapse": "折叠", "completed": "完成", @@ -1220,9 +1242,15 @@ "footnote": "引用内容", "footnotes": "引用内容", "fullscreen": "已进入全屏模式,按 F11 退出", + "generate_random_seed": "生成随机种子", + "get_embedding_dimension": "获取嵌入维度", "go_to_settings": "前往设置", + "html_preview": "HTML 预览", "i_know": "我知道了", "ignore": "忽略", + "image_preview": "图片预览", + "image_url": "图片链接", + "image_url_or_upload": "输入图片链接或上传文件", "inspect": "检查", "invalid_value": "无效值", "knowledge_base": "知识库", @@ -1232,6 +1260,7 @@ "models": "模型", "more": "更多", "name": "名称", + "next_match": "下一个匹配", "no_results": "无结果", "none": "无", "off": "关闭", @@ -1243,13 +1272,17 @@ "model": "选择模型" } }, + "powered_by": "由 ", "preview": "预览", + "previous_match": "上一个匹配", "prompt": "提示词", "provider": "提供商", "reasoning_content": "已深度思考", "refresh": "刷新", "regenerate": "重新生成", + "remove_image": "移除图片", "rename": "重命名", + "required_field": "必填字段", "reset": "重置", "save": "保存", "saved": "已保存", @@ -1272,14 +1305,29 @@ "success": "成功", "swap": "交换", "topics": "话题", + "translate_text": "翻译文本", "unknown": "未知", "unnamed": "未命名", "unsubscribe": "退订", "update_success": "更新成功", "upload_files": "上传文件", + "upload_image": "上传图片文件", + "uploaded_image": "已上传图片", "warning": "警告", "you": "用户" }, + "dialog": { + "all_files": "所有文件", + "html_files": "HTML 文件", + "open_file": "打开文件", + "pdf_files": "PDF 文件", + "png_image": "PNG 图片", + "save_as_html": "保存为 HTML", + "save_as_pdf": "保存为 PDF", + "save_file": "保存文件", + "select_folder": "选择文件夹", + "word_document": "Word 文档" + }, "docs": { "title": "帮助文档" }, @@ -2001,10 +2049,65 @@ "cancelled": "已取消", "completed": "已完成", "error": "发生错误", + "groupHeader": "{{count}} 个工具调用", "invoking": "调用中", + "labels": { + "bash": "执行命令", + "edit": "编辑", + "exitPlanMode": "退出计划模式", + "glob": "文件匹配", + "grep": "搜索", + "mcpServerTool": "MCP 服务器工具", + "multiEdit": "批量编辑", + "notebookEdit": "笔记本编辑", + "readFile": "读取文件", + "search": "搜索", + "skill": "技能", + "task": "任务", + "todoWrite": "待办写入", + "tool": "工具", + "webFetch": "网页获取", + "webSearch": "网页搜索", + "write": "写入" + }, + "noData": "此工具暂无可用数据", "pending": "等待中", "preview": "预览", - "raw": "原始" + "raw": "原始", + "runningCount": "{{count}} 个工具运行中", + "sections": { + "command": "命令", + "exitCode": "退出码", + "input": "输入", + "output": "输出", + "prompt": "提示", + "searchQuery": "搜索查询", + "searchResults": "搜索结果", + "stderr": "标准错误", + "stdout": "标准输出" + }, + "status": { + "done": "完成", + "error": "错误", + "failed": "失败", + "running": "运行中", + "success": "成功" + }, + "truncated": "输出已截断(原始大小:{{size}})", + "units": { + "done_one": "{{count}} 项完成", + "done_other": "{{count}} 项完成", + "file_one": "{{count}} 个文件", + "file_other": "{{count}} 个文件", + "item_one": "{{count}} 项", + "item_other": "{{count}} 项", + "line_one": "{{count}} 行", + "line_other": "{{count}} 行", + "plan_one": "{{count}} 个计划", + "plan_other": "{{count}} 个计划", + "result_one": "{{count}} 个结果", + "result_other": "{{count}} 个结果" + } }, "topic": { "added": "话题添加成功" @@ -2212,14 +2315,18 @@ "drop_markdown_hint": "拖拽 .md 文件或目录到此处导入", "empty": "暂无笔记", "expand": "展开", + "exportToWord": "导出为 Word", "export_failed": "导出到知识库失败", "export_knowledge": "导出笔记到知识库", "export_success": "成功导出到知识库", + "export_to_word_failed": "导出为 Word 失败", "folder": "文件夹", "new_folder": "新建文件夹", "new_note": "新建笔记", "no_content_to_copy": "没有内容可复制", + "no_content_to_export": "没有内容可导出", "no_file_selected": "请选择要上传的文件", + "no_note_selected": "请先选择一个笔记", "no_valid_files": "没有上传有效的文件", "open_folder": "打开外部文件夹", "open_outside": "从外部打开", @@ -2425,6 +2532,39 @@ } }, "custom_size": "自定义尺寸", + "dmxapi": { + "generating_tip": "正在使用官方的模型生成,预计等待2~5分钟效果最好,本次消耗金额请到DMXAPI后台日志查看", + "style": ",风格:", + "style_types": { + "25d_animation": "2.5D动画", + "3d_cartoon": "3D卡通", + "american_retro": "美式复古", + "baroque": "巴洛克", + "cartoon_illustration": "卡通插画", + "chinese_gongbi": "中国工笔", + "clay": "黏土", + "cyberpunk": "赛博朋克", + "felt": "毛毡", + "flat": "平坦", + "fresh_anime": "新鲜动漫", + "ghibli": "吉卜力", + "japanese_anime": "日本动漫", + "little_people_book": "小人书", + "monet_garden": "莫奈花园", + "oil_painting": "油画", + "pixar": "皮克斯", + "pixel_art": "像素艺术", + "poetic_ancient": "诗意古风", + "psychedelic": "迷幻", + "sketch": "草图", + "street_art": "街头艺术", + "texture": "纹理", + "ukiyo_e": "浮世绘", + "watercolor": "水彩", + "wood_carving": "木雕", + "yarn_doll": "纱线娃娃" + } + }, "edit": { "image_file": "编辑的图像", "magic_prompt_option_tip": "智能优化编辑提示词", @@ -2449,6 +2589,7 @@ "style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本", "width": "宽度" }, + "generate_failed": "生成图像失败", "generated_image": "生成图片", "go_to_settings": "去设置", "guidance_scale": "引导比例", @@ -2459,6 +2600,7 @@ "image_file_required": "请先上传图片", "image_file_retry": "请重新上传图片", "image_handle_required": "请先上传图片", + "image_mix_failed": "图像混合失败", "image_placeholder": "暂无图片", "image_retry": "重试", "image_size_options": { @@ -2468,6 +2610,7 @@ "inference_steps_tip": "要执行的推理步数。步数越多,质量越高但耗时越长", "input_image": "输入图片", "input_parameters": "输入参数", + "invalid_image_url": "无效的图片URL格式", "learn_more": "了解更多", "magic_prompt_option": "提示词增强", "mode": { @@ -2489,6 +2632,7 @@ "no_image_generation_model": "暂无可用的图片生成模型,请先新增模型并设置端点类型为 {{endpoint_type}}", "number_images": "生成数量", "number_images_tip": "一次生成的图片数量 (1-4)", + "operation_failed": "操作失败,请稍后重试", "paint_course": "教程", "per_image": "每张图片", "per_images": "每张图片", @@ -2564,6 +2708,26 @@ "resemblance": "相似度", "resemblance_tip": "控制放大结果与原图的相似程度", "seed_tip": "控制放大结果的随机性" + }, + "zhipu": { + "custom_size_divisible": "自定义尺寸必须能被16整除", + "custom_size_hint": "长宽均需满足512px-2048px之间,需被16整除,并保证最大像素数不超过2^21px", + "custom_size_pixels": "自定义尺寸的总像素数不能超过2,097,152", + "custom_size_range": "自定义尺寸必须在512px-2048px之间", + "custom_size_required": "请设置自定义尺寸的宽度和高度", + "image_sizes": { + "1024x1024_default": "1024x1024(默认)", + "1152x864": "1152x864", + "1344x768": "1344x768", + "1440x720": "1440x720", + "720x1440": "720x1440", + "768x1344": "768×1344", + "864x1152": "864x1152" + }, + "quality_options": { + "hd": "高清", + "standard_default": "标准(默认)" + } } }, "plugins": { @@ -2574,6 +2738,20 @@ "category": "类别", "commands": "命令", "confirm_uninstall": "确定要卸载 {{name}} 吗?", + "content_saved": "插件内容保存成功", + "detail": { + "allowed_tools": "允许的工具", + "author": "作者", + "content": "内容", + "description": "描述", + "file": "文件", + "installed": "安装时间", + "metadata": "元数据", + "size": "大小", + "source": "来源", + "tags": "标签", + "tools": "工具" + }, "install": "安装", "install_plugins_from_browser": "浏览可用插件以开始使用", "installing": "安装中...", @@ -2924,6 +3102,11 @@ "summary": "总结", "translate": "翻译" }, + "prompt": { + "explain": "请解释下面的内容。要求:使用 {{language}} 语言进行回复;请不要包含对本提示词的任何解释,直接给出回复: \n\n", + "refine": "请对用XML标签<INPUT>包裹的用户输入内容进行优化或润色,并保持原内容的含义和完整性。要求:你的输出应当与用户输入内容的语言相同。;请不要包含对本提示词的任何解释,直接给出回复;请不要输出XML标签,直接输出优化后的内容: \n\n<INPUT>{{text}}</INPUT>", + "summary": "请总结下面的内容。要求:使用 {{language}} 语言进行回复;请不要包含对本提示词的任何解释,直接给出回复: \n\n" + }, "translate": { "smart_translate_tips": "智能翻译:内容将优先翻译为目标语言;内容已是目标语言的,将翻译为备选语言" }, @@ -3101,6 +3284,10 @@ }, "settings": { "about": { + "careers": { + "button": "查看", + "title": "加入我们" + }, "checkUpdate": { "available": "立即更新", "label": "检查更新" @@ -4013,6 +4200,10 @@ "jsonModeHint": "编辑 MCP 服务器配置的 JSON 表示。保存前请确保格式正确", "jsonSaveError": "保存 JSON 配置失败", "jsonSaveSuccess": "JSON 配置已保存", + "lanyun": { + "description": "蓝耘科技云平台 MCP 服务", + "name": "蓝耘科技" + }, "logoUrl": "标志网址", "logs": "日志", "longRunning": "长时间运行模式", @@ -4109,6 +4300,14 @@ "getToken": "获取 API 令牌", "getTokenDescription": "从您的帐户中获取个人 API 令牌", "noServersAvailable": "无可用的 MCP 服务器", + "providerDescriptions": { + "302ai": "302.AI 平台 MCP 服务", + "bailian": "百炼平台服务", + "lanyun": "蓝耘科技云平台 MCP 服务", + "mcprouter": "MCP Router 平台 MCP 服务", + "modelscope": "ModelScope 平台 MCP 服务", + "tokenflux": "TokenFlux 平台 MCP 服务" + }, "selectProvider": "选择提供商:", "setToken": "输入您的令牌", "success": "同步 MCP 服务器成功", @@ -4201,9 +4400,6 @@ "title": "消息设置", "use_serif_font": "使用衬线字体" }, - "mineru": { - "api_key": "MinerU现在提供每日500页的免费额度,您不需要填写密钥。" - }, "miniapps": { "cache_change_notice": "更改将在打开的小程序增减至设定值后生效", "cache_description": "设置同时保持活跃状态的小程序最大数量", @@ -4461,6 +4657,14 @@ } }, "options": { + "anthropic_cache": { + "cache_last_n": "缓存最后 N 条消息", + "cache_last_n_help": "缓存最后的 N 条对话消息(不含系统消息)", + "cache_system": "缓存系统消息", + "cache_system_help": "是否缓存系统提示词", + "token_threshold": "缓存 Token 阈值", + "token_threshold_help": "消息超过此 Token 数才会被缓存,设为 0 禁用缓存" + }, "array_content": { "help": "该提供商是否支持 message 的 content 字段为 array 类型", "label": "支持数组格式的 message content" @@ -4581,6 +4785,9 @@ "title": "删除提供商" }, "dmxapi": { + "platform_enterprise": "ssvip.DMXAPI.com 生产级商用站", + "platform_international": "www.DMXAPI.com 国际站", + "platform_official": "www.DMXAPI.cn 人民币站", "select_platform": "选择平台" }, "docs_check": "查看", @@ -4740,10 +4947,8 @@ "image_provider": "OCR 服务提供商", "paddleocr": { "aistudio_access_token": "星河社区访问令牌", - "aistudio_url_label": "星河社区", "api_url": "API URL", - "serving_doc_url_label": "PaddleOCR 服务化部署文档", - "tip": "您可以参考 PaddleOCR 官方文档部署本地服务,或者在飞桨星河社区部署云服务。对于后一种情况,请填写星河社区访问令牌。" + "api_url_label": "获取访问令牌及API URL" }, "system": { "win": { @@ -4756,6 +4961,12 @@ "title": "OCR 服务" }, "preprocess": { + "paddleocr": { + "aistudio_access_token": "星河社区访问令牌", + "api_url": "API URL", + "api_url_label": "获取访问令牌及API URL", + "paddleocr_url_label": "PaddleOCR 官网" + }, "provider": "文档处理服务商", "provider_placeholder": "选择一个文档处理服务商", "title": "文档处理", @@ -4917,6 +5128,13 @@ "show": "显示托盘图标", "title": "托盘" }, + "use_system_title_bar": { + "confirm": { + "content": "更改标题栏样式需要重启应用才能生效,是否现在重启?", + "title": "需要重启应用" + }, + "title": "使用系统标题栏 (Linux)" + }, "zoom": { "reset": "重置", "title": "缩放" @@ -4994,6 +5212,8 @@ "detected": { "language": "自动检测" }, + "detected_source": "检测到", + "detecting": "检测中...", "empty": "翻译内容为空", "error": { "chat_qwen_mt": "Qwen MT 模型不可在对话中使用,请转至翻译页面", @@ -5046,6 +5266,7 @@ "not_pair": "源语言与设置的语言不同", "same": "源语言和目标语言相同" }, + "language_settings": "语言设置", "menu": { "description": "对当前输入框内容进行翻译" }, @@ -5055,6 +5276,7 @@ "output": { "placeholder": "翻译" }, + "preferred_target": "首选目标", "processing": "翻译中...", "settings": { "autoCopy": "翻译完成后自动复制", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index ec39a08058..3fbe7e00ae 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -271,6 +271,7 @@ }, "toolPermission": { "aria": { + "allowAllRequest": "總是允許此工具", "allowRequest": "允許工具請求", "denyRequest": "拒絕工具請求", "hideDetails": "隱藏工具詳細資訊", @@ -278,6 +279,7 @@ "showDetails": "顯示工具詳細資訊" }, "button": { + "allowAll": "總是允許", "cancel": "取消", "run": "執行" }, @@ -544,6 +546,20 @@ "description": "預設啟用的 MCP 伺服器", "enableFirst": "請先在 MCP 設定中啟用此伺服器", "label": "MCP 伺服器", + "mode": { + "auto": { + "description": "AI 自動發現和使用工具", + "label": "自動" + }, + "disabled": { + "description": "不使用 MCP 工具", + "label": "停用" + }, + "manual": { + "description": "選擇特定的 MCP 伺服器", + "label": "手動" + } + }, "noServersAvailable": "無可用 MCP 伺服器。請在設定中新增伺服器", "title": "MCP 設定" }, @@ -653,6 +669,10 @@ "title": "新增話題" } }, + "alerts": { + "create_session": "建立工作階段", + "select_agent": "選擇一個代理人" + }, "artifacts": { "button": { "download": "下載", @@ -1186,6 +1206,8 @@ "cancel": "取消", "chat": "聊天", "clear": "清除", + "clear_all": "全部清除", + "click_to_replace": "點擊以替換", "close": "關閉", "collapse": "收合", "completed": "已完成", @@ -1220,9 +1242,15 @@ "footnote": "引用內容", "footnotes": "引用", "fullscreen": "已進入全螢幕模式,按 F11 結束", + "generate_random_seed": "產生隨機種子", + "get_embedding_dimension": "取得嵌入維度", "go_to_settings": "前往設定", + "html_preview": "HTML 預覽", "i_know": "我知道了", "ignore": "忽略", + "image_preview": "圖片預覽", + "image_url": "圖片網址", + "image_url_or_upload": "輸入圖片網址或上傳檔案", "inspect": "檢查", "invalid_value": "無效值", "knowledge_base": "知識庫", @@ -1232,6 +1260,7 @@ "models": "模型", "more": "更多", "name": "名稱", + "next_match": "下一場比賽", "no_results": "沒有結果", "none": "無", "off": "關閉", @@ -1243,13 +1272,17 @@ "model": "選擇模型" } }, + "powered_by": "技術提供", "preview": "預覽", + "previous_match": "上一場比賽", "prompt": "提示詞", "provider": "供應商", "reasoning_content": "已深度思考", "refresh": "重新整理", "regenerate": "重新產生", + "remove_image": "移除圖片", "rename": "重新命名", + "required_field": "必填欄位", "reset": "重設", "save": "儲存", "saved": "已儲存", @@ -1272,14 +1305,29 @@ "success": "成功", "swap": "交換", "topics": "話題", + "translate_text": "翻譯文字", "unknown": "未知", "unnamed": "未命名", "unsubscribe": "取消訂閱", "update_success": "更新成功", "upload_files": "上傳檔案", + "upload_image": "上傳圖片檔案", + "uploaded_image": "已上傳圖片", "warning": "警告", "you": "您" }, + "dialog": { + "all_files": "所有檔案", + "html_files": "HTML 檔案", + "open_file": "開啟檔案", + "pdf_files": "PDF 檔案", + "png_image": "PNG 影像", + "save_as_html": "儲存為 HTML", + "save_as_pdf": "儲存為 PDF", + "save_file": "儲存檔案", + "select_folder": "選擇資料夾", + "word_document": "Word 文件" + }, "docs": { "title": "說明文件" }, @@ -2001,10 +2049,65 @@ "cancelled": "已取消", "completed": "已完成", "error": "發生錯誤", + "groupHeader": "{{count}} 次工具呼叫", "invoking": "呼叫中", + "labels": { + "bash": "執行命令", + "edit": "編輯", + "exitPlanMode": "退出計畫模式", + "glob": "檔案匹配", + "grep": "搜尋", + "mcpServerTool": "MCP 伺服器工具", + "multiEdit": "批次編輯", + "notebookEdit": "筆記本編輯", + "readFile": "讀取檔案", + "search": "搜尋", + "skill": "技能", + "task": "任務", + "todoWrite": "待辦寫入", + "tool": "工具", + "webFetch": "網頁擷取", + "webSearch": "網頁搜尋", + "write": "寫入" + }, + "noData": "此工具暫無可用資料", "pending": "等待中", "preview": "預覽", - "raw": "原始碼" + "raw": "原始碼", + "runningCount": "{{count}} 個工具正在運行", + "sections": { + "command": "命令", + "exitCode": "退出碼", + "input": "輸入", + "output": "輸出", + "prompt": "提示", + "searchQuery": "搜尋查詢", + "searchResults": "搜尋結果", + "stderr": "標準錯誤", + "stdout": "標準輸出" + }, + "status": { + "done": "完成", + "error": "錯誤", + "failed": "失敗", + "running": "執行中", + "success": "成功" + }, + "truncated": "輸出已截斷(原始大小:{{size}})", + "units": { + "done_one": "{{count}} 完成", + "done_other": "{{count}} 完成", + "file_one": "{{count}} 個檔案", + "file_other": "{{count}} 個檔案", + "item_one": "{{count}} 個項目", + "item_other": "{{count}} 個項目", + "line_one": "{{count}} 行", + "line_other": "{{count}} 行", + "plan_one": "{{count}} 方案", + "plan_other": "{{count}} 個方案", + "result_one": "{{count}} 個結果", + "result_other": "{{count}} 個結果" + } }, "topic": { "added": "新話題已新增" @@ -2212,14 +2315,18 @@ "drop_markdown_hint": "拖曳 .md 檔案或資料夾到此處匯入", "empty": "暫無筆記", "expand": "展開", + "exportToWord": "匯出為 Word", "export_failed": "匯出至知識庫失敗", "export_knowledge": "匯出筆記至知識庫", "export_success": "成功匯出至知識庫", + "export_to_word_failed": "匯出為 Word 失敗", "folder": "資料夾", "new_folder": "新增資料夾", "new_note": "新增筆記", "no_content_to_copy": "沒有內容可複製", + "no_content_to_export": "沒有內容可匯出", "no_file_selected": "請選擇要上傳的檔案", + "no_note_selected": "請先選擇一個筆記", "no_valid_files": "沒有上傳有效的檔案", "open_folder": "開啟外部資料夾", "open_outside": "從外部開啟", @@ -2425,6 +2532,39 @@ } }, "custom_size": "自訂尺寸", + "dmxapi": { + "generating_tip": "使用官方模型生成,預計等待時間為 2–5 分鐘以獲得最佳結果。請查看 DMXAPI 後端日誌以了解此操作的成本。", + "style": ",風格:", + "style_types": { + "25d_animation": "2.5D動畫", + "3d_cartoon": "3D卡通", + "american_retro": "美式復古", + "baroque": "巴洛克", + "cartoon_illustration": "卡通插圖", + "chinese_gongbi": "中國工筆", + "clay": "黏土", + "cyberpunk": "賽博龐克", + "felt": "氈", + "flat": "平的", + "fresh_anime": "新鮮動漫", + "ghibli": "吉卜力", + "japanese_anime": "日本動畫", + "little_people_book": "小小人書", + "monet_garden": "莫內花園", + "oil_painting": "油畫", + "pixar": "皮克斯", + "pixel_art": "像素藝術", + "poetic_ancient": "詩意的古代", + "psychedelic": "迷幻", + "sketch": "草圖", + "street_art": "街頭藝術", + "texture": "質地", + "ukiyo_e": "浮世繪", + "watercolor": "水彩", + "wood_carving": "木雕", + "yarn_doll": "毛線娃娃" + } + }, "edit": { "image_file": "編輯影像", "magic_prompt_option_tip": "開啟後會自動調整編輯提示詞,以提升效果", @@ -2449,6 +2589,7 @@ "style_type_tip": "產生圖片的風格,僅適用於 V_2 及以上版本", "width": "寬度" }, + "generate_failed": "無法生成圖片", "generated_image": "產生的圖片", "go_to_settings": "前往設定", "guidance_scale": "引導比例", @@ -2459,6 +2600,7 @@ "image_file_required": "請先上傳圖片", "image_file_retry": "請重新上傳圖片", "image_handle_required": "請先上傳圖片。", + "image_mix_failed": "無法混合圖片", "image_placeholder": "無圖片", "image_retry": "重試", "image_size_options": { @@ -2468,6 +2610,7 @@ "inference_steps_tip": "要執行的推理步數。步數越多,品質越高但耗時越長", "input_image": "輸入圖片", "input_parameters": "輸入參數", + "invalid_image_url": "圖片網址格式無效", "learn_more": "了解更多", "magic_prompt_option": "提示詞增強", "mode": { @@ -2489,6 +2632,7 @@ "no_image_generation_model": "暫無可用的圖片產生模型,請先新增模型並設定端點類型為 {{endpoint_type}}", "number_images": "張數", "number_images_tip": "一次產生的圖片數量 (1-4)", + "operation_failed": "操作失敗,請稍後再試", "paint_course": "教學", "per_image": "每張圖片", "per_images": "每張圖片", @@ -2564,6 +2708,26 @@ "resemblance": "相似度", "resemblance_tip": "控制放大結果與原圖的相似程度", "seed_tip": "控制放大結果的隨機性" + }, + "zhipu": { + "custom_size_divisible": "自訂尺寸必須能被 16 整除", + "custom_size_hint": "寬度和高度必須介於 512px 到 2048px 之間,可被 16 整除,且總像素數不得超過 2^21px。", + "custom_size_pixels": "自訂尺寸的總像素數不可超過 2,097,152", + "custom_size_range": "自訂尺寸必須介於 512 像素至 2048 像素之間", + "custom_size_required": "請設定自訂寬度和高度", + "image_sizes": { + "1024x1024_default": "1024x1024(預設)", + "1152x864": "1152x864", + "1344x768": "1344x768", + "1440x720": "1440x720", + "720x1440": "720x1440", + "768x1344": "768x1344", + "864x1152": "864x1152" + }, + "quality_options": { + "hd": "HD", + "standard_default": "標準(預設)" + } } }, "plugins": { @@ -2574,6 +2738,20 @@ "category": "類別", "commands": "指令", "confirm_uninstall": "確定要解除安裝 {{name}} 嗎?", + "content_saved": "外掛內容已成功儲存", + "detail": { + "allowed_tools": "允許的工具", + "author": "作者", + "content": "內容", + "description": "描述", + "file": "檔案", + "installed": "已安裝", + "metadata": "詮釋資料", + "size": "尺寸", + "source": "來源", + "tags": "標籤", + "tools": "工具" + }, "install": "安裝", "install_plugins_from_browser": "瀏覽可用外掛以開始使用", "installing": "安裝中...", @@ -2924,6 +3102,11 @@ "summary": "總結", "translate": "翻譯" }, + "prompt": { + "explain": "請說明以下內容。要求:以{{language}}回覆;不要包含任何對此提示的說明,直接給出回應:", + "refine": "請優化或潤飾以 XML 標籤 <INPUT> 包覆的使用者輸入內容,同時保留原意與完整性。要求:輸出須與使用者輸入使用相同語言;不需包含本提示的任何說明,直接給出回應;勿輸出 XML 標籤,直接輸出優化後的內容:\n\n{{text}}", + "summary": "請直接以{{language}}回覆,無須任何說明。" + }, "translate": { "smart_translate_tips": "智慧翻譯:內容將優先翻譯為目標語言;內容已是目標語言時,將翻譯為備用語言" }, @@ -3101,6 +3284,10 @@ }, "settings": { "about": { + "careers": { + "button": "查看", + "title": "加入我們" + }, "checkUpdate": { "available": "立即更新", "label": "檢查更新" @@ -4013,6 +4200,10 @@ "jsonModeHint": "編輯 MCP 伺服器設定的 JSON 表示。儲存前請確認格式正確", "jsonSaveError": "儲存 JSON 設定失敗", "jsonSaveSuccess": "JSON 設定已儲存", + "lanyun": { + "description": "藍雲科技雲平台MCP服務", + "name": "藍雲科技" + }, "logoUrl": "Logo URL", "logs": "日誌", "longRunning": "長時間運作模式", @@ -4109,6 +4300,14 @@ "getToken": "取得 API 權杖", "getTokenDescription": "從帳戶取得個人 API 權杖", "noServersAvailable": "無可用的 MCP 伺服器", + "providerDescriptions": { + "302ai": "302.AI 平台 MCP 服務", + "bailian": "阿里雲百鍊平台 MCP 服務", + "lanyun": "藍雲科技雲平台 MCP 服務", + "mcprouter": "MCP 路由器平台 MCP 服務", + "modelscope": "ModelScope平台MCP服務", + "tokenflux": "TokenFlux 平台 MCP 服務" + }, "selectProvider": "選擇提供者:", "setToken": "輸入權杖", "success": "同步 MCP 伺服器成功", @@ -4201,9 +4400,6 @@ "title": "訊息設定", "use_serif_font": "使用襯線字型" }, - "mineru": { - "api_key": "Mineru 現在每天提供 500 頁的免費配額,且無需輸入金鑰。" - }, "miniapps": { "cache_change_notice": "變更會在開啟的小程式數量調整至設定值後生效", "cache_description": "設定同時保持活躍狀態的小程式最大數量", @@ -4461,6 +4657,14 @@ } }, "options": { + "anthropic_cache": { + "cache_last_n": "快取最近 N 則訊息", + "cache_last_n_help": "快取最後 N 則對話訊息(排除系統訊息)", + "cache_system": "快取系統訊息", + "cache_system_help": "是否快取系統提示", + "token_threshold": "快取權杖閾值", + "token_threshold_help": "超過此標記數量的訊息將被快取。設為 0 以停用快取。" + }, "array_content": { "help": "該供應商是否支援 message 的 content 欄位為 array 類型", "label": "支援陣列格式的 message content" @@ -4581,6 +4785,9 @@ "title": "刪除提供者" }, "dmxapi": { + "platform_enterprise": "ssvip.DMXAPI.com(企業版)", + "platform_international": "www.DMXAPI.com(國際)", + "platform_official": "www.DMXAPI.cn(人民幣)", "select_platform": "選擇平台" }, "docs_check": "檢查", @@ -4739,11 +4946,9 @@ }, "image_provider": "OCR 服務供應商", "paddleocr": { - "aistudio_access_token": "星河社群存取權杖", - "aistudio_url_label": "星河社群", + "aistudio_access_token": "星河社區訪問令牌", "api_url": "API 網址", - "serving_doc_url_label": "PaddleOCR 服務化部署文件", - "tip": "您可以參考 PaddleOCR 官方文件來部署本機服務,或是在飛槳星河社群部署雲端服務。對於後者,請提供星河社群的存取權杖。" + "api_url_label": "取得訪問令牌及 API 網址" }, "system": { "win": { @@ -4756,6 +4961,12 @@ "title": "OCR 服務" }, "preprocess": { + "paddleocr": { + "aistudio_access_token": "星河社區訪問令牌", + "api_url": "API 網址", + "api_url_label": "取得訪問令牌及 API 網址", + "paddleocr_url_label": "PaddleOCR 官方網站" + }, "provider": "文件處理供應商", "provider_placeholder": "選擇一個文件處理供應商", "title": "文件處理", @@ -4917,6 +5128,13 @@ "show": "顯示系統匣圖示", "title": "系統匣" }, + "use_system_title_bar": { + "confirm": { + "content": "變更標題列樣式需要重新啟動應用程式才能生效。您要立即重新啟動嗎?", + "title": "需要重新啟動" + }, + "title": "使用系統標題列(Linux)" + }, "zoom": { "reset": "重設", "title": "縮放" @@ -4994,6 +5212,8 @@ "detected": { "language": "自動偵測" }, + "detected_source": "偵測到", + "detecting": "偵測中...", "empty": "翻譯內容為空", "error": { "chat_qwen_mt": "Qwen MT 模型無法在對話中使用,請前往翻譯頁面", @@ -5046,6 +5266,7 @@ "not_pair": "來源語言與設定的語言不同", "same": "來源語言和目標語言相同" }, + "language_settings": "語言設定", "menu": { "description": "對目前輸入框內容進行翻譯" }, @@ -5055,6 +5276,7 @@ "output": { "placeholder": "翻譯" }, + "preferred_target": "首選目標", "processing": "翻譯中...", "settings": { "autoCopy": "翻譯完成後自動複製", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index d5445961e5..56bd6a822f 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -271,6 +271,7 @@ }, "toolPermission": { "aria": { + "allowAllRequest": "Dieses Tool immer erlauben", "allowRequest": "Werkzeuganfrage zulassen", "denyRequest": "Werkzeuganfrage ablehnen", "hideDetails": "Werkzeugdetails ausblenden", @@ -278,6 +279,7 @@ "showDetails": "Zeige Werkzeugdetails" }, "button": { + "allowAll": "Immer erlauben", "cancel": "Abbrechen", "run": "Laufen" }, @@ -544,6 +546,20 @@ "description": "Standardmäßig aktivierte MCP-Server", "enableFirst": "Bitte aktivieren Sie diesen Server zuerst in den MCP-Einstellungen", "label": "MCP-Server", + "mode": { + "auto": { + "description": "KI entdeckt und nutzt Werkzeuge automatisch", + "label": "Auto" + }, + "disabled": { + "description": "Keine MCP-Tools", + "label": "Deaktiviert" + }, + "manual": { + "description": "Wählen Sie spezifische MCP-Server", + "label": "Handbuch" + } + }, "noServersAvailable": "Keine MCP-Server verfügbar. Bitte fügen Sie Server in den Einstellungen hinzu", "title": "MCP-Server" }, @@ -653,6 +669,10 @@ "title": "Neues Thema erstellen" } }, + "alerts": { + "create_session": "Erstelle eine Sitzung", + "select_agent": "Wählen Sie einen Agenten" + }, "artifacts": { "button": { "download": "Herunterladen", @@ -1186,6 +1206,8 @@ "cancel": "Abbrechen", "chat": "Chat", "clear": "Löschen", + "clear_all": "Alles löschen", + "click_to_replace": "Klicken zum Ersetzen", "close": "Schließen", "collapse": "Einklappen", "completed": "Abgeschlossen", @@ -1220,9 +1242,15 @@ "footnote": "Zitierte Inhalte", "footnotes": "Zitierte Inhalte", "fullscreen": "Vollbildmodus aktiviert, F11 zum Beenden", + "generate_random_seed": "Zufälligen Seed generieren", + "get_embedding_dimension": "Abruf der Einbettungsdimension", "go_to_settings": "Zu Einstellungen", + "html_preview": "HTML-Vorschau", "i_know": "Verstanden", "ignore": "Ignorieren", + "image_preview": "Bildvorschau", + "image_url": "Bild-URL", + "image_url_or_upload": "Gib Bild-URL ein oder lade Datei hoch", "inspect": "Prüfen", "invalid_value": "Ungültiger Wert", "knowledge_base": "Wissensdatenbank", @@ -1232,6 +1260,7 @@ "models": "Modelle", "more": "Mehr", "name": "Name", + "next_match": "Nächstes Spiel", "no_results": "Keine Ergebnisse", "none": "Keine", "off": "Aus", @@ -1243,13 +1272,17 @@ "model": "Modell auswählen" } }, + "powered_by": "Bereitgestellt von", "preview": "Vorschau", + "previous_match": "Vorheriges Spiel", "prompt": "Prompt", "provider": "Anbieter", "reasoning_content": "Tiefgehend nachgedacht", "refresh": "Aktualisieren", "regenerate": "Neu generieren", + "remove_image": "Bild entfernen", "rename": "Umbenennen", + "required_field": "Pflichtfeld", "reset": "Zurücksetzen", "save": "Speichern", "saved": "Gespeichert", @@ -1272,14 +1305,29 @@ "success": "Erfolgreich", "swap": "Tauschen", "topics": "Themen", + "translate_text": "Text übersetzen", "unknown": "Unbekannt", "unnamed": "Unbenannt", "unsubscribe": "Abmelden", "update_success": "Erfolgreich aktualisiert", "upload_files": "Dateien hochladen", + "upload_image": "Bilddatei hochladen", + "uploaded_image": "Hochgeladenes Bild", "warning": "Warnung", "you": "Sie" }, + "dialog": { + "all_files": "Alle Dateien", + "html_files": "HTML-Dateien", + "open_file": "Datei öffnen", + "pdf_files": "PDF-Dateien", + "png_image": "PNG-Bild", + "save_as_html": "Als HTML speichern", + "save_as_pdf": "Als PDF speichern", + "save_file": "Datei speichern", + "select_folder": "Ordner auswählen", + "word_document": "Word-Dokument" + }, "docs": { "title": "Hilfedokumentation" }, @@ -1297,6 +1345,7 @@ "backup": { "file_format": "Backup-Dateiformat fehlerhaft" }, + "base64DataTruncated": "Base64-Bilddaten abgeschnitten, Größe", "boundary": { "default": { "devtools": "Debug-Panel öffnen", @@ -1377,6 +1426,8 @@ "text": "Text", "toolInput": "Tool-Eingabe", "toolName": "Tool-Name", + "truncated": "Daten wurden gekürzt, Originalgröße", + "truncatedBadge": "Abgeschnitten", "unknown": "Unbekannter Fehler", "usage": "Nutzung", "user_message_not_found": "Ursprüngliche Benutzernachricht nicht gefunden", @@ -1998,10 +2049,65 @@ "cancelled": "Abgebrochen", "completed": "Abgeschlossen", "error": "Fehler aufgetreten", + "groupHeader": "{{count}} Toolaufrufe", "invoking": "Wird aufgerufen", + "labels": { + "bash": "Bash", + "edit": "Bearbeiten", + "exitPlanMode": "ExitPlanModus", + "glob": "Globus", + "grep": "Grep", + "mcpServerTool": "MCP-Server-Tool", + "multiEdit": "MultiEdit", + "notebookEdit": "NotizbuchBearbeiten", + "readFile": "Datei lesen", + "search": "Suche", + "skill": "Fähigkeit", + "task": "Aufgabe", + "todoWrite": "Alles schreiben", + "tool": "Werkzeug", + "webFetch": "Web abrufen", + "webSearch": "Websuche", + "write": "Schreiben" + }, + "noData": "Keine Daten für dieses Tool verfügbar", "pending": "Wartend", "preview": "Vorschau", - "raw": "Roh" + "raw": "Roh", + "runningCount": "{{count}} Werkzeuge laufen", + "sections": { + "command": "Befehl", + "exitCode": "Exit-Code", + "input": "Eingabe", + "output": "Ausgabe", + "prompt": "Aufforderung", + "searchQuery": "Suchbegriff", + "searchResults": "Suchergebnisse", + "stderr": "stderr", + "stdout": "stdout" + }, + "status": { + "done": "Erledigt", + "error": "Fehler", + "failed": "Fehlgeschlagen", + "running": "Laufen", + "success": "Erfolg" + }, + "truncated": "Ausgabe gekürzt (Original: {{size}})", + "units": { + "done_one": "{{count}} Erledigt", + "done_other": "{{count}} Erledigt", + "file_one": "{{count}} Datei", + "file_other": "{{count}} Dateien", + "item_one": "{{count}} Artikel", + "item_other": "{{count}} Artikel", + "line_one": "{{count}} Zeile", + "line_other": "{{count}} Zeilen", + "plan_one": "{{count}} Plan", + "plan_other": "{{count}} Pläne", + "result_one": "{{count}} Ergebnis", + "result_other": "{{count}} Ergebnisse" + } }, "topic": { "added": "Thema erfolgreich hinzugefügt" @@ -2209,14 +2315,18 @@ "drop_markdown_hint": ".md-Datei oder Verzeichnis hierher ziehen zum Importieren", "empty": "Keine Notizen vorhanden", "expand": "Ausklappen", + "exportToWord": "In Word exportieren", "export_failed": "Export in Wissensdatenbank fehlgeschlagen", "export_knowledge": "Notiz in Wissensdatenbank exportieren", "export_success": "Erfolgreich in Wissensdatenbank exportiert", + "export_to_word_failed": "Export nach Word fehlgeschlagen", "folder": "Ordner", "new_folder": "Neuer Ordner", "new_note": "Neue Notiz", "no_content_to_copy": "Kein Inhalt zum Kopieren", + "no_content_to_export": "Kein Inhalt zum Exportieren", "no_file_selected": "Bitte Datei zum Hochladen auswählen", + "no_note_selected": "Bitte wählen Sie zuerst eine Notiz aus", "no_valid_files": "Keine gültigen Dateien hochgeladen", "open_folder": "Externen Ordner öffnen", "open_outside": "Extern öffnen", @@ -2422,6 +2532,39 @@ } }, "custom_size": "Benutzerdefinierte Größe", + "dmxapi": { + "generating_tip": "Generierung mit dem offiziellen Modell, geschätzte Wartezeit beträgt 2–5 Minuten für beste Ergebnisse. Bitte überprüfe die DMXAPI-Backend-Logs für die Kosten dieses Vorgangs.", + "style": ", Stil:", + "style_types": { + "25d_animation": "2.5D-Animation", + "3d_cartoon": "3D-Cartoon", + "american_retro": "Amerikanisches Retro", + "baroque": "Barock", + "cartoon_illustration": "Cartoon-Illustration", + "chinese_gongbi": "Chinesische Gongbi", + "clay": "Ton", + "cyberpunk": "Cyberpunk", + "felt": "Filz", + "flat": "Flach", + "fresh_anime": "Frisches Anime", + "ghibli": "Ghibli", + "japanese_anime": "Japanischer Anime", + "little_people_book": "Kleine-Leute-Buch", + "monet_garden": "Monet-Garten", + "oil_painting": "Ölgemälde", + "pixar": "Pixar", + "pixel_art": "Pixelkunst", + "poetic_ancient": "Poetisch Antik", + "psychedelic": "Psychedelisch", + "sketch": "Skizze", + "street_art": "Street Art", + "texture": "Textur", + "ukiyo_e": "Ukiyo-e", + "watercolor": "Aquarell", + "wood_carving": "Holzschnitzerei", + "yarn_doll": "Garnpuppe" + } + }, "edit": { "image_file": "Zu bearbeitendes Bild", "magic_prompt_option_tip": "Intelligente Optimierung des Bearbeitungs-Prompts", @@ -2446,6 +2589,7 @@ "style_type_tip": "Bildgenerierungsstil, nur für V_2 und höher", "width": "Breite" }, + "generate_failed": "Fehler beim Generieren des Bildes", "generated_image": "Generiertes Bild", "go_to_settings": "Zu Einstellungen", "guidance_scale": "Guidance-Skala", @@ -2456,6 +2600,7 @@ "image_file_required": "Bitte laden Sie zuerst ein Bild hoch", "image_file_retry": "Bitte laden Sie das Bild erneut hoch", "image_handle_required": "Bitte laden Sie zuerst ein Bild hoch", + "image_mix_failed": "Fehler beim Mischen der Bilder", "image_placeholder": "Kein Bild vorhanden", "image_retry": "Wiederholen", "image_size_options": { @@ -2465,6 +2610,7 @@ "inference_steps_tip": "Anzahl der auszuführenden Inference-Schritte. Mehr Schritte bedeuten höhere Qualität, aber längere Dauer", "input_image": "Eingabebild", "input_parameters": "Eingabeparameter", + "invalid_image_url": "Ungültiges Bild-URL-Format", "learn_more": "Mehr erfahren", "magic_prompt_option": "Prompt-Verbesserung", "mode": { @@ -2486,6 +2632,7 @@ "no_image_generation_model": "Kein Bildgenerierungsmodell verfügbar. Bitte fügen Sie ein Modell hinzu und setzen Sie den Endpunkttyp auf {{endpoint_type}}", "number_images": "Generierungsanzahl", "number_images_tip": "Anzahl der Bilder pro Generierung (1-4)", + "operation_failed": "Vorgang fehlgeschlagen, bitte versuchen Sie es später erneut", "paint_course": "Tutorial", "per_image": "Pro Bild", "per_images": "Pro Bild", @@ -2561,6 +2708,26 @@ "resemblance": "Ähnlichkeit", "resemblance_tip": "Ähnlichkeitsgrad des Upscale-Ergebnisses zum Originalbild kontrollieren", "seed_tip": "Kontrolle der Zufälligkeit des Upscale-Ergebnisses" + }, + "zhipu": { + "custom_size_divisible": "Benutzerdefinierte Größe muss durch 16 teilbar sein", + "custom_size_hint": "Breite und Höhe müssen zwischen 512 px und 2048 px liegen, durch 16 teilbar sein, und die Gesamtzahl der Pixel darf 2^21 px nicht überschreiten.", + "custom_size_pixels": "Die Gesamtpixel der benutzerdefinierten Größe dürfen 2.097.152 nicht überschreiten", + "custom_size_range": "Benutzerdefinierte Größe muss zwischen 512px und 2048px liegen", + "custom_size_required": "Bitte benutzerdefinierte Breite und Höhe einstellen", + "image_sizes": { + "1024x1024_default": "1024x1024 (Standard)", + "1152x864": "1152x864", + "1344x768": "1344x768", + "1440x720": "1440x720", + "720x1440": "720x1440", + "768x1344": "768x1344", + "864x1152": "864x1152" + }, + "quality_options": { + "hd": "HD", + "standard_default": "Standard (Standard)" + } } }, "plugins": { @@ -2571,6 +2738,20 @@ "category": "Kategorie", "commands": "Befehle", "confirm_uninstall": "Sind Sie sicher, dass Sie {{name}} deinstallieren möchten?", + "content_saved": "Plugin-Inhalt erfolgreich gespeichert", + "detail": { + "allowed_tools": "Erlaubte Werkzeuge", + "author": "Autor", + "content": "Inhalt", + "description": "Beschreibung", + "file": "Datei", + "installed": "Installiert", + "metadata": "Metadaten", + "size": "Größe", + "source": "Quelle", + "tags": "Tags", + "tools": "Werkzeuge" + }, "install": "Installieren", "install_plugins_from_browser": "Durchsuche verfügbare Plugins, um loszulegen", "installing": "Installiere…", @@ -2921,6 +3102,11 @@ "summary": "Zusammenfassen", "translate": "Übersetzen" }, + "prompt": { + "explain": "Bitte erklären Sie den folgenden Inhalt. Anforderungen: Antworten Sie auf Deutsch; geben Sie keine Erklärung zu dieser Aufforderung, sondern antworten Sie direkt.", + "refine": "Bitte optimieren oder überarbeiten Sie die im XML-Tag <INPUT> eingeschlossene Nutzereingabe, wobei die Bedeutung und Integrität des ursprünglichen Inhalts erhalten bleiben sollen. Anforderungen: Ihre Ausgabe sollte in derselben Sprache wie die Nutzereingabe erfolgen; geben Sie keine Erklärung zu dieser Aufforderung an, sondern liefern Sie direkt die Antwort; geben Sie keine XML-Tags aus, sondern geben Sie den optimierten Inhalt direkt aus:\n\n<INPUT>{{text}}</INPUT>", + "summary": "Bitte fasse den folgenden Inhalt zusammen. Anforderungen: Antworte auf {{language}}; gib keine Erklärung zu dieser Aufforderung, sondern antworte direkt." + }, "translate": { "smart_translate_tips": "Intelligente Übersetzung: Inhalt wird bevorzugt in Zielsprache übersetzt; wenn Inhalt bereits in Zielsprache, Übersetzung in Alternativsprache" }, @@ -3098,6 +3284,10 @@ }, "settings": { "about": { + "careers": { + "button": "Ansicht", + "title": "Karriere" + }, "checkUpdate": { "available": "Jetzt aktualisieren", "label": "Auf Updates prüfen" @@ -4010,6 +4200,10 @@ "jsonModeHint": "JSON-Darstellung der MCP-Server-Konfiguration. Bitte sicherstellen, dass das Format korrekt ist, bevor gespeichert wird", "jsonSaveError": "JSON-Konfiguration speichern fehlgeschlagen", "jsonSaveSuccess": "JSON-Konfiguration erfolgreich gespeichert", + "lanyun": { + "description": "Lanyun Technology Cloud Platform MCP Service", + "name": "Lanyun Technology" + }, "logoUrl": "Logo-URL", "logs": "Protokolle", "longRunning": "Lang laufender Modus", @@ -4106,6 +4300,14 @@ "getToken": "API-Token abrufen", "getTokenDescription": "Persönlichen API-Token aus Ihrem Konto abrufen", "noServersAvailable": "Keine MCP-Server verfügbar", + "providerDescriptions": { + "302ai": "302.AI-Plattform MCP-Dienst", + "bailian": "Alibaba Cloud Bailian Platform MCP-Dienst", + "lanyun": "Lanyun Technology Cloud Platform MCP-Dienst", + "mcprouter": "MCP-Router-Plattform MCP-Service", + "modelscope": "ModelScope-Plattform MCP-Dienst", + "tokenflux": "TokenFlux Platform MCP Service" + }, "selectProvider": "Anbieter auswählen:", "setToken": "Ihren Token eingeben", "success": "MCP-Server erfolgreich synchronisiert", @@ -4198,9 +4400,6 @@ "title": "Nachrichteneinstellungen", "use_serif_font": "Serifenschrift verwenden" }, - "mineru": { - "api_key": "MinerU bietet täglich 500 Seiten kostenlos an, Sie müssen keinen Schlüssel eingeben." - }, "miniapps": { "cache_change_notice": "Änderung wird wirksam wenn Anzahl geöffneter Mini-Apps auf festgelegten Wert angepasst wird", "cache_description": "Maximale Anzahl gleichzeitig aktiver Mini-Apps festlegen", @@ -4458,6 +4657,14 @@ } }, "options": { + "anthropic_cache": { + "cache_last_n": "Letzte N Nachrichten zwischenspeichern", + "cache_last_n_help": "Zwischen die letzten N Gesprächsnachrichten (ohne Systemnachrichten) zwischenspeichern", + "cache_system": "Cache-Systemnachricht", + "cache_system_help": "Ob der System-Prompt zwischengespeichert werden soll", + "token_threshold": "Cache-Token-Schwellenwert", + "token_threshold_help": "Nachrichten, die diese Token-Anzahl überschreiten, werden zwischengespeichert. Auf 0 setzen, um das Caching zu deaktivieren." + }, "array_content": { "help": "Unterstützt Array-Format für message content", "label": "Unterstützt Array-Format für message content" @@ -4578,6 +4785,9 @@ "title": "Anbieter löschen" }, "dmxapi": { + "platform_enterprise": "ssvip.DMXAPI.com (Unternehmen)", + "platform_international": "www.DMXAPI.com (International)", + "platform_official": "www.DMXAPI.cn (CNY)", "select_platform": "Plattform auswählen" }, "docs_check": "Anzeigen", @@ -4737,10 +4947,8 @@ "image_provider": "OCR-Anbieter", "paddleocr": { "aistudio_access_token": "StarRiver Community Access Token", - "aistudio_url_label": "StarRiver Community", "api_url": "API-URL", - "serving_doc_url_label": "PaddleOCR-Dokumentation für die Bereitstellung", - "tip": "Sie können die offizielle PaddleOCR-Dokumentation als Referenz für die lokale Bereitstellung verwenden oder in der StarRiver-Community Cloud-Dienste bereitstellen. Für letztere Option geben Sie bitte den StarRiver-Community-Zugriffstoken ein." + "api_url_label": "Zugriffstoken und API-URL abrufen" }, "system": { "win": { @@ -4753,6 +4961,12 @@ "title": "OCR-Dienst" }, "preprocess": { + "paddleocr": { + "aistudio_access_token": "Zugriffstoken der AI Studio Community", + "api_url": "API-URL", + "api_url_label": "Zugriffstoken und API-URL abrufen", + "paddleocr_url_label": "Offizielle Website von PaddleOCR" + }, "provider": "Dokumentverarbeitungsanbieter", "provider_placeholder": "Einen Dokumentverarbeitungsanbieter auswählen", "title": "Dokumentverarbeitung", @@ -4914,6 +5128,13 @@ "show": "Tray-Symbol anzeigen", "title": "Tray" }, + "use_system_title_bar": { + "confirm": { + "content": "Das Ändern des Titelleistenstils erfordert einen Neustart der App, damit die Änderung wirksam wird. Möchten Sie jetzt neu starten?", + "title": "Neustart erforderlich" + }, + "title": "System-Titelleiste verwenden (Linux)" + }, "zoom": { "reset": "Zurücksetzen", "title": "Zoom" @@ -4991,6 +5212,8 @@ "detected": { "language": "Automatische Erkennung" }, + "detected_source": "Erfasst", + "detecting": "Erkenne...", "empty": "Übersetzungsinhalt leer", "error": { "chat_qwen_mt": "Qwen MT-Modell kann nicht in der Konversation verwendet werden, bitte gehen Sie zur Übersetzungsseite", @@ -5043,6 +5266,7 @@ "not_pair": "Quellsprache unterscheidet sich von eingestellter Sprache", "same": "Quell- und Zielsprache sind identisch" }, + "language_settings": "Spracheinstellungen", "menu": { "description": "Inhalt des aktuellen Eingabefelds übersetzen" }, @@ -5052,6 +5276,7 @@ "output": { "placeholder": "Übersetzen" }, + "preferred_target": "Bevorzugtes Ziel", "processing": "Wird übersetzt...", "settings": { "autoCopy": "Nach Übersetzung automatisch kopieren", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index d0cfe0579c..a22c3744d0 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -271,6 +271,7 @@ }, "toolPermission": { "aria": { + "allowAllRequest": "Να επιτρέπεται πάντα αυτό το εργαλείο", "allowRequest": "Επίτρεψη αίτησης εργαλείου", "denyRequest": "Απόρριψη αιτήματος εργαλείου", "hideDetails": "Απόκρυψη λεπτομερειών εργαλείου", @@ -278,6 +279,7 @@ "showDetails": "Εμφάνιση λεπτομερειών εργαλείου" }, "button": { + "allowAll": "Να Επιτρέπεται Πάντα", "cancel": "Ακύρωση", "run": "Τρέξε" }, @@ -544,6 +546,20 @@ "description": "Διακομιστής MCP που είναι ενεργοποιημένος εξ ορισμού", "enableFirst": "Πρώτα ενεργοποιήστε αυτόν τον διακομιστή στις ρυθμίσεις MCP", "label": "Διακομιστής MCP", + "mode": { + "auto": { + "description": "Η τεχνητή νοημοσύνη ανακαλύπτει και χρησιμοποιεί εργαλεία αυτόματα", + "label": "Αυτόματο" + }, + "disabled": { + "description": "Χωρίς εργαλεία MCP", + "label": "Ανάπηρος" + }, + "manual": { + "description": "Επιλέξτε συγκεκριμένους διακομιστές MCP", + "label": "Εγχειρίδιο" + } + }, "noServersAvailable": "Δεν υπάρχουν διαθέσιμοι διακομιστές MCP. Προσθέστε ένα διακομιστή στις ρυθμίσεις", "title": "Ρυθμίσεις MCP" }, @@ -653,6 +669,10 @@ "title": "Δημιουργία νέου θέματος" } }, + "alerts": { + "create_session": "Δημιουργία συνεδρίας", + "select_agent": "Επιλέξτε έναν πράκτορα" + }, "artifacts": { "button": { "download": "Λήψη", @@ -1186,6 +1206,8 @@ "cancel": "Άκυρο", "chat": "Συζήτηση", "clear": "Καθαρισμός", + "clear_all": "Εκκαθάριση Όλων", + "click_to_replace": "Κάντε κλικ για αντικατάσταση", "close": "Κλείσιμο", "collapse": "Σύμπτυξη", "completed": "Ολοκληρώθηκε", @@ -1220,9 +1242,15 @@ "footnote": "Παραπομπή", "footnotes": "Παραπομπές", "fullscreen": "Εισήχθη σε πλήρη οθόνη, πατήστε F11 για να έξω", + "generate_random_seed": "Δημιουργία τυχαίου σπόρου", + "get_embedding_dimension": "Λήψη διάστασης ενσωμάτωσης", "go_to_settings": "Πηγαίνετε στις ρυθμίσεις", + "html_preview": "Προεπισκόπηση HTML", "i_know": "Το έχω καταλάβει", "ignore": "Αγνόησε", + "image_preview": "Προεπισκόπηση εικόνας", + "image_url": "Διεύθυνση URL εικόνας", + "image_url_or_upload": "Εισαγάγετε διεύθυνση URL εικόνας ή ανεβάστε αρχείο", "inspect": "Επιθεώρηση", "invalid_value": "Μη έγκυρη τιμή", "knowledge_base": "Βάση Γνώσεων", @@ -1232,6 +1260,7 @@ "models": "Μοντέλα", "more": "Περισσότερα", "name": "Όνομα", + "next_match": "Επόμενος αγώνας", "no_results": "Δεν βρέθηκαν αποτελέσματα", "none": "Χωρίς", "off": "Κλειστό", @@ -1243,13 +1272,17 @@ "model": "Επιλέξτε μοντέλο" } }, + "powered_by": "Με την υποστήριξη της", "preview": "Προεπισκόπηση", + "previous_match": "Προηγούμενος αγώνας", "prompt": "Ενδεικτικός ρήματος", "provider": "Παρέχων", "reasoning_content": "Έχει σκεφτεί πολύ καλά", "refresh": "Ανανέωση", "regenerate": "Ξαναπαραγωγή", + "remove_image": "Αφαίρεση εικόνας", "rename": "Μετονομασία", + "required_field": "Υποχρεωτικό πεδίο", "reset": "Επαναφορά", "save": "Αποθήκευση", "saved": "Αποθηκεύτηκε", @@ -1272,14 +1305,29 @@ "success": "Επιτυχία", "swap": "Εναλλαγή", "topics": "Θέματα", + "translate_text": "Μετάφραση κειμένου", "unknown": "Άγνωστο", "unnamed": "Χωρίς όνομα", "unsubscribe": "Απεγγραφή", "update_success": "Επιτυχής ενημέρωση", "upload_files": "Ανέβασμα αρχείου", + "upload_image": "Μεταφόρτωση αρχείου εικόνας", + "uploaded_image": "Μεταφορτωμένη εικόνα", "warning": "Προσοχή", "you": "Εσείς" }, + "dialog": { + "all_files": "Όλα τα Αρχεία", + "html_files": "Αρχεία HTML", + "open_file": "Άνοιγμα Αρχείου", + "pdf_files": "Αρχεία PDF", + "png_image": "Εικόνα PNG", + "save_as_html": "Αποθήκευση ως HTML", + "save_as_pdf": "Αποθήκευση ως PDF", + "save_file": "Αποθήκευση Αρχείου", + "select_folder": "Επιλέξτε φάκελο", + "word_document": "Έγγραφο Word" + }, "docs": { "title": "Βοήθεια" }, @@ -1297,6 +1345,7 @@ "backup": { "file_format": "Λάθος μορφή αρχείου που επιστρέφεται" }, + "base64DataTruncated": "Τα δεδομένα εικόνας Base64 έχουν περικοπεί, μέγεθος", "boundary": { "default": { "devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης", @@ -1377,6 +1426,8 @@ "text": "κείμενο", "toolInput": "εισαγωγή εργαλείου", "toolName": "Όνομα εργαλείου", + "truncated": "Δεδομένα περικόπηκαν, αρχικό μέγεθος", + "truncatedBadge": "Αποκομμένο", "unknown": "Άγνωστο σφάλμα", "usage": "δοσολογία", "user_message_not_found": "Αδυναμία εύρεσης της αρχικής μηνύματος χρήστη", @@ -1998,10 +2049,65 @@ "cancelled": "Ακυρώθηκε", "completed": "Ολοκληρώθηκε", "error": "Προέκυψε σφάλμα", + "groupHeader": "{{count}} κλήσεις εργαλείων", "invoking": "κλήση σε εξέλιξη", + "labels": { + "bash": "Μπας", + "edit": "Επεξεργασία", + "exitPlanMode": "ΛειτουργίαΈξοδουΣχεδίου", + "glob": "Σφαίρα", + "grep": "Grep", + "mcpServerTool": "Εργαλείο Διακομιστή MCP", + "multiEdit": "ΠολλαπλήΕπεξεργασία", + "notebookEdit": "ΣημειωματάριοΕπεξεργασία", + "readFile": "Διάβασε Αρχείο", + "search": "Αναζήτηση", + "skill": "Δεξιότητα", + "task": "Εργασία", + "todoWrite": "Να γράψεις", + "tool": "Εργαλείο", + "webFetch": "Ανάκτηση Ιστού", + "webSearch": "Αναζήτηση στον Ιστό", + "write": "Γράψε" + }, + "noData": "Δεν υπάρχουν διαθέσιμα δεδομένα για αυτό το εργαλείο", "pending": "Εκκρεμεί", "preview": "Προεπισκόπηση", - "raw": "Ακατέργαστο" + "raw": "Ακατέργαστο", + "runningCount": "{{count}} εργαλεία εκτελούνται", + "sections": { + "command": "Εντολή", + "exitCode": "Κωδικός Εξόδου", + "input": "Εισαγωγή", + "output": "Έξοδος", + "prompt": "Προτροπή", + "searchQuery": "ερώτημα αναζήτησης", + "searchResults": "Αποτελέσματα Αναζήτησης", + "stderr": "stderr", + "stdout": "stdout" + }, + "status": { + "done": "Έγινε", + "error": "Σφάλμα", + "failed": "Απέτυχε", + "running": "Τρέξιμο", + "success": "Επιτυχία" + }, + "truncated": "Η έξοδος περικόπηκε (πρωτότυπο: {{size}})", + "units": { + "done_one": "{{count}} Ολοκληρώθηκε", + "done_other": "{{count}} Ολοκληρώθηκαν", + "file_one": "{{count}} αρχείο", + "file_other": "{{count}} αρχεία", + "item_one": "{{count}} αντικείμενο", + "item_other": "{{count}} αντικείμενα", + "line_one": "{{count}} γραμμή", + "line_other": "{{count}} γραμμές", + "plan_one": "{{count}} σχέδιο", + "plan_other": "{{count}} σχέδια", + "result_one": "{{count}} αποτέλεσμα", + "result_other": "{{count}} αποτελέσματα" + } }, "topic": { "added": "Η θεματική προστέθηκε επιτυχώς" @@ -2209,14 +2315,18 @@ "drop_markdown_hint": "Σύρετε και αποθέστε αρχεία ή φακέλους .md εδώ για εισαγωγή", "empty": "δεν υπάρχει σημείωση για τώρα", "expand": "να ανοίξει", + "exportToWord": "Εξαγωγή σε Word", "export_failed": "Εξαγωγή στη βάση γνώσης απέτυχε", "export_knowledge": "εξαγωγή σημειώσεων στη βάση γνώσης", "export_success": "Επιτυχής εξαγωγή στην βάση γνώσης", + "export_to_word_failed": "Αποτυχία εξαγωγής στο Word", "folder": "φάκελος", "new_folder": "Νέος φάκελος", "new_note": "Δημιουργία νέας σημείωσης", "no_content_to_copy": "Δεν υπάρχει περιεχόμενο προς αντιγραφή", + "no_content_to_export": "Κανένα περιεχόμενο προς εξαγωγή", "no_file_selected": "Επιλέξτε το αρχείο για μεταφόρτωση", + "no_note_selected": "Παρακαλώ επιλέξτε πρώτα μια σημείωση", "no_valid_files": "Δεν ανέβηκε έγκυρο αρχείο", "open_folder": "Άνοιγμα εξωτερικού φακέλου", "open_outside": "Από το εξωτερικό", @@ -2422,6 +2532,39 @@ } }, "custom_size": "Προσαρμοσμένο μέγεθος", + "dmxapi": { + "generating_tip": "Η δημιουργία γίνεται με το επίσημο μοντέλο, ο εκτιμώμενος χρόνος αναμονής είναι 2-5 λεπτά για τα καλύτερα αποτελέσματα. Ελέγξτε τα αρχεία καταγραφής του διακομιστή DMXAPI για το κόστος αυτής της λειτουργίας.", + "style": ", Στυλ:", + "style_types": { + "25d_animation": "Κινούμενα Σχέδια 2.5D", + "3d_cartoon": "3D Καρτούν", + "american_retro": "Αμερικανικό Ρετρό", + "baroque": "Μπαρόκ", + "cartoon_illustration": "Κινούμενη εικονογράφηση", + "chinese_gongbi": "Κινέζικο Γκόνγκμπι", + "clay": "Πηλός", + "cyberpunk": "Κυβερνοπάνκ", + "felt": "Ύφασμα", + "flat": "Flat", + "fresh_anime": "Φρέσκο Anime", + "ghibli": "Γκίμπλι", + "japanese_anime": "Ιαπωνικό Άνιμε", + "little_people_book": "Βιβλίο για τους Μικρούς Ανθρώπους", + "monet_garden": "Κήπος Μονέ", + "oil_painting": "Ζωγραφική με λάδι", + "pixar": "Pixar", + "pixel_art": "Τέχνη των Πίξελ", + "poetic_ancient": "Ποιητικό Αρχαίο", + "psychedelic": "Ψυχεδελικό", + "sketch": "Σχέδιο", + "street_art": "Τέχνη του Δρόμου", + "texture": "Υφή", + "ukiyo_e": "Ουκιγιό-ε", + "watercolor": "Νερομπογιά", + "wood_carving": "Ξυλογλυπτική", + "yarn_doll": "Κούκλα από κλωστή" + } + }, "edit": { "image_file": "Επεξεργασμένη εικόνα", "magic_prompt_option_tip": "Έξυπνη βελτιστοποίηση της πρότασης επεξεργασίας", @@ -2446,6 +2589,7 @@ "style_type_tip": "Στυλ δημιουργίας εικόνας, ισχύει μόνο για την έκδοση V_2 και μεταγενέστερες", "width": "Πλάτος" }, + "generate_failed": "Αποτυχία δημιουργίας εικόνας", "generated_image": "Δημιουργία εικόνας", "go_to_settings": "Πηγαίνετε στις ρυθμίσεις", "guidance_scale": "Κλίμακα προσαρμογής", @@ -2456,6 +2600,7 @@ "image_file_required": "Παρακαλώ ανεβάστε πρώτα μια εικόνα", "image_file_retry": "Παρακαλώ ανεβάστε ξανά την εικόνα", "image_handle_required": "Παρακαλώ ανεβάστε πρώτα μια εικόνα", + "image_mix_failed": "Αποτυχία ανάμειξης εικόνων", "image_placeholder": "Δεν υπάρχει εικόνα για τη στιγμή", "image_retry": "Δοκιμάστε ξανά", "image_size_options": { @@ -2465,6 +2610,7 @@ "inference_steps_tip": "Το πλήθος των βημάτων επεξεργασίας που πρέπει να εκτελεστούν. Περισσότερα βήματα = χαμηλότερη ποιότητα και μεγαλύτερος χρόνος εκτέλεσης", "input_image": "Εικόνα εισόδου", "input_parameters": "Παράμετροι εισόδου", + "invalid_image_url": "Μη έγκυρη μορφή διεύθυνσης URL εικόνας", "learn_more": "Μάθετε περισσότερα", "magic_prompt_option": "Ενίσχυση προτροπής", "mode": { @@ -2486,6 +2632,7 @@ "no_image_generation_model": "Δεν υπάρχει διαθέσιμο μοντέλο δημιουργίας εικόνας. Προσθέστε ένα μοντέλο και ορίστε τον τύπο τερματικού σημείου ως {{endpoint_type}}", "number_images": "Ποσότητα δημιουργιών", "number_images_tip": "Ποσότητα εικόνων που θα δημιουργηθούν μια φορά (1-4)", + "operation_failed": "Η λειτουργία απέτυχε, παρακαλώ δοκιμάστε ξανά αργότερα", "paint_course": "Εκπαίδευση", "per_image": "Ανά εικόνα", "per_images": "Ανά εικόνα", @@ -2561,6 +2708,26 @@ "resemblance": "Ομοιότητα", "resemblance_tip": "Ρυθμίστε την ομοιότητα της μεγεθυσμένης εικόνας με την αρχική", "seed_tip": "Ελέγχει την τυχαιότητα του αποτελέσματος μεγέθυνσης" + }, + "zhipu": { + "custom_size_divisible": "Το προσαρμοσμένο μέγεθος πρέπει να διαιρείται με το 16", + "custom_size_hint": "Το πλάτος και το ύψος πρέπει να είναι μεταξύ 512px-2048px, να διαιρούνται με το 16 και τα συνολικά pixel να μην υπερβαίνουν τα 2^21px.", + "custom_size_pixels": "Το συνολικό πλήθος εικονοστοιχείων προσαρμοσμένου μεγέθους δεν μπορεί να υπερβαίνει τα 2.097.152", + "custom_size_range": "Το προσαρμοσμένο μέγεθος πρέπει να είναι μεταξύ 512px-2048px", + "custom_size_required": "Παρακαλώ ορίστε προσαρμοσμένο πλάτος και ύψος", + "image_sizes": { + "1024x1024_default": "1024x1024 (Προεπιλογή)", + "1152x864": "1152x864", + "1344x768": "1344x768", + "1440x720": "1440x720", + "720x1440": "720x1440", + "768x1344": "768x1344", + "864x1152": "864x1152" + }, + "quality_options": { + "hd": "HD", + "standard_default": "Πρότυπο (Προεπιλογή)" + } } }, "plugins": { @@ -2571,6 +2738,20 @@ "category": "Κατηγορία", "commands": "εντολή", "confirm_uninstall": "Είστε σίγουροι ότι θέλετε να απεγκαταστήσετε το {{name}};", + "content_saved": "Το περιεχόμενο του πρόσθετου αποθηκεύτηκε με επιτυχία", + "detail": { + "allowed_tools": "Επιτρεπόμενα Εργαλεία", + "author": "Συγγραφέας", + "content": "Περιεχόμενο", + "description": "Περιγραφή", + "file": "Αρχείο", + "installed": "Εγκατεστημένο", + "metadata": "Μεταδεδομένα", + "size": "Μέγεθος", + "source": "Πηγή", + "tags": "Ετικέτες", + "tools": "Εργαλεία" + }, "install": "εγκατάσταση", "install_plugins_from_browser": "Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε", "installing": "Εγκατάσταση...", @@ -2921,6 +3102,11 @@ "summary": "Σύνοψη", "translate": "Μετάφραση" }, + "prompt": { + "explain": "Παρακαλώ εξήγησε το ακόλουθο περιεχόμενο.", + "refine": "Παρακαλώ βελτιστοποιήστε ή γυαλίστε το περιεχόμενο που δίνει ο χρήστης, περιτριγυρισμένο με την ετικέτα XML <INPUT>, διατηρώντας το νόημα και την ακεραιότητα του αρχικού περιεχομένου. Απαιτήσεις: Η έξοδός σας πρέπει να είναι στην ίδια γλώσσα με την είσοδο του χρήστη· μην συμπεριλάβετε καμία εξήγηση αυτής της οδηγίας, δώστε απλώς την απάντηση· μην εκτυπώνετε ετικέτες XML, εκτυπώστε απευθείας το βελτιστοποιημένο περιεχόμενο.", + "summary": "Παρακαλώ συνοψίστε το ακόλουθο περιεχόμενο. Απαιτήσεις: Απαντήστε στα ελληνικά· μη συμπεριλάβετε καμία εξήγηση αυτού του προτρόπου, δώστε απευθείας την απάντηση:" + }, "translate": { "smart_translate_tips": "Έξυπνη μετάφραση: το περιεχόμενο θα μεταφραστεί προτεραιακά στη στόχος γλώσσα· αν το περιεχόμενο είναι ήδη στη στόχος γλώσσα, θα μεταφραστεί στην εναλλακτική γλώσσα" }, @@ -3098,6 +3284,10 @@ }, "settings": { "about": { + "careers": { + "button": "Προβολή", + "title": "Καριέρα" + }, "checkUpdate": { "available": "Άμεση ενημέρωση", "label": "Έλεγχος ενημερώσεων" @@ -4010,6 +4200,10 @@ "jsonModeHint": "Επεξεργασία της εκφώνησης JSON του διακομιστή MCP. Παρακαλώ εξασφαλίστε ότι το μορφοποίηση είναι σωστό πριν από την αποθήκευση.", "jsonSaveError": "Αποτυχία αποθήκευσης της διαμορφωτικής ρύθμισης JSON", "jsonSaveSuccess": "Η διαμορφωτική ρύθμιση JSON αποθηκεύτηκε επιτυχώς", + "lanyun": { + "description": "Υπηρεσία MCP Πλατφόρμας Cloud Lanyun Technology", + "name": "Lanyun Technology" + }, "logoUrl": "URL Λογότυπου", "logs": "Αρχεία καταγραφής", "longRunning": "Μακροχρόνια λειτουργία", @@ -4106,6 +4300,14 @@ "getToken": "Λήψη API Τοκεν", "getTokenDescription": "Λήψη ενός προσωπικού API τοκεν από τον λογαριασμό σας", "noServersAvailable": "Δεν υπάρχουν διαθέσιμοι MCP διακομιστές", + "providerDescriptions": { + "302ai": "302.Υπηρεσία MCP Πλατφόρμας AI", + "bailian": "Υπηρεσία MCP Πλατφόρμας Bailian της Alibaba Cloud", + "lanyun": "Υπηρεσία MCP της Πλατφόρμας Cloud Τεχνολογίας Lanyun", + "mcprouter": "Πλατφόρμα Δρομολογητή MCP Υπηρεσία MCP", + "modelscope": "Υπηρεσία MCP Πλατφόρμας ModelScope", + "tokenflux": "Υπηρεσία MCP της Πλατφόρμας TokenFlux" + }, "selectProvider": "Επιλέξτε Πάροχο:", "setToken": "Εισαγάγετε το τοκεν σας", "success": "Ο συγχρονισμός MCP διακομιστή ολοκληρώθηκε επιτυχώς", @@ -4198,9 +4400,6 @@ "title": "Ρυθμίσεις μηνυμάτων", "use_serif_font": "Χρήση μορφής Serif" }, - "mineru": { - "api_key": "Το MinerU παρέχει δωρεάν χρήση 500 σελίδων ημερησίως, δεν χρειάζεται να συμπληρώσετε κλειδί." - }, "miniapps": { "cache_change_notice": "Η αλλαγή θα τεθεί σε ισχύ αφού το πλήθος των ανοιχτών μικροπρογραμμάτων φτάσει τη ρυθμισμένη τιμή", "cache_description": "Ορίστε τον μέγιστο αριθμό των μικροπρογραμμάτων που μπορούν να είναι ενεργά ταυτόχρονα", @@ -4458,6 +4657,14 @@ } }, "options": { + "anthropic_cache": { + "cache_last_n": "Κρύψτε τα τελευταία N μηνύματα", + "cache_last_n_help": "Αποθηκεύστε στην κρυφή μνήμη τα τελευταία N μηνύματα της συνομιλίας (εξαιρουμένων των μηνυμάτων συστήματος)", + "cache_system": "Μήνυμα Συστήματος Κρυφής Μνήμης", + "cache_system_help": "Εάν θα αποθηκευτεί προσωρινά το σύστημα εντολών", + "token_threshold": "Κατώφλι Διακριτικού Κρυφής Μνήμης", + "token_threshold_help": "Μηνύματα που υπερβαίνουν αυτό το όριο token θα αποθηκεύονται στην cache. Ορίστε το σε 0 για να απενεργοποιήσετε την προσωρινή αποθήκευση." + }, "array_content": { "help": "Εάν ο πάροχος υποστηρίζει το πεδίο περιεχομένου του μηνύματος ως τύπο πίνακα", "label": "Υποστήριξη για περιεχόμενο μηνύματος με μορφή πίνακα" @@ -4578,6 +4785,9 @@ "title": "Διαγραφή παρόχου" }, "dmxapi": { + "platform_enterprise": "ssvip.DMXAPI.com (Enterprise)", + "platform_international": "www.DMXAPI.com (Διεθνές)", + "platform_official": "www.DMXAPI.cn (CNY)", "select_platform": "Επιλέξτε πλατφόρμα" }, "docs_check": "Άνοιγμα", @@ -4737,10 +4947,8 @@ "image_provider": "Πάροχοι υπηρεσιών OCR", "paddleocr": { "aistudio_access_token": "Διακριτικό πρόσβασης της κοινότητας AI Studio", - "aistudio_url_label": "Κοινότητα AI Studio", "api_url": "Διεύθυνση URL API", - "serving_doc_url_label": "Τεκμηρίωση PaddleOCR Serving", - "tip": "Μπορείτε να ανατρέξετε στην επίσημη τεκμηρίωση του PaddleOCR για να αναπτύξετε μια τοπική υπηρεσία, ή να αναπτύξετε μια υπηρεσία στο cloud στην Κοινότητα PaddlePaddle AI Studio. Στη δεύτερη περίπτωση, παρακαλώ παρέχετε το διακριτικό πρόσβασης (access token) της Κοινότητας AI Studio." + "api_url_label": "Λήψη Διακριτικού Πρόσβασης και URL του API" }, "system": { "win": { @@ -4753,6 +4961,12 @@ "title": "Υπηρεσία OCR" }, "preprocess": { + "paddleocr": { + "aistudio_access_token": "Δείκτης πρόσβασης της κοινότητας AI Studio", + "api_url": "Διεύθυνση URL του API", + "api_url_label": "Λήψη Διακριτικού Πρόσβασης και URL API", + "paddleocr_url_label": "Επίσημη ιστοσελίδα του PaddleOCR" + }, "provider": "πάροχος υπηρεσιών προεπεξεργασίας εγγράφων", "provider_placeholder": "Επιλέξτε έναν πάροχο υπηρεσιών προεπεξεργασίας εγγράφων", "title": "Προεπεξεργασία εγγράφων", @@ -4914,6 +5128,13 @@ "show": "Εμφάνιση εικονιδίου συνδρομής", "title": "Συνδρομή" }, + "use_system_title_bar": { + "confirm": { + "content": "Η αλλαγή του στυλ της γραμμής τίτλου απαιτεί επανεκκίνηση της εφαρμογής για να τεθεί σε ισχύ. Θέλετε να γίνει επανεκκίνηση τώρα;", + "title": "Απαιτείται επανεκκίνηση" + }, + "title": "Χρήση γραμμής τίτλου συστήματος (Linux)" + }, "zoom": { "reset": "Επαναφορά", "title": "Κλίμακα" @@ -4991,6 +5212,8 @@ "detected": { "language": "Αυτόματη ανίχνευση" }, + "detected_source": "Εντοπίστηκε", + "detecting": "Ανίχνευση...", "empty": "Το μεταφρασμένο κείμενο είναι κενό", "error": { "chat_qwen_mt": "Τα μοντέλα Qwen MT δεν είναι διαθέσιμα για χρήση σε διαλόγους, παρακαλώ μεταβείτε στη σελίδα μετάφρασης", @@ -5043,6 +5266,7 @@ "not_pair": "Η γλώσσα πηγής διαφέρει από την οριζόμενη γλώσσα", "same": "Η γλώσσα πηγής και η γλώσσα προορισμού είναι ίδιες" }, + "language_settings": "Ρυθμίσεις Γλώσσας", "menu": { "description": "Μεταφράστε το περιεχόμενο του τρέχοντος πεδίου εισαγωγής" }, @@ -5052,6 +5276,7 @@ "output": { "placeholder": "Μετάφραση" }, + "preferred_target": "Προτιμώμενος Στόχος", "processing": "Μεταφράζεται...", "settings": { "autoCopy": "Μετά τη μετάφραση, αντιγράφεται αυτόματα", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 039a289e7a..2b4c558126 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -271,6 +271,7 @@ }, "toolPermission": { "aria": { + "allowAllRequest": "Permitir siempre esta herramienta", "allowRequest": "Permitir solicitud de herramienta", "denyRequest": "Denegar solicitud de herramienta", "hideDetails": "Ocultar detalles de la herramienta", @@ -278,6 +279,7 @@ "showDetails": "Mostrar detalles de la herramienta" }, "button": { + "allowAll": "Permitir siempre", "cancel": "Cancelar", "run": "Correr" }, @@ -544,6 +546,20 @@ "description": "Servidor MCP habilitado por defecto", "enableFirst": "Habilite este servidor en la configuración de MCP primero", "label": "Servidor MCP", + "mode": { + "auto": { + "description": "La IA descubre y utiliza herramientas automáticamente", + "label": "Auto" + }, + "disabled": { + "description": "Sin herramientas MCP", + "label": "Discapacitado" + }, + "manual": { + "description": "Seleccionar servidores MCP específicos", + "label": "Manual" + } + }, "noServersAvailable": "No hay servidores MCP disponibles. Agregue un servidor en la configuración", "title": "Configuración MCP" }, @@ -653,6 +669,10 @@ "title": "Crear nuevo tema" } }, + "alerts": { + "create_session": "Crear una sesión", + "select_agent": "Selecciona un agente" + }, "artifacts": { "button": { "download": "Descargar", @@ -1186,6 +1206,8 @@ "cancel": "Cancelar", "chat": "Chat", "clear": "Limpiar", + "clear_all": "Borrar todo", + "click_to_replace": "Haz clic para reemplazar", "close": "Cerrar", "collapse": "Colapsar", "completed": "Completado", @@ -1220,9 +1242,15 @@ "footnote": "Nota al pie", "footnotes": "Notas al pie", "fullscreen": "En modo pantalla completa, presione F11 para salir", + "generate_random_seed": "Generar semilla aleatoria", + "get_embedding_dimension": "Obtener dimensión de incrustación", "go_to_settings": "Ir a la configuración", + "html_preview": "Vista previa de HTML", "i_know": "Entendido", "ignore": "Ignorar", + "image_preview": "Vista previa de imagen", + "image_url": "URL de la imagen", + "image_url_or_upload": "Introduce la URL de la imagen o sube un archivo", "inspect": "Inspeccionar", "invalid_value": "Valor inválido", "knowledge_base": "Base de conocimiento", @@ -1232,6 +1260,7 @@ "models": "Modelos", "more": "Más", "name": "Nombre", + "next_match": "Próximo partido", "no_results": "Sin resultados", "none": "无", "off": "Apagado", @@ -1243,13 +1272,17 @@ "model": "Seleccionar modelo" } }, + "powered_by": "Impulsado por", "preview": "Vista previa", + "previous_match": "Partido anterior", "prompt": "Prompt", "provider": "Proveedor", "reasoning_content": "Pensamiento profundo", "refresh": "Actualizar", "regenerate": "Regenerar", + "remove_image": "Quitar imagen", "rename": "Renombrar", + "required_field": "Campo obligatorio", "reset": "Restablecer", "save": "Guardar", "saved": "Guardado", @@ -1272,14 +1305,29 @@ "success": "Éxito", "swap": "Intercambiar", "topics": "Temas", + "translate_text": "Traducir texto", "unknown": "Desconocido", "unnamed": "Sin nombre", "unsubscribe": "Cancelar suscripción", "update_success": "Actualización exitosa", "upload_files": "Subir archivo", + "upload_image": "Subir archivo de imagen", + "uploaded_image": "Imagen cargada", "warning": "Advertencia", "you": "Usuario" }, + "dialog": { + "all_files": "Todos los archivos", + "html_files": "Archivos HTML", + "open_file": "Abrir archivo", + "pdf_files": "Archivos PDF", + "png_image": "Imagen PNG", + "save_as_html": "Guardar como HTML", + "save_as_pdf": "Guardar como PDF", + "save_file": "Guardar archivo", + "select_folder": "Seleccionar carpeta", + "word_document": "Documento de Word" + }, "docs": { "title": "Documentación de Ayuda" }, @@ -1297,6 +1345,7 @@ "backup": { "file_format": "Formato de archivo de copia de seguridad incorrecto" }, + "base64DataTruncated": "Datos de imagen Base64 truncados, tamaño", "boundary": { "default": { "devtools": "Abrir el panel de depuración", @@ -1377,6 +1426,8 @@ "text": "Texto", "toolInput": "Herramienta de entrada", "toolName": "Nombre de la herramienta", + "truncated": "Datos truncados, tamaño original", + "truncatedBadge": "Truncado", "unknown": "Error desconocido", "usage": "Cantidad de uso", "user_message_not_found": "No se pudo encontrar el mensaje original del usuario", @@ -1998,10 +2049,65 @@ "cancelled": "Cancelado", "completed": "Completado", "error": "Se ha producido un error", + "groupHeader": "{{count}} llamadas a herramientas", "invoking": "En llamada", + "labels": { + "bash": "Bash", + "edit": "Editar", + "exitPlanMode": "ModoPlanDeSalida", + "glob": "Globo", + "grep": "Grep", + "mcpServerTool": "Herramienta del Servidor MCP", + "multiEdit": "MultiEdit", + "notebookEdit": "CuadernoEditar", + "readFile": "Leer archivo", + "search": "Buscar", + "skill": "Habilidad", + "task": "Tarea", + "todoWrite": "Todo Escribir", + "tool": "Herramienta", + "webFetch": "Obtención Web", + "webSearch": "Búsqueda en la web", + "write": "Escribir" + }, + "noData": "No hay datos disponibles para esta herramienta.", "pending": "Pendiente", "preview": "Vista previa", - "raw": "Crudo" + "raw": "Crudo", + "runningCount": "{{count}} herramientas en ejecución", + "sections": { + "command": "Comando", + "exitCode": "Código de Salida", + "input": "Entrada", + "output": "Salida", + "prompt": "Indicación", + "searchQuery": "Consulta de búsqueda", + "searchResults": "Resultados de búsqueda", + "stderr": "stderr", + "stdout": "stdout" + }, + "status": { + "done": "Hecho", + "error": "Error", + "failed": "Fallido", + "running": "Corriendo", + "success": "Éxito" + }, + "truncated": "Salida truncada (original: {{size}})", + "units": { + "done_one": "{{count}} Hecho", + "done_other": "{{count}} Hecho", + "file_one": "{{count}} archivo", + "file_other": "{{count}} archivos", + "item_one": "{{count}} artículo", + "item_other": "{{count}} artículos", + "line_one": "{{count}} línea", + "line_other": "{{count}} líneas", + "plan_one": "{{count}} plan", + "plan_other": "{{count}} planes", + "result_one": "{{count}} resultado", + "result_other": "{{count}} resultados" + } }, "topic": { "added": "Tema agregado con éxito" @@ -2209,14 +2315,18 @@ "drop_markdown_hint": "Arrastre y suelte archivos o carpetas de .md aquí para importar", "empty": "Sin notas por el momento", "expand": "expandir", + "exportToWord": "Exportar a Word", "export_failed": "Exportación a la base de conocimientos fallida", "export_knowledge": "exportar notas a la base de conocimientos", "export_success": "Exportado con éxito a la base de conocimientos", + "export_to_word_failed": "Error al exportar a Word", "folder": "carpeta", "new_folder": "Nueva carpeta", "new_note": "Crear nota nueva", "no_content_to_copy": "No hay contenido para copiar", + "no_content_to_export": "Sin contenido para exportar", "no_file_selected": "Por favor, seleccione el archivo a subir", + "no_note_selected": "Por favor, selecciona primero una nota.", "no_valid_files": "No se ha cargado un archivo válido", "open_folder": "abrir carpeta externa", "open_outside": "Abrir desde el exterior", @@ -2422,6 +2532,39 @@ } }, "custom_size": "Tamaño personalizado", + "dmxapi": { + "generating_tip": "Generando con el modelo oficial, el tiempo de espera estimado es de 2-5 minutos para obtener los mejores resultados. Por favor, verifica los registros del backend de DMXAPI para conocer el costo de esta operación.", + "style": ", Estilo:", + "style_types": { + "25d_animation": "Animación 2.5D", + "3d_cartoon": "Caricatura 3D", + "american_retro": "Retro Americano", + "baroque": "Barroco", + "cartoon_illustration": "Ilustración de caricatura", + "chinese_gongbi": "Gongbi chino", + "clay": "Arcilla", + "cyberpunk": "Ciberpunk", + "felt": "Fieltro", + "flat": "Plano", + "fresh_anime": "Anime Fresco", + "ghibli": "Ghibli", + "japanese_anime": "Anime japonés", + "little_people_book": "Libro de Little People", + "monet_garden": "Jardín Monet", + "oil_painting": "Pintura al óleo", + "pixar": "Pixar", + "pixel_art": "Arte de píxeles", + "poetic_ancient": "Poético Antiguo", + "psychedelic": "Psicodélico", + "sketch": "Boceto", + "street_art": "Arte urbano", + "texture": "Textura", + "ukiyo_e": "Ukiyo-e", + "watercolor": "Acuarela", + "wood_carving": "Talla en madera", + "yarn_doll": "Muñeca de hilo" + } + }, "edit": { "image_file": "Imagen editada", "magic_prompt_option_tip": "Optimización inteligente de las palabras clave de edición", @@ -2446,6 +2589,7 @@ "style_type_tip": "Estilo de generación de imágenes, solo aplicable para la versión V_2 y posteriores", "width": "Ancho" }, + "generate_failed": "Error al generar la imagen", "generated_image": "Generar imagen", "go_to_settings": "Ir a configuración", "guidance_scale": "Escala de guía", @@ -2456,6 +2600,7 @@ "image_file_required": "Por favor, carga una imagen primero", "image_file_retry": "Vuelve a cargar la imagen", "image_handle_required": "Por favor, suba primero una imagen", + "image_mix_failed": "Error al mezclar imágenes", "image_placeholder": "No hay imágenes por ahora", "image_retry": "Reintentar", "image_size_options": { @@ -2465,6 +2610,7 @@ "inference_steps_tip": "Número de pasos de inferencia a realizar. Cuantos más pasos, mejor la calidad pero más tiempo tarda", "input_image": "Imagen de entrada", "input_parameters": "Parámetros de entrada", + "invalid_image_url": "Formato de URL de imagen inválido", "learn_more": "Más información", "magic_prompt_option": "Mejora de indicación", "mode": { @@ -2486,6 +2632,7 @@ "no_image_generation_model": "No hay modelos disponibles para generación de imágenes. Por favor, agregue un modelo y configure el tipo de punto final como {{endpoint_type}}", "number_images": "Cantidad de imágenes generadas", "number_images_tip": "Número de imágenes generadas por vez (1-4)", + "operation_failed": "Operación fallida, por favor inténtelo de nuevo más tarde", "paint_course": "Tutorial", "per_image": "Por imagen", "per_images": "Por imagen", @@ -2561,6 +2708,26 @@ "resemblance": "Similitud", "resemblance_tip": "Controla el nivel de similitud entre el resultado ampliado y la imagen original", "seed_tip": "Controla la aleatoriedad del resultado de la ampliación" + }, + "zhipu": { + "custom_size_divisible": "El tamaño personalizado debe ser divisible por 16", + "custom_size_hint": "El ancho y la altura deben estar entre 512px y 2048px, ser divisibles por 16 y el total de píxeles no puede exceder 2^21px.", + "custom_size_pixels": "El total de píxeles de tamaño personalizado no puede exceder 2,097,152", + "custom_size_range": "El tamaño personalizado debe estar entre 512px y 2048px", + "custom_size_required": "Por favor, establece el ancho y la altura personalizados.", + "image_sizes": { + "1024x1024_default": "1024x1024 (Predeterminado)", + "1152x864": "1152x864", + "1344x768": "1344x768", + "1440x720": "1440x720", + "720x1440": "720x1440", + "768x1344": "768x1344", + "864x1152": "864x1152" + }, + "quality_options": { + "hd": "HD", + "standard_default": "Estándar (Predeterminado)" + } } }, "plugins": { @@ -2571,6 +2738,20 @@ "category": "Categoría", "commands": "comando", "confirm_uninstall": "¿Estás seguro de que quieres desinstalar {{name}}?", + "content_saved": "Contenido del plugin guardado exitosamente", + "detail": { + "allowed_tools": "Herramientas Permitidas", + "author": "Autor", + "content": "Contenido", + "description": "Descripción", + "file": "Archivo", + "installed": "Instalado", + "metadata": "Metadatos", + "size": "Tamaño", + "source": "Fuente", + "tags": "Etiquetas", + "tools": "Herramientas" + }, "install": "instalación", "install_plugins_from_browser": "Explora los complementos disponibles para empezar a usar", "installing": "Instalando...", @@ -2921,6 +3102,11 @@ "summary": "Resumen", "translate": "Traducir" }, + "prompt": { + "explain": "Por favor, explica el siguiente contenido. Requisitos: Responde en {{language}}; no incluyas ninguna explicación de esta instrucción, solo da la respuesta directamente:", + "refine": "Por favor, optimiza o pulsa el contenido del usuario envuelto en la etiqueta XML <INPUT>, manteniendo el significado y la integridad del contenido original. Requisitos: tu salida debe estar en el mismo idioma que la entrada del usuario; no incluyas ninguna explicación de este mensaje, solo da la respuesta directamente; no imprimas etiquetas XML, imprime el contenido optimizado directamente:", + "summary": "Por favor, resume el siguiente contenido. Requisitos: responder en {{language}}; no incluyas ninguna explicación de este mensaje, solo da la respuesta directamente:" + }, "translate": { "smart_translate_tips": "Traducción inteligente: el contenido se traducirá primero al idioma de destino; si el contenido ya está en el idioma de destino, se traducirá al idioma alternativo" }, @@ -3098,6 +3284,10 @@ }, "settings": { "about": { + "careers": { + "button": "Vista", + "title": "Carreras" + }, "checkUpdate": { "available": "Actualizar ahora", "label": "Comprobar actualizaciones" @@ -4010,6 +4200,10 @@ "jsonModeHint": "Edite la representación JSON de la configuración del servidor MCP. Asegúrese de que el formato sea correcto antes de guardar.", "jsonSaveError": "Fallo al guardar la configuración JSON", "jsonSaveSuccess": "Configuración JSON guardada exitosamente", + "lanyun": { + "description": "Plataforma Cloud de Tecnología Lanyun Servicio MCP", + "name": "Lanyun Technology" + }, "logoUrl": "URL del logotipo", "logs": "Registros", "longRunning": "Modo de ejecución prolongada", @@ -4106,6 +4300,14 @@ "getToken": "Obtener token de API", "getTokenDescription": "Obtener un token de API personal desde su cuenta", "noServersAvailable": "No hay servidores MCP disponibles", + "providerDescriptions": { + "302ai": "302.Servicio MCP de la Plataforma AI", + "bailian": "Servicio MCP de la Plataforma Bailian de Alibaba Cloud", + "lanyun": "Plataforma en la nube de tecnología Lanyun Servicio MCP", + "mcprouter": "Plataforma de Enrutamiento MCP Servicio MCP", + "modelscope": "Servicio MCP de la Plataforma ModelScope", + "tokenflux": "Servicio MCP de la Plataforma TokenFlux" + }, "selectProvider": "Seleccionar proveedor:", "setToken": "Ingrese su token", "success": "Servidor MCP sincronizado correctamente", @@ -4198,9 +4400,6 @@ "title": "Configuración de mensajes", "use_serif_font": "Usar fuente serif" }, - "mineru": { - "api_key": "MinerU ahora ofrece un cupo gratuito de 500 páginas diarias, no es necesario que ingrese una clave." - }, "miniapps": { "cache_change_notice": "Los cambios surtirán efecto cuando el número de miniaplicaciones abiertas aumente o disminuya hasta alcanzar el valor configurado", "cache_description": "Establece el número máximo de miniaplicaciones que pueden permanecer activas simultáneamente", @@ -4458,6 +4657,14 @@ } }, "options": { + "anthropic_cache": { + "cache_last_n": "Caché de los últimos N mensajes", + "cache_last_n_help": "Almacenar en caché los últimos N mensajes de la conversación (excluyendo los mensajes del sistema)", + "cache_system": "Mensaje del Sistema de Caché", + "cache_system_help": "Si se debe almacenar en caché el mensaje del sistema", + "token_threshold": "Umbral de Token de Caché", + "token_threshold_help": "Los mensajes que superen este recuento de tokens se almacenarán en caché. Establecer en 0 para desactivar el almacenamiento en caché." + }, "array_content": { "help": "¿Admite el proveedor que el campo content del mensaje sea de tipo array?", "label": "Contenido del mensaje compatible con formato de matriz" @@ -4578,6 +4785,9 @@ "title": "Eliminar proveedor" }, "dmxapi": { + "platform_enterprise": "ssvip.DMXAPI.com (Empresa)", + "platform_international": "www.DMXAPI.com (Internacional)", + "platform_official": "www.DMXAPI.cn (CNY)", "select_platform": "Seleccionar Plataforma" }, "docs_check": "Ver", @@ -4737,10 +4947,8 @@ "image_provider": "Proveedor de servicios OCR", "paddleocr": { "aistudio_access_token": "Token de acceso de la comunidad de AI Studio", - "aistudio_url_label": "Comunidad de AI Studio", "api_url": "URL de la API", - "serving_doc_url_label": "Documentación de PaddleOCR Serving", - "tip": "Puede consultar la documentación oficial de PaddleOCR para implementar un servicio local, o implementar un servicio en la nube en la Comunidad de PaddlePaddle AI Studio. En este último caso, proporcione el token de acceso de la Comunidad de AI Studio." + "api_url_label": "Obtener el token de acceso y la URL de la API" }, "system": { "win": { @@ -4753,6 +4961,12 @@ "title": "Servicio OCR" }, "preprocess": { + "paddleocr": { + "aistudio_access_token": "Token de acceso de la Comunidad AI Studio", + "api_url": "URL de la API", + "api_url_label": "Obtener el token de acceso y la URL de la API", + "paddleocr_url_label": "Sitio web oficial de PaddleOCR" + }, "provider": "Proveedor de servicios de preprocesamiento de documentos", "provider_placeholder": "Seleccionar un proveedor de servicios de preprocesamiento de documentos", "title": "Preprocesamiento de documentos", @@ -4914,6 +5128,13 @@ "show": "Mostrar bandera del sistema", "title": "Bandera" }, + "use_system_title_bar": { + "confirm": { + "content": "Cambiar el estilo de la barra de título requiere reiniciar la aplicación para que surta efecto. ¿Desea reiniciar ahora?", + "title": "Reinicio requerido" + }, + "title": "Usar la barra de título del sistema (Linux)" + }, "zoom": { "reset": "Restablecer", "title": "Escala" @@ -4991,6 +5212,8 @@ "detected": { "language": "Detección automática" }, + "detected_source": "Detectado", + "detecting": "Detectando...", "empty": "El contenido de traducción está vacío", "error": { "chat_qwen_mt": "El modelo Qwen MT no está disponible para uso en conversaciones, por favor vaya a la página de traducción.", @@ -5043,6 +5266,7 @@ "not_pair": "El idioma de origen es diferente al idioma configurado", "same": "El idioma de origen y el idioma de destino son iguales" }, + "language_settings": "Configuración de idioma", "menu": { "description": "Traducir el contenido del campo de entrada actual" }, @@ -5052,6 +5276,7 @@ "output": { "placeholder": "Traducción" }, + "preferred_target": "Objetivo Preferido", "processing": "Traduciendo...", "settings": { "autoCopy": "Copiar automáticamente después de completar la traducción", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 352678c4ad..e7869b71fc 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -271,6 +271,7 @@ }, "toolPermission": { "aria": { + "allowAllRequest": "Toujours autoriser cet outil", "allowRequest": "Autoriser la demande d'outil", "denyRequest": "Refuser la demande d'outil", "hideDetails": "Masquer les détails de l'outil", @@ -278,6 +279,7 @@ "showDetails": "Afficher les détails de l'outil" }, "button": { + "allowAll": "Toujours autoriser", "cancel": "Annuler", "run": "Courir" }, @@ -544,6 +546,20 @@ "description": "Serveur MCP activé par défaut", "enableFirst": "Veuillez d'abord activer ce serveur dans les paramètres MCP", "label": "Serveur MCP", + "mode": { + "auto": { + "description": "L'IA découvre et utilise des outils automatiquement", + "label": "Auto" + }, + "disabled": { + "description": "Aucun outil MCP", + "label": "Désactivé" + }, + "manual": { + "description": "Sélectionner des serveurs MCP spécifiques", + "label": "Manuel" + } + }, "noServersAvailable": "Aucun serveur MCP disponible. Veuillez ajouter un serveur dans les paramètres", "title": "Paramètres MCP" }, @@ -653,6 +669,10 @@ "title": "Nouveau sujet" } }, + "alerts": { + "create_session": "Créer une session", + "select_agent": "Sélectionnez un agent" + }, "artifacts": { "button": { "download": "Télécharger", @@ -1186,6 +1206,8 @@ "cancel": "Annuler", "chat": "Chat", "clear": "Effacer", + "clear_all": "Tout effacer", + "click_to_replace": "Cliquer pour remplacer", "close": "Fermer", "collapse": "Réduire", "completed": "Terminé", @@ -1220,9 +1242,15 @@ "footnote": "Note de bas de page", "footnotes": "Notes de bas de page", "fullscreen": "Mode plein écran, appuyez sur F11 pour quitter", + "generate_random_seed": "Générer une graine aléatoire", + "get_embedding_dimension": "Obtenir la dimension d'intégration", "go_to_settings": "Aller aux paramètres", + "html_preview": "Aperçu HTML", "i_know": "J'ai compris", "ignore": "Ignorer", + "image_preview": "Aperçu de l'image", + "image_url": "URL de l'image", + "image_url_or_upload": "Entrez l'URL de l'image ou téléchargez un fichier", "inspect": "Vérifier", "invalid_value": "valeur invalide", "knowledge_base": "Base de connaissances", @@ -1232,6 +1260,7 @@ "models": "Modèles", "more": "Plus", "name": "Nom", + "next_match": "Prochain match", "no_results": "Aucun résultat", "none": "Aucun", "off": "Désactivé", @@ -1243,13 +1272,17 @@ "model": "Choisir le modèle" } }, + "powered_by": "Propulsé par", "preview": "Aperçu", + "previous_match": "Match précédent", "prompt": "Prompt", "provider": "Fournisseur", "reasoning_content": "Réflexion approfondie", "refresh": "Actualiser", "regenerate": "Regénérer", + "remove_image": "Supprimer l'image", "rename": "Renommer", + "required_field": "Champ obligatoire", "reset": "Réinitialiser", "save": "Enregistrer", "saved": "enregistré", @@ -1272,14 +1305,29 @@ "success": "Succès", "swap": "Échanger", "topics": "Sujets", + "translate_text": "Traduire le texte", "unknown": "Inconnu", "unnamed": "Sans nom", "unsubscribe": "Se désabonner", "update_success": "Mise à jour réussie", "upload_files": "Uploader des fichiers", + "upload_image": "Télécharger le fichier image", + "uploaded_image": "Image téléchargée", "warning": "Avertissement", "you": "Vous" }, + "dialog": { + "all_files": "Tous les fichiers", + "html_files": "Fichiers HTML", + "open_file": "Ouvrir le fichier", + "pdf_files": "Fichiers PDF", + "png_image": "Image PNG", + "save_as_html": "Enregistrer au format HTML", + "save_as_pdf": "Enregistrer au format PDF", + "save_file": "Enregistrer le fichier", + "select_folder": "Sélectionner un dossier", + "word_document": "Document Word" + }, "docs": { "title": "Documentation d'aide" }, @@ -1297,6 +1345,7 @@ "backup": { "file_format": "Le format du fichier de sauvegarde est incorrect" }, + "base64DataTruncated": "Données d'image Base64 tronquées, taille", "boundary": { "default": { "devtools": "Ouvrir le panneau de débogage", @@ -1377,6 +1426,8 @@ "text": "texte", "toolInput": "entrée de l'outil", "toolName": "Nom de l'outil", + "truncated": "Données tronquées, taille d'origine", + "truncatedBadge": "Tronqué", "unknown": "Неизвестная ошибка", "usage": "Quantité", "user_message_not_found": "Impossible de trouver le message d'utilisateur original", @@ -1998,10 +2049,65 @@ "cancelled": "Annulé", "completed": "Terminé", "error": "Une erreur s'est produite", + "groupHeader": "{{count}} appels d'outil", "invoking": "En cours d'exécution", + "labels": { + "bash": "Bash", + "edit": "Modifier", + "exitPlanMode": "ModePlanSortie", + "glob": "Globe", + "grep": "Grep", + "mcpServerTool": "Outil du serveur MCP", + "multiEdit": "MultiEdit", + "notebookEdit": "ÉditionCahier", + "readFile": "Lire le fichier", + "search": "Rechercher", + "skill": "Compétence", + "task": "Tâche", + "todoWrite": "À faire Écrire", + "tool": "Outil", + "webFetch": "Récupération Web", + "webSearch": "Recherche Web", + "write": "Écrire" + }, + "noData": "Aucune donnée disponible pour cet outil", "pending": "En attente", "preview": "Aperçu", - "raw": "Brut" + "raw": "Brut", + "runningCount": "{{count}} outils en cours d'exécution", + "sections": { + "command": "Commande", + "exitCode": "Code de sortie", + "input": "Entrée", + "output": "Sortie", + "prompt": "Invite", + "searchQuery": "Requête de recherche", + "searchResults": "Résultats de recherche", + "stderr": "stderr", + "stdout": "sortie standard" + }, + "status": { + "done": "Fait", + "error": "Erreur", + "failed": "Échoué", + "running": "Courir", + "success": "Succès" + }, + "truncated": "Sortie tronquée (original : {{size}})", + "units": { + "done_one": "{{count}} Terminé", + "done_other": "{{count}} Terminé", + "file_one": "{{count}} fichier", + "file_other": "{{count}} fichiers", + "item_one": "{{count}} article", + "item_other": "{{count}} articles", + "line_one": "{{count}} ligne", + "line_other": "{{count}} lignes", + "plan_one": "{{count}} plan", + "plan_other": "{{count}} plans", + "result_one": "{{count}} résultat", + "result_other": "{{count}} résultats" + } }, "topic": { "added": "Thème ajouté avec succès" @@ -2209,14 +2315,18 @@ "drop_markdown_hint": "Déposez ici des fichiers ou dossiers .md pour les importer", "empty": "Aucune note pour le moment", "expand": "développer", + "exportToWord": "Exporter vers Word", "export_failed": "Échec de l'exportation vers la base de connaissances", "export_knowledge": "exporter la note vers la base de connaissances", "export_success": "Exporté avec succès vers la base de connaissances", + "export_to_word_failed": "Échec de l'exportation vers Word", "folder": "dossier", "new_folder": "Nouveau dossier", "new_note": "Nouvelle note", "no_content_to_copy": "Aucun contenu à copier", + "no_content_to_export": "Aucun contenu à exporter", "no_file_selected": "Veuillez sélectionner le fichier à télécharger", + "no_note_selected": "Veuillez d'abord sélectionner une note", "no_valid_files": "Aucun fichier valide n’a été téléversé", "open_folder": "ouvrir le dossier externe", "open_outside": "Ouvrir depuis l'extérieur", @@ -2422,6 +2532,39 @@ } }, "custom_size": "Dimensions personnalisées", + "dmxapi": { + "generating_tip": "Génération avec le modèle officiel, le temps d'attente estimé est de 2 à 5 minutes pour les meilleurs résultats. Veuillez consulter les journaux du backend DMXAPI pour le coût de cette opération.", + "style": ", Style :", + "style_types": { + "25d_animation": "Animation 2.5D", + "3d_cartoon": "Cartoon 3D", + "american_retro": "Rétro américain", + "baroque": "Baroque", + "cartoon_illustration": "Illustration de bande dessinée", + "chinese_gongbi": "Gongbi chinois", + "clay": "Argile", + "cyberpunk": "Cyberpunk", + "felt": "Feutre", + "flat": "Plat", + "fresh_anime": "Anime Frais", + "ghibli": "Ghibli", + "japanese_anime": "Animé japonais", + "little_people_book": "Petits Livres pour Petites Gens", + "monet_garden": "Jardin Monet", + "oil_painting": "Peinture à l'huile", + "pixar": "Pixar", + "pixel_art": "Art pixel", + "poetic_ancient": "Poétique antique", + "psychedelic": "Psychédélique", + "sketch": "Croquis", + "street_art": "Art de rue", + "texture": "Texture", + "ukiyo_e": "Ukiyo-e", + "watercolor": "Aquarelle", + "wood_carving": "Sculpture sur bois", + "yarn_doll": "Poupée en laine" + } + }, "edit": { "image_file": "Image éditée", "magic_prompt_option_tip": "Optimisation intelligente du mot-clé d'édition", @@ -2446,6 +2589,7 @@ "style_type_tip": "Стиль генерации изображения, применим к версии V_2 и выше", "width": "Largeur" }, + "generate_failed": "Échec de la génération de l'image", "generated_image": "Image générée", "go_to_settings": "Aller aux paramètres", "guidance_scale": "Échelle de guidance", @@ -2456,6 +2600,7 @@ "image_file_required": "Veuillez d'abord télécharger une image", "image_file_retry": "Veuillez réuploader l'image", "image_handle_required": "Veuillez d'abord télécharger une image", + "image_mix_failed": "Échec du mélange des images", "image_placeholder": "Aucune image pour le moment", "image_retry": "Réessayer", "image_size_options": { @@ -2465,6 +2610,7 @@ "inference_steps_tip": "Nombre d'étapes d'inférence à effectuer. Plus il y a d'étapes, meilleure est la qualité mais plus c'est long", "input_image": "Image d'entrée", "input_parameters": "Paramètres d'entrée", + "invalid_image_url": "Format d'URL d'image invalide", "learn_more": "En savoir plus", "magic_prompt_option": "Amélioration du prompt", "mode": { @@ -2486,6 +2632,7 @@ "no_image_generation_model": "Aucun modèle de génération d'image disponible pour le moment. Veuillez ajouter un modèle et définir le type de point de terminaison sur {{endpoint_type}}", "number_images": "Nombre d'images générées", "number_images_tip": "Le nombre d'images générées en une seule fois (1-4)", + "operation_failed": "L'opération a échoué, veuillez réessayer plus tard", "paint_course": "Tutoriel", "per_image": "Par image", "per_images": "Par image", @@ -2561,6 +2708,26 @@ "resemblance": "Similarité", "resemblance_tip": "Contrôle le niveau de similarité entre le résultat agrandi et l'image originale", "seed_tip": "Contrôle la randomisation du résultat d'agrandissement" + }, + "zhipu": { + "custom_size_divisible": "La taille personnalisée doit être divisible par 16", + "custom_size_hint": "La largeur et la hauteur doivent être comprises entre 512 px et 2048 px, divisibles par 16, et le nombre total de pixels ne peut pas dépasser 2^21 px.", + "custom_size_pixels": "Le nombre total de pixels d'une taille personnalisée ne peut pas dépasser 2 097 152", + "custom_size_range": "La taille personnalisée doit être comprise entre 512 px et 2048 px", + "custom_size_required": "Veuillez définir une largeur et une hauteur personnalisées", + "image_sizes": { + "1024x1024_default": "1024x1024 (Par défaut)", + "1152x864": "1152x864", + "1344x768": "1344x768", + "1440x720": "1440x720", + "720x1440": "720x1440", + "768x1344": "768x1344", + "864x1152": "864x1152" + }, + "quality_options": { + "hd": "HD", + "standard_default": "Standard (Par défaut)" + } } }, "plugins": { @@ -2571,6 +2738,20 @@ "category": "Catégorie", "commands": "commande", "confirm_uninstall": "Êtes-vous sûr de vouloir désinstaller {{name}} ?", + "content_saved": "Contenu du plugin enregistré avec succès", + "detail": { + "allowed_tools": "Outils autorisés", + "author": "Auteur", + "content": "Contenu", + "description": "Description", + "file": "Fichier", + "installed": "Installé", + "metadata": "Métadonnées", + "size": "Taille", + "source": "Source", + "tags": "Étiquettes", + "tools": "Outils" + }, "install": "Installation", "install_plugins_from_browser": "Parcourir les plugins disponibles pour commencer", "installing": "Installation en cours...", @@ -2921,6 +3102,11 @@ "summary": "Résumé", "translate": "Traduire" }, + "prompt": { + "explain": "Veuillez expliquer le contenu suivant. Exigences : répondre en {{language}} ; n’incluez aucune explication de cette consigne, fournissez simplement la réponse directement :", + "refine": "Veuillez optimiser ou peaufiner le contenu saisi par l’utilisateur inclus dans la balise XML <INPUT>, tout en conservant le sens et l’intégrité du contenu original. Exigences : votre réponse doit être dans la même langue que la saisie utilisateur ; n’incluez aucune explication de cette consigne, fournissez directement la réponse ; ne produisez pas de balises XML, affichez le contenu optimisé directement.", + "summary": "Je ne peux pas résumer le contenu, car aucun texte à résumer n’a été fourni après « [to be translated] »." + }, "translate": { "smart_translate_tips": "Traduction intelligente : le contenu sera d'abord traduit dans la langue cible ; si le contenu est déjà dans la langue cible, il sera traduit dans la langue secondaire" }, @@ -3098,6 +3284,10 @@ }, "settings": { "about": { + "careers": { + "button": "Vue", + "title": "Carrières" + }, "checkUpdate": { "available": "Mettre à jour maintenant", "label": "Vérifier les mises à jour" @@ -4010,6 +4200,10 @@ "jsonModeHint": "Modifier la représentation JSON de la configuration des serveurs MCP. Assurez-vous que le format est correct avant de sauvegarder.", "jsonSaveError": "Échec de la sauvegarde de la configuration JSON", "jsonSaveSuccess": "Configuration JSON sauvegardée", + "lanyun": { + "description": "Plateforme Cloud Lanyun Technology Service MCP", + "name": "Lanyun Technology" + }, "logoUrl": "Адрес логотипа", "logs": "Journaux", "longRunning": "Mode d'exécution prolongée", @@ -4106,6 +4300,14 @@ "getToken": "Получить API-токен", "getTokenDescription": "Получите персональный API-токен из вашей учетной записи", "noServersAvailable": "Нет доступных MCP-серверов", + "providerDescriptions": { + "302ai": "302.Service MCP de la Plateforme AI", + "bailian": "Service MCP de la plateforme Bailian d'Alibaba Cloud", + "lanyun": "Plateforme Cloud Lanyun Technology Service MCP", + "mcprouter": "Plateforme de routeur MCP Service MCP", + "modelscope": "Service MCP de la plateforme ModelScope", + "tokenflux": "Service MCP de la plateforme TokenFlux" + }, "selectProvider": "Выберите провайдера:", "setToken": "Введите ваш токен", "success": "MCP-сервер успешно синхронизирован", @@ -4198,9 +4400,6 @@ "title": "Paramètres des messages", "use_serif_font": "Utiliser une police serif" }, - "mineru": { - "api_key": "MinerU propose désormais un quota gratuit de 500 pages par jour, vous n'avez donc pas besoin de saisir de clé." - }, "miniapps": { "cache_change_notice": "Les modifications prendront effet après l'ajout ou la suppression d'applications ouvertes jusqu'à atteindre la valeur définie", "cache_description": "Définir le nombre maximum d'applications pouvant rester actives simultanément", @@ -4458,6 +4657,14 @@ } }, "options": { + "anthropic_cache": { + "cache_last_n": "Mettre en cache les N derniers messages", + "cache_last_n_help": "Mettre en cache les N derniers messages de conversation (à l’exclusion des messages système)", + "cache_system": "Message du système de cache", + "cache_system_help": "S'il faut mettre en cache l'invite système", + "token_threshold": "Seuil de jeton de cache", + "token_threshold_help": "Les messages dépassant ce nombre de jetons seront mis en cache. Mettre à 0 pour désactiver la mise en cache." + }, "array_content": { "help": "Ce fournisseur prend-il en charge le champ content du message sous forme de tableau ?", "label": "Prise en charge du format de tableau pour le contenu du message" @@ -4578,6 +4785,9 @@ "title": "Supprimer le fournisseur" }, "dmxapi": { + "platform_enterprise": "ssvip.DMXAPI.com (Entreprise)", + "platform_international": "www.DMXAPI.com (International)", + "platform_official": "www.DMXAPI.cn (CNY)", "select_platform": "Sélectionner la plateforme" }, "docs_check": "Voir", @@ -4737,10 +4947,8 @@ "image_provider": "Fournisseur de service OCR", "paddleocr": { "aistudio_access_token": "Jeton d’accès de la communauté AI Studio", - "aistudio_url_label": "Communauté AI Studio", "api_url": "URL de l’API", - "serving_doc_url_label": "Documentation de PaddleOCR Serving", - "tip": "Vous pouvez consulter la documentation officielle de PaddleOCR pour déployer un service local, ou déployer un service cloud sur la Communauté PaddlePaddle AI Studio. Dans ce dernier cas, veuillez fournir le jeton d’accès de la Communauté AI Studio." + "api_url_label": "Obtenir le jeton d’accès et l’URL de l’API" }, "system": { "win": { @@ -4753,6 +4961,12 @@ "title": "Service OCR" }, "preprocess": { + "paddleocr": { + "aistudio_access_token": "Jeton d’accès à la communauté AI Studio", + "api_url": "URL de l’API", + "api_url_label": "Obtenir le jeton d’accès et l’URL de l’API", + "paddleocr_url_label": "Site officiel de PaddleOCR" + }, "provider": "fournisseur de services de prétraitement de documents", "provider_placeholder": "Choisissez un prestataire de traitement de documents", "title": "Prétraitement des documents", @@ -4914,6 +5128,13 @@ "show": "Afficher l'icône dans la barre d'état système", "title": "Barre d'état système" }, + "use_system_title_bar": { + "confirm": { + "content": "La modification du style de la barre de titre nécessite le redémarrage de l'application pour prendre effet. Voulez-vous redémarrer maintenant ?", + "title": "Redémarrage requis" + }, + "title": "Utiliser la barre de titre du système (Linux)" + }, "zoom": { "reset": "Réinitialiser", "title": "Zoom" @@ -4991,6 +5212,8 @@ "detected": { "language": "Détection automatique" }, + "detected_source": "Détecté", + "detecting": "Détection...", "empty": "Le contenu à traduire est vide", "error": { "chat_qwen_mt": "Les modèles Qwen MT ne peuvent pas être utilisés dans les conversations, veuillez vous rendre sur la page de traduction.", @@ -5043,6 +5266,7 @@ "not_pair": "La langue source est différente de la langue définie", "same": "La langue source et la langue cible sont identiques" }, + "language_settings": "Paramètres de langue", "menu": { "description": "Traduire le contenu de la zone de saisie actuelle" }, @@ -5052,6 +5276,7 @@ "output": { "placeholder": "traduction" }, + "preferred_target": "Cible préférée", "processing": "en cours de traduction...", "settings": { "autoCopy": "Copié automatiquement après la traduction", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index b58fe588f6..3959cb26a9 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -271,6 +271,7 @@ }, "toolPermission": { "aria": { + "allowAllRequest": "常にこのツールを許可する", "allowRequest": "ツールリクエストを許可", "denyRequest": "ツールリクエストを拒否", "hideDetails": "ツールの詳細を非表示", @@ -278,6 +279,7 @@ "showDetails": "ツールの詳細を表示" }, "button": { + "allowAll": "常に許可", "cancel": "キャンセル", "run": "走る" }, @@ -544,6 +546,20 @@ "description": "デフォルトで有効な MCP サーバー", "enableFirst": "まず MCP 設定でこのサーバーを有効にしてください", "label": "MCP サーバー", + "mode": { + "auto": { + "description": "AIはツールを自動的に発見し、使用する", + "label": "オート" + }, + "disabled": { + "description": "MCPツールなし", + "label": "無効" + }, + "manual": { + "description": "特定のMCPサーバーを選択", + "label": "マニュアル" + } + }, "noServersAvailable": "利用可能な MCP サーバーがありません。設定でサーバーを追加してください", "title": "MCP 設定" }, @@ -653,6 +669,10 @@ "title": "新しいトピック" } }, + "alerts": { + "create_session": "セッションを作成", + "select_agent": "エージェントを選択してください" + }, "artifacts": { "button": { "download": "ダウンロード", @@ -1186,6 +1206,8 @@ "cancel": "キャンセル", "chat": "チャット", "clear": "クリア", + "clear_all": "すべてクリア", + "click_to_replace": "クリックして置換", "close": "閉じる", "collapse": "折りたたむ", "completed": "完了", @@ -1220,9 +1242,15 @@ "footnote": "引用内容", "footnotes": "脚注", "fullscreen": "全画面モードに入りました。F11キーで終了します", + "generate_random_seed": "ランダムシードを生成", + "get_embedding_dimension": "埋め込み次元を取得", "go_to_settings": "設定に移動", + "html_preview": "HTMLプレビュー", "i_know": "わかりました", "ignore": "無視", + "image_preview": "画像プレビュー", + "image_url": "画像URL", + "image_url_or_upload": "画像のURLを入力するか、ファイルをアップロードしてください", "inspect": "検査", "invalid_value": "無効な値", "knowledge_base": "ナレッジベース", @@ -1232,6 +1260,7 @@ "models": "モデル", "more": "もっと", "name": "名前", + "next_match": "次の試合", "no_results": "検索結果なし", "none": "無", "off": "オフ", @@ -1243,13 +1272,17 @@ "model": "モデルを選択" } }, + "powered_by": "搭載", "preview": "プレビュー", + "previous_match": "前回の試合", "prompt": "プロンプト", "provider": "プロバイダー", "reasoning_content": "深く考察済み", "refresh": "更新", "regenerate": "再生成", + "remove_image": "画像を削除", "rename": "名前を変更", + "required_field": "必須項目", "reset": "リセット", "save": "保存", "saved": "保存されました", @@ -1272,14 +1305,29 @@ "success": "成功", "swap": "交換", "topics": "トピック", + "translate_text": "翻訳", "unknown": "Unknown", "unnamed": "無題", "unsubscribe": "配信停止", "update_success": "更新成功", "upload_files": "ファイルをアップロードする", + "upload_image": "画像ファイルをアップロード", + "uploaded_image": "アップロードされた画像", "warning": "警告", "you": "あなた" }, + "dialog": { + "all_files": "すべてのファイル", + "html_files": "HTMLファイル", + "open_file": "ファイルを開く", + "pdf_files": "PDFファイル", + "png_image": "PNG画像", + "save_as_html": "HTMLとして保存", + "save_as_pdf": "PDFとして保存", + "save_file": "ファイルを保存", + "select_folder": "フォルダを選択", + "word_document": "ワード文書" + }, "docs": { "title": "ドキュメント" }, @@ -1297,6 +1345,7 @@ "backup": { "file_format": "バックアップファイルの形式エラー" }, + "base64DataTruncated": "Base64画像データが切り捨てられています、サイズ", "boundary": { "default": { "devtools": "デバッグパネルを開く", @@ -1377,6 +1426,8 @@ "text": "テキスト", "toolInput": "ツール入力", "toolName": "ツール名", + "truncated": "データが切り捨てられました、元のサイズ", + "truncatedBadge": "切り捨て", "unknown": "不明なエラー", "usage": "用量", "user_message_not_found": "元のユーザーメッセージを見つけることができませんでした", @@ -1998,10 +2049,65 @@ "cancelled": "キャンセル", "completed": "完了", "error": "エラーが発生しました", + "groupHeader": "{{count}}ツール呼び出し", "invoking": "呼び出し中", + "labels": { + "bash": "バッシュ", + "edit": "編集", + "exitPlanMode": "ExitPlanMode", + "glob": "グローブ", + "grep": "グレップ", + "mcpServerTool": "MCPサーバーツール", + "multiEdit": "マルチエディット", + "notebookEdit": "ノートブック編集", + "readFile": "ファイルを読む", + "search": "検索", + "skill": "スキル", + "task": "タスク", + "todoWrite": "やること 書く", + "tool": "ツール", + "webFetch": "ウェブフェッチ", + "webSearch": "ウェブ検索", + "write": "書く" + }, + "noData": "このツールに利用可能なデータはありません", "pending": "保留中", "preview": "プレビュー", - "raw": "生データ" + "raw": "生データ", + "runningCount": "{{count}}個のツールが実行中", + "sections": { + "command": "コマンド", + "exitCode": "終了コード", + "input": "入力", + "output": "出力", + "prompt": "プロンプト", + "searchQuery": "検索クエリ", + "searchResults": "検索結果", + "stderr": "標準エラー出力", + "stdout": "標準出力" + }, + "status": { + "done": "完了", + "error": "エラー", + "failed": "失敗", + "running": "走っている", + "success": "成功" + }, + "truncated": "出力が切り捨てられました(元のサイズ: {{size}})", + "units": { + "done_one": "{{count}} 完了", + "done_other": "{{count}} 完了", + "file_one": "{{count}} ファイル", + "file_other": "{{count}}個のファイル", + "item_one": "{{count}}個のアイテム", + "item_other": "{{count}}件のアイテム", + "line_one": "{{count}}行", + "line_other": "{{count}}行", + "plan_one": "{{count}} プラン", + "plan_other": "{{count}}件のプラン", + "result_one": "{{count}}件の結果", + "result_other": "{{count}}件の結果" + } }, "topic": { "added": "新しいトピックが追加されました" @@ -2209,14 +2315,18 @@ "drop_markdown_hint": ".md ファイルまたはディレクトリをここにドラッグ&ドロップしてインポートしてください", "empty": "暫無ノート", "expand": "展開", + "exportToWord": "Wordにエクスポート", "export_failed": "知識ベースへのエクスポートに失敗しました", "export_knowledge": "ノートをナレッジベースにエクスポートする", "export_success": "知識ベースへのエクスポートが成功しました", + "export_to_word_failed": "Wordへのエクスポートに失敗しました", "folder": "フォルダー", "new_folder": "新しいフォルダーを作成する", "new_note": "新規ノート作成", "no_content_to_copy": "コピーするコンテンツはありません", + "no_content_to_export": "エクスポートするコンテンツがありません", "no_file_selected": "アップロードするファイルを選択してください", + "no_note_selected": "まずノートを選択してください", "no_valid_files": "有効なファイルがアップロードされていません", "open_folder": "外部フォルダーを開きます", "open_outside": "外部から開く", @@ -2422,6 +2532,39 @@ } }, "custom_size": "カスタムサイズ", + "dmxapi": { + "generating_tip": "公式モデルで生成中です。最良の結果のため、推定待機時間は2〜5分です。この操作のコストについては、DMXAPIバックエンドログをご確認ください。", + "style": "スタイル:", + "style_types": { + "25d_animation": "2.5Dアニメーション", + "3d_cartoon": "3Dカートゥーン", + "american_retro": "アメリカン・レトロ", + "baroque": "バロック", + "cartoon_illustration": "漫画風イラスト", + "chinese_gongbi": "中国の工筆", + "clay": "クレイ", + "cyberpunk": "サイバーパンク", + "felt": "フェルト", + "flat": "平ら", + "fresh_anime": "フレッシュアニメ", + "ghibli": "ジブリ", + "japanese_anime": "日本のアニメ", + "little_people_book": "小人の本", + "monet_garden": "モネ庭園", + "oil_painting": "油絵", + "pixar": "ピクサー", + "pixel_art": "ピクセルアート", + "poetic_ancient": "詩的な古代", + "psychedelic": "サイケデリック", + "sketch": "スケッチ", + "street_art": "ストリートアート", + "texture": "テクスチャ", + "ukiyo_e": "浮世絵", + "watercolor": "水彩画", + "wood_carving": "木彫刻", + "yarn_doll": "糸人形" + } + }, "edit": { "image_file": "編集画像", "magic_prompt_option_tip": "編集効果を向上させるための提示詞を最適化します", @@ -2446,6 +2589,7 @@ "style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用", "width": "幅" }, + "generate_failed": "画像の生成に失敗しました", "generated_image": "生成画像", "go_to_settings": "設定に移動", "guidance_scale": "ガイダンススケール", @@ -2456,6 +2600,7 @@ "image_file_required": "画像を先にアップロードしてください", "image_file_retry": "画像を先にアップロードしてください", "image_handle_required": "最初に画像をアップロードしてください。", + "image_mix_failed": "画像の混合に失敗しました", "image_placeholder": "画像がありません", "image_retry": "再試行", "image_size_options": { @@ -2465,6 +2610,7 @@ "inference_steps_tip": "実行する推論ステップ数。ステップ数が多いほど品質が向上しますが、時間がかかります", "input_image": "入力画像", "input_parameters": "パラメータ入力", + "invalid_image_url": "無効な画像URLの形式", "learn_more": "詳しくはこちら", "magic_prompt_option": "プロンプト強化", "mode": { @@ -2486,6 +2632,7 @@ "no_image_generation_model": "利用可能な画像生成モデルがありません。モデルを追加し、エンドポイントタイプを {{endpoint_type}} に設定してください", "number_images": "生成数", "number_images_tip": "生成する画像の数(1-4)", + "operation_failed": "操作に失敗しました。後でもう一度お試しください。", "paint_course": "チュートリアル", "per_image": "1枚あたり", "per_images": "複数枚あたり", @@ -2561,6 +2708,26 @@ "resemblance": "類似度", "resemblance_tip": "拡大結果と原画像の類似度を制御します", "seed_tip": "拡大結果のランダム性を制御します" + }, + "zhipu": { + "custom_size_divisible": "カスタムサイズは16で割り切れる必要があります", + "custom_size_hint": "幅と高さは512px~2048pxの間で16で割り切れ、総ピクセル数は2^21pxを超えてはなりません", + "custom_size_pixels": "カスタムサイズの総ピクセル数は2,097,152を超えることはできません", + "custom_size_range": "カスタムサイズは512px~2048pxの間でなければなりません", + "custom_size_required": "カスタム幅と高さを設定してください", + "image_sizes": { + "1024x1024_default": "1024x1024(デフォルト)", + "1152x864": "1152×864", + "1344x768": "1344x768", + "1440x720": "1440x720", + "720x1440": "720x1440", + "768x1344": "768x1344", + "864x1152": "864×1152" + }, + "quality_options": { + "hd": "HD", + "standard_default": "標準(デフォルト)" + } } }, "plugins": { @@ -2571,6 +2738,20 @@ "category": "カテゴリー", "commands": "命令", "confirm_uninstall": "{{name}}をアンインストールしてもよろしいですか?", + "content_saved": "プラグインコンテンツが正常に保存されました", + "detail": { + "allowed_tools": "許可されたツール", + "author": "著者", + "content": "コンテンツ", + "description": "説明", + "file": "ファイル", + "installed": "インストール済み", + "metadata": "メタデータ", + "size": "サイズ", + "source": "ソース", + "tags": "タグ", + "tools": "ツール" + }, "install": "インストール", "install_plugins_from_browser": "利用可能なプラグインを閲覧して、使用を開始してください", "installing": "インストール中...", @@ -2921,6 +3102,11 @@ "summary": "要約", "translate": "翻訳" }, + "prompt": { + "explain": "以下の内容を説明してください。要件:{{language}}で返答すること;このプロンプトの説明は一切含めず、直接回答してください。", + "refine": "ユーザー入力内容をXMLタグ<INPUT>で囲み、元の意味と整合性を保ちながら最適化または磨きをかけてください。要件:出力はユーザー入力と同じ言語で行い、このプロンプトの説明は一切含めず、直接応答を提供してください;XMLタグは出力せず、最適化された内容を直接出力してください。", + "summary": "以下の内容を要約してください。要件:{{language}}で返答;このプロンプトの説明は一切含めず、直接回答してください。" + }, "translate": { "smart_translate_tips": "スマート翻訳:内容は優先的に目標言語に翻訳されます。すでに目標言語の場合は、備用言語に翻訳されます。" }, @@ -3098,6 +3284,10 @@ }, "settings": { "about": { + "careers": { + "button": "表示", + "title": "キャリア" + }, "checkUpdate": { "available": "今すぐ更新", "label": "更新を確認" @@ -4010,6 +4200,10 @@ "jsonModeHint": "MCPサーバー設定のJSON表現を編集します。保存する前に、フォーマットが正しいことを確認してください。", "jsonSaveError": "JSON設定の保存に失敗しました", "jsonSaveSuccess": "JSON設定が保存されました。", + "lanyun": { + "description": "Lanyun Technology Cloud Platform MCP Service", + "name": "ランユン・テクノロジー" + }, "logoUrl": "ロゴURL", "logs": "ログ", "longRunning": "長時間運行モード", @@ -4106,6 +4300,14 @@ "getToken": "API トークンを取得する", "getTokenDescription": "アカウントから個人用 API トークンを取得します", "noServersAvailable": "利用可能な MCP サーバーがありません", + "providerDescriptions": { + "302ai": "302.AIプラットフォームMCPサービス", + "bailian": "Alibaba Cloud Bailian Platform MCP Service", + "lanyun": "Lanyun Technology Cloud Platform MCP Service", + "mcprouter": "MCPルータープラットフォーム MCPサービス", + "modelscope": "ModelScopeプラットフォームMCPサービス", + "tokenflux": "TokenFlux Platform MCP Service" + }, "selectProvider": "プロバイダーを選択:", "setToken": "トークンを入力してください", "success": "MCPサーバーの同期成功", @@ -4198,9 +4400,6 @@ "title": "メッセージ設定", "use_serif_font": "セリフフォントを使用" }, - "mineru": { - "api_key": "Mineruでは現在、1日500ページの無料クォータを提供しており、キーを入力する必要はありません。" - }, "miniapps": { "cache_change_notice": "設定値に達するまでミニアプリの開閉が行われた後に変更が適用されます", "cache_description": "メモリに保持するアクティブなミニアプリの最大数を設定します", @@ -4458,6 +4657,14 @@ } }, "options": { + "anthropic_cache": { + "cache_last_n": "最後のN件のメッセージをキャッシュ", + "cache_last_n_help": "最後のN件の会話メッセージをキャッシュする(システムメッセージは除く)", + "cache_system": "キャッシュシステムメッセージ", + "cache_system_help": "システムプロンプトをキャッシュするかどうか", + "token_threshold": "キャッシュトークン閾値", + "token_threshold_help": "このトークン数を超えるメッセージはキャッシュされます。キャッシュを無効にするには0を設定してください。" + }, "array_content": { "help": "このプロバイダーは、message の content フィールドが配列型であることをサポートしていますか", "label": "配列形式のメッセージコンテンツをサポート" @@ -4578,6 +4785,9 @@ "title": "プロバイダーを削除" }, "dmxapi": { + "platform_enterprise": "ssvip.DMXAPI.com(エンタープライズ)", + "platform_international": "www.DMXAPI.com(インターナショナル)", + "platform_official": "www.DMXAPI.cn(人民元)", "select_platform": "プラットフォームを選択" }, "docs_check": "チェック", @@ -4737,10 +4947,8 @@ "image_provider": "OCRサービスプロバイダー", "paddleocr": { "aistudio_access_token": "AI Studio Community のアクセス・トークン", - "aistudio_url_label": "AI Studio Community", "api_url": "API URL", - "serving_doc_url_label": "PaddleOCR サービング ドキュメント", - "tip": "ローカルサービスをデプロイするには、公式の PaddleOCR ドキュメントを参照するか、PaddlePaddle AI Studio コミュニティ上でクラウドサービスをデプロイすることができます。後者の場合は、AI Studio コミュニティのアクセストークンを提供してください。" + "api_url_label": "アクセストークンとAPI URLを取得する" }, "system": { "win": { @@ -4753,6 +4961,12 @@ "title": "OCRサービス" }, "preprocess": { + "paddleocr": { + "aistudio_access_token": "AI Studioコミュニティのアクセストークン", + "api_url": "API URL", + "api_url_label": "アクセストークンとAPI URLを取得する", + "paddleocr_url_label": "PaddleOCR 公式ウェブサイト" + }, "provider": "プレプロセスプロバイダー", "provider_placeholder": "前処理プロバイダーを選択してください", "title": "前処理", @@ -4914,6 +5128,13 @@ "show": "トレイアイコンを表示", "title": "トレイ" }, + "use_system_title_bar": { + "confirm": { + "content": "タイトルバーのスタイルを変更するには、アプリを再起動する必要があります。今すぐ再起動しますか?", + "title": "再起動が必要です" + }, + "title": "システムタイトルバーを使用(Linux)" + }, "zoom": { "reset": "リセット", "title": "ページズーム" @@ -4991,6 +5212,8 @@ "detected": { "language": "自動検出" }, + "detected_source": "検出されました", + "detecting": "検出中...", "empty": "翻訳内容が空です", "error": { "chat_qwen_mt": "Qwen MT モデルは対話で使用できません。翻訳ページに移動してください", @@ -5043,6 +5266,7 @@ "not_pair": "ソース言語が設定された言語と異なります", "same": "ソース言語と目標言語が同じです" }, + "language_settings": "言語設定", "menu": { "description": "對當前輸入框內容進行翻譯" }, @@ -5052,6 +5276,7 @@ "output": { "placeholder": "翻訳" }, + "preferred_target": "優先ターゲット", "processing": "翻訳中...", "settings": { "autoCopy": "翻訳完了後、自動的にコピー", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 24a38261ca..181fa2bb47 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -271,6 +271,7 @@ }, "toolPermission": { "aria": { + "allowAllRequest": "Sempre permitir esta ferramenta", "allowRequest": "Permitir solicitação de ferramenta", "denyRequest": "Negar solicitação de ferramenta", "hideDetails": "Ocultar detalhes da ferramenta", @@ -278,6 +279,7 @@ "showDetails": "Mostrar detalhes da ferramenta" }, "button": { + "allowAll": "Sempre Permitir", "cancel": "Cancelar", "run": "Correr" }, @@ -544,6 +546,20 @@ "description": "Servidor MCP ativado por padrão", "enableFirst": "Por favor, ative este servidor nas configurações do MCP primeiro", "label": "Servidor MCP", + "mode": { + "auto": { + "description": "IA descobre e usa ferramentas automaticamente", + "label": "Auto" + }, + "disabled": { + "description": "Sem ferramentas MCP", + "label": "Desativado" + }, + "manual": { + "description": "Selecione servidores MCP específicos", + "label": "Manual" + } + }, "noServersAvailable": "Nenhum servidor MCP disponível. Adicione um servidor nas configurações", "title": "Configurações do MCP" }, @@ -653,6 +669,10 @@ "title": "Novo Tópico" } }, + "alerts": { + "create_session": "Criar uma sessão", + "select_agent": "Selecione um agente" + }, "artifacts": { "button": { "download": "Baixar", @@ -1186,6 +1206,8 @@ "cancel": "Cancelar", "chat": "Bate-papo", "clear": "Limpar", + "clear_all": "Limpar Tudo", + "click_to_replace": "Clique para substituir", "close": "Fechar", "collapse": "Recolher", "completed": "Concluído", @@ -1220,9 +1242,15 @@ "footnote": "Nota de rodapé", "footnotes": "Notas de rodapé", "fullscreen": "Entrou no modo de tela cheia, pressione F11 para sair", + "generate_random_seed": "Gerar semente aleatória", + "get_embedding_dimension": "Obter dimensão de incorporação", "go_to_settings": "Ir para configurações", + "html_preview": "Pré-visualização em HTML", "i_know": "Entendi", "ignore": "Pular", + "image_preview": "Pré-visualização da imagem", + "image_url": "URL da imagem", + "image_url_or_upload": "Insira o URL da imagem ou carregue o arquivo", "inspect": "Verificar", "invalid_value": "Valor inválido", "knowledge_base": "Base de Conhecimento", @@ -1232,6 +1260,7 @@ "models": "Modelos", "more": "Mais", "name": "Nome", + "next_match": "Próxima partida", "no_results": "Nenhum resultado", "none": "Nenhum", "off": "Desligado", @@ -1243,13 +1272,17 @@ "model": "Selecionar modelo" } }, + "powered_by": "Desenvolvido por", "preview": "Pré-visualização", + "previous_match": "Partida anterior", "prompt": "Prompt", "provider": "Fornecedor", "reasoning_content": "Pensamento profundo concluído", "refresh": "Atualizar", "regenerate": "Regenerar", + "remove_image": "Remover imagem", "rename": "Renomear", + "required_field": "Campo obrigatório", "reset": "Redefinir", "save": "Salvar", "saved": "Guardado", @@ -1272,14 +1305,29 @@ "success": "Sucesso", "swap": "Trocar", "topics": "Tópicos", + "translate_text": "Traduzir texto", "unknown": "Desconhecido", "unnamed": "Sem nome", "unsubscribe": "Cancelar inscrição", "update_success": "Atualização bem-sucedida", "upload_files": "Carregar arquivo", + "upload_image": "Carregar arquivo de imagem", + "uploaded_image": "Imagem carregada", "warning": "Aviso", "you": "Você" }, + "dialog": { + "all_files": "Todos os Arquivos", + "html_files": "Arquivos HTML", + "open_file": "Abrir Arquivo", + "pdf_files": "Arquivos PDF", + "png_image": "Imagem PNG", + "save_as_html": "Salvar como HTML", + "save_as_pdf": "Salvar como PDF", + "save_file": "Salvar Arquivo", + "select_folder": "Selecionar Pasta", + "word_document": "Documento do Word" + }, "docs": { "title": "Documentação de Ajuda" }, @@ -1297,6 +1345,7 @@ "backup": { "file_format": "Formato do arquivo de backup está incorreto" }, + "base64DataTruncated": "Dados da imagem em Base64 truncados, tamanho", "boundary": { "default": { "devtools": "Abrir o painel de depuração", @@ -1377,6 +1426,8 @@ "text": "texto", "toolInput": "ferramenta de entrada", "toolName": "Nome da ferramenta", + "truncated": "Dados truncados, tamanho original", + "truncatedBadge": "Truncado", "unknown": "Erro desconhecido", "usage": "dosagem", "user_message_not_found": "Não foi possível encontrar a mensagem original do usuário", @@ -1998,10 +2049,65 @@ "cancelled": "Cancelado", "completed": "Completo", "error": "Ocorreu um erro", + "groupHeader": "{{count}} chamadas de ferramenta", "invoking": "Em execução", + "labels": { + "bash": "Bash", + "edit": "Editar", + "exitPlanMode": "ModoPlanoDeSaída", + "glob": "Globo", + "grep": "Grep", + "mcpServerTool": "Ferramenta do Servidor MCP", + "multiEdit": "MultiEdit", + "notebookEdit": "NotebookEdit", + "readFile": "Ler Arquivo", + "search": "Pesquisar", + "skill": "Habilidade", + "task": "Tarefa", + "todoWrite": "Fazer Escrever", + "tool": "Ferramenta", + "webFetch": "Busca na Web", + "webSearch": "Pesquisa na Web", + "write": "Escreva" + }, + "noData": "Nenhum dado disponível para esta ferramenta", "pending": "Pendente", "preview": "Pré-visualização", - "raw": "Bruto" + "raw": "Bruto", + "runningCount": "{{count}} ferramentas em execução", + "sections": { + "command": "Comando", + "exitCode": "Código de Saída", + "input": "Entrada", + "output": "Saída", + "prompt": "Prompt", + "searchQuery": "Consulta de Pesquisa", + "searchResults": "Resultados da Pesquisa", + "stderr": "stderr", + "stdout": "stdout" + }, + "status": { + "done": "Feito", + "error": "Erro", + "failed": "Falhou", + "running": "Correndo", + "success": "Sucesso" + }, + "truncated": "Saída truncada (original: {{size}})", + "units": { + "done_one": "{{count}} Concluído", + "done_other": "{{count}} Concluído", + "file_one": "{{count}} arquivo", + "file_other": "{{count}} arquivos", + "item_one": "{{count}} item", + "item_other": "{{count}} itens", + "line_one": "{{count}} linha", + "line_other": "{{count}} linhas", + "plan_one": "{{count}} plano", + "plan_other": "{{count}} planos", + "result_one": "{{count}} resultado", + "result_other": "{{count}} resultados" + } }, "topic": { "added": "Tópico adicionado com sucesso" @@ -2209,14 +2315,18 @@ "drop_markdown_hint": "Arraste e solte arquivos ou pastas .md aqui para importar", "empty": "Ainda não existem notas", "expand": "expandir", + "exportToWord": "Exportar para Word", "export_failed": "Falha ao exportar para a base de conhecimento", "export_knowledge": "exportar anotações para a base de conhecimento", "export_success": "exportado com sucesso para a base de conhecimento", + "export_to_word_failed": "Falha ao exportar para Word", "folder": "pasta", "new_folder": "Nova pasta", "new_note": "Nova nota", "no_content_to_copy": "Não há conteúdo para copiar", + "no_content_to_export": "Sem conteúdo para exportar", "no_file_selected": "Selecione o arquivo a ser enviado", + "no_note_selected": "Por favor, selecione uma nota primeiro", "no_valid_files": "Nenhum arquivo válido foi carregado", "open_folder": "Abrir pasta externa", "open_outside": "Abrir externamente", @@ -2422,6 +2532,39 @@ } }, "custom_size": "Dimensão personalizada", + "dmxapi": { + "generating_tip": "Gerando com o modelo oficial, o tempo estimado de espera é de 2 a 5 minutos para obter os melhores resultados. Verifique os logs do backend DMXAPI para saber o custo desta operação.", + "style": "Estilo:", + "style_types": { + "25d_animation": "Animação 2.5D", + "3d_cartoon": "Desenho animado 3D", + "american_retro": "Retrô Americano", + "baroque": "Barroco", + "cartoon_illustration": "Ilustração de desenho animado", + "chinese_gongbi": "Gongbi Chinês", + "clay": "Argila", + "cyberpunk": "Ciborgue", + "felt": "Sentiu", + "flat": "Plano", + "fresh_anime": "Anime Fresco", + "ghibli": "Ghibli", + "japanese_anime": "Anime Japonês", + "little_people_book": "Livro de Gente Pequena", + "monet_garden": "Jardim Monet", + "oil_painting": "Pintura a Óleo", + "pixar": "Pixar", + "pixel_art": "Arte em Pixel", + "poetic_ancient": "Poético Antigo", + "psychedelic": "Psicodélico", + "sketch": "Esboço", + "street_art": "Arte de Rua", + "texture": "Textura", + "ukiyo_e": "Ukiyo-e", + "watercolor": "Aquarela", + "wood_carving": "Escultura em Madeira", + "yarn_doll": "Boneca de Fios" + } + }, "edit": { "image_file": "Imagem editada", "magic_prompt_option_tip": "Otimização inteligente da palavra-chave de edição", @@ -2446,6 +2589,7 @@ "style_type_tip": "Estilo de geração da imagem, aplicável apenas às versões V_2 e superiores", "width": "Largura" }, + "generate_failed": "Falha ao gerar imagem", "generated_image": "Imagem gerada", "go_to_settings": "Ir para configurações", "guidance_scale": "Escala de Direção", @@ -2456,6 +2600,7 @@ "image_file_required": "Por favor, faça o upload da imagem primeiro", "image_file_retry": "Por favor, faça o upload novamente da imagem", "image_handle_required": "Por favor, faça o upload da imagem primeiro", + "image_mix_failed": "Falha ao misturar imagens", "image_placeholder": "Nenhuma imagem disponível no momento", "image_retry": "Tentar novamente", "image_size_options": { @@ -2465,6 +2610,7 @@ "inference_steps_tip": "Número de passos de inferência a serem executados. Quanto mais passos, melhor a qualidade, mas mais demorado", "input_image": "Imagem de entrada", "input_parameters": "Parâmetros de entrada", + "invalid_image_url": "Formato de URL de imagem inválido", "learn_more": "Saiba Mais", "magic_prompt_option": "Aprimoramento de Prompt", "mode": { @@ -2486,6 +2632,7 @@ "no_image_generation_model": "Nenhum modelo de geração de imagem disponível no momento. Por favor, adicione um modelo e defina o tipo de endpoint como {{endpoint_type}}", "number_images": "Quantidade de Imagens", "number_images_tip": "Quantidade de imagens a serem geradas por vez (1-4)", + "operation_failed": "Operação falhou, por favor tente novamente mais tarde", "paint_course": "Tutorial", "per_image": "Por imagem", "per_images": "Por imagem", @@ -2561,6 +2708,26 @@ "resemblance": "Similaridade", "resemblance_tip": "Controla o nível de semelhança entre o resultado ampliado e a imagem original", "seed_tip": "Controla a aleatoriedade do resultado de ampliação" + }, + "zhipu": { + "custom_size_divisible": "Tamanho personalizado deve ser divisível por 16", + "custom_size_hint": "Largura e altura devem estar entre 512px e 2048px, ser divisíveis por 16, e o total de pixels não pode exceder 2^21px.", + "custom_size_pixels": "O total de pixels de tamanho personalizado não pode exceder 2.097.152", + "custom_size_range": "Tamanho personalizado deve estar entre 512px-2048px", + "custom_size_required": "Por favor, defina largura e altura personalizadas", + "image_sizes": { + "1024x1024_default": "1024x1024 (Padrão)", + "1152x864": "1152x864", + "1344x768": "1344x768", + "1440x720": "1440x720", + "720x1440": "720x1440", + "768x1344": "768x1344", + "864x1152": "864x1152" + }, + "quality_options": { + "hd": "HD", + "standard_default": "Padrão (Padrão)" + } } }, "plugins": { @@ -2571,6 +2738,20 @@ "category": "categoria", "commands": "comando", "confirm_uninstall": "Tem certeza de que deseja desinstalar {{name}}?", + "content_saved": "Conteúdo do plugin salvo com sucesso", + "detail": { + "allowed_tools": "Ferramentas Permitidas", + "author": "Autor", + "content": "Conteúdo", + "description": "Descrição", + "file": "Arquivo", + "installed": "Instalado", + "metadata": "Metadados", + "size": "Tamanho", + "source": "Fonte", + "tags": "Etiquetas", + "tools": "Ferramentas" + }, "install": "Instalação", "install_plugins_from_browser": "Navegue pelos plugins disponíveis para começar a usar", "installing": "Instalando...", @@ -2921,6 +3102,11 @@ "summary": "Resumir", "translate": "Traduzir" }, + "prompt": { + "explain": "Por favor, explique o seguinte conteúdo.", + "refine": "Por favor, otimize ou aprimore o conteúdo fornecido pelo usuário dentro da tag XML <INPUT>, mantendo o significado e a integridade do conteúdo original. Requisitos: sua saída deve estar no mesmo idioma que o entrada do usuário; não inclua nenhuma explicação deste prompt, apenas forneça a resposta diretamente; não inclua tags XML, forneça o conteúdo otimizado diretamente:", + "summary": "Resuma o conteúdo a seguir. Requisitos: Responda em {{language}}; não inclua nenhuma explicação deste prompt, apenas forneça a resposta diretamente:" + }, "translate": { "smart_translate_tips": "Tradução inteligente: o conteúdo será priorizado para tradução no idioma de destino; se o conteúdo já estiver no idioma de destino, será traduzido para o idioma alternativo" }, @@ -3098,6 +3284,10 @@ }, "settings": { "about": { + "careers": { + "button": "Visualizar", + "title": "Carreiras" + }, "checkUpdate": { "available": "Atualizar agora", "label": "Verificar atualizações" @@ -4010,6 +4200,10 @@ "jsonModeHint": "Edite a representação JSON da configuração do servidor MCP. Certifique-se de que o formato está correto antes de salvar.", "jsonSaveError": "Falha ao salvar configuração JSON", "jsonSaveSuccess": "Configuração JSON salva com sucesso", + "lanyun": { + "description": "Plataforma Cloud da Lanyun Technology – Serviço MCP", + "name": "Lanyun Technology" + }, "logoUrl": "URL do Logotipo", "logs": "Registros", "longRunning": "Modo de execução prolongada", @@ -4106,6 +4300,14 @@ "getToken": "Obter token de API", "getTokenDescription": "Obtenha um token de API pessoal da sua conta", "noServersAvailable": "Nenhum servidor MCP disponível", + "providerDescriptions": { + "302ai": "302.Serviço MCP da Plataforma AI", + "bailian": "Alibaba Cloud Bailian Plataforma Serviço MCP", + "lanyun": "Plataforma Cloud da Lanyun Technology - Serviço MCP", + "mcprouter": "Plataforma de Roteamento MCP Serviço MCP", + "modelscope": "Serviço MCP da Plataforma ModelScope", + "tokenflux": "Serviço MCP da Plataforma TokenFlux" + }, "selectProvider": "Selecione o provedor:", "setToken": "Digite seu token", "success": "Servidor MCP sincronizado com sucesso", @@ -4198,9 +4400,6 @@ "title": "Configurações de mensagem", "use_serif_font": "Usar fonte serif" }, - "mineru": { - "api_key": "O MinerU agora oferece uma cota diária gratuita de 500 páginas; você não precisa preencher uma chave." - }, "miniapps": { "cache_change_notice": "As alterações entrarão em vigor após a abertura ou remoção dos mini aplicativos até atingir o número definido", "cache_description": "Defina o número máximo de mini aplicativos que permanecerão ativos simultaneamente", @@ -4458,6 +4657,14 @@ } }, "options": { + "anthropic_cache": { + "cache_last_n": "Cache Últimas N Mensagens", + "cache_last_n_help": "Armazenar em cache as últimas N mensagens da conversa (excluindo mensagens do sistema)", + "cache_system": "Mensagem do Sistema de Cache", + "cache_system_help": "Se deve armazenar em cache o prompt do sistema", + "token_threshold": "Limite de Token de Cache", + "token_threshold_help": "Mensagens que excederem essa contagem de tokens serão armazenadas em cache. Defina como 0 para desativar o cache." + }, "array_content": { "help": "O fornecedor suporta que o campo content da mensagem seja do tipo array?", "label": "suporta o formato de matriz do conteúdo da mensagem" @@ -4578,6 +4785,9 @@ "title": "Excluir Fornecedor" }, "dmxapi": { + "platform_enterprise": "ssvip.DMXAPI.com (Empresa)", + "platform_international": "www.DMXAPI.com (Internacional)", + "platform_official": "www.DMXAPI.cn (CNY)", "select_platform": "Selecionar Plataforma" }, "docs_check": "Verificar", @@ -4737,10 +4947,8 @@ "image_provider": "Provedor de serviços OCR", "paddleocr": { "aistudio_access_token": "Token de acesso da comunidade AI Studio", - "aistudio_url_label": "Comunidade AI Studio", "api_url": "URL da API", - "serving_doc_url_label": "Documentação do PaddleOCR Serving", - "tip": "Você pode consultar a documentação oficial do PaddleOCR para implantar um serviço local ou implantar um serviço na nuvem na Comunidade PaddlePaddle AI Studio. No último caso, forneça o token de acesso da Comunidade AI Studio." + "api_url_label": "Obter Token de Acesso e URL da API" }, "system": { "win": { @@ -4753,6 +4961,12 @@ "title": "Serviço OCR" }, "preprocess": { + "paddleocr": { + "aistudio_access_token": "Token de acesso da Comunidade AI Studio", + "api_url": "URL da API", + "api_url_label": "Obter Token de Acesso e URL da API", + "paddleocr_url_label": "Site Oficial do PaddleOCR" + }, "provider": "prestador de serviços de pré-processamento de documentos", "provider_placeholder": "Escolha um fornecedor de pré-processamento de documentos", "title": "Pré-processamento de documentos", @@ -4914,6 +5128,13 @@ "show": "Mostrar ícone de bandeja", "title": "Tray" }, + "use_system_title_bar": { + "confirm": { + "content": "Alterar o estilo da barra de título requer reiniciar o aplicativo para ter efeito. Deseja reiniciar agora?", + "title": "Reinicialização Necessária" + }, + "title": "Usar Barra de Título do Sistema (Linux)" + }, "zoom": { "reset": "Redefinir", "title": "Escala" @@ -4991,6 +5212,8 @@ "detected": { "language": "Detecção automática" }, + "detected_source": "Detectado", + "detecting": "Detectando...", "empty": "O conteúdo de tradução está vazio", "error": { "chat_qwen_mt": "Modelos Qwen MT não estão disponíveis para uso em conversas. Por favor, vá para a página de tradução.", @@ -5043,6 +5266,7 @@ "not_pair": "O idioma de origem é diferente do idioma definido", "same": "O idioma de origem e o idioma de destino são iguais" }, + "language_settings": "Configurações de Idioma", "menu": { "description": "Traduzir o conteúdo da caixa de entrada atual" }, @@ -5052,6 +5276,7 @@ "output": { "placeholder": "Tradução" }, + "preferred_target": "Alvo Preferencial", "processing": "Traduzindo...", "settings": { "autoCopy": "Cópia automática após a tradução", diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index f95cb7bdeb..74b1f677de 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -271,13 +271,15 @@ }, "toolPermission": { "aria": { + "allowAllRequest": "Permiteți întotdeauna acest instrument", "allowRequest": "Permite cererea instrumentului", "denyRequest": "Refuză cererea instrumentului", "hideDetails": "Ascunde detaliile instrumentului", "runWithOptions": "Rulează cu opțiuni suplimentare", - "showDetails": "Arată detaliile instrumentului" + "showDetails": "Afișează detaliile instrumentului" }, "button": { + "allowAll": "Permiteți întotdeauna", "cancel": "Anulează", "run": "Rulează" }, @@ -378,7 +380,7 @@ "about": "Despre", "close": "Închide fereastra", "copy": "Copiază", - "cut": "Taie", + "cut": "Decupează", "delete": "Șterge", "documentation": "Documentație", "edit": "Editare", @@ -401,7 +403,7 @@ "toggleDevTools": "Comută instrumentele pentru dezvoltatori", "toggleFullscreen": "Comută ecranul complet", "undo": "Anulează", - "unhide": "Arată toate", + "unhide": "Afișează toate", "view": "Vizualizare", "website": "Site web", "window": "Fereastră", @@ -544,6 +546,20 @@ "description": "Servere MCP activate implicit", "enableFirst": "Activează mai întâi acest server în setările MCP", "label": "Servere MCP", + "mode": { + "auto": { + "description": "AI descoperă și folosește instrumente automat", + "label": "Auto" + }, + "disabled": { + "description": "Niciun instrument MCP", + "label": "Dezactivat" + }, + "manual": { + "description": "Selectați servere MCP specifice", + "label": "Manual" + } + }, "noServersAvailable": "Nu există servere MCP disponibile. Adaugă servere în setări", "title": "Setări MCP" }, @@ -635,7 +651,7 @@ "manage": "Gestionează", "select_model": "Selectează modelul", "show": { - "all": "Arată tot" + "all": "Afișează tot" }, "update_available": "Actualizare disponibilă", "whole_word": "Cuvânt întreg" @@ -653,6 +669,10 @@ "title": "Subiect nou" } }, + "alerts": { + "create_session": "Creează o sesiune", + "select_agent": "Selectați un agent" + }, "artifacts": { "button": { "download": "Descarcă", @@ -961,7 +981,7 @@ }, "reset": "Resetează", "set_as_default": "Aplică la asistentul implicit", - "show_line_numbers": "Arată numerele de linie în cod", + "show_line_numbers": "Afișează numerele liniilor de cod", "temperature": { "label": "Temperatură", "tip": "Valorile mai mari fac modelul mai creativ și imprevizibil, în timp ce valorile mai mici îl fac mai determinist și precis." @@ -1120,7 +1140,7 @@ "model": "Model", "model_placeholder": "Selectează modelul de utilizat", "model_required": "Te rugăm să selectezi un model", - "select_folder": "Selectează folderul", + "select_folder": "Selectează dosarul", "set_custom_path": "Setează calea personalizată a terminalului", "supported_providers": "Furnizori acceptați", "terminal": "Terminal", @@ -1177,7 +1197,7 @@ "agent_one": "Agent", "agent_other": "Agenți", "and": "și", - "assistant": "Agent", + "assistant": "Asistent", "assistant_one": "Asistent", "assistant_other": "Asistenți", "avatar": "Avatar", @@ -1186,6 +1206,8 @@ "cancel": "Anulează", "chat": "Chat", "clear": "Golește", + "clear_all": "Șterge tot", + "click_to_replace": "Faceți clic pentru a înlocui", "close": "Închide", "collapse": "Restrânge", "completed": "Finalizat", @@ -1194,7 +1216,7 @@ "copy": "Copiază", "copy_failed": "Copiere eșuată", "current": "Curent", - "cut": "Taie", + "cut": "Decupează", "default": "Implicit", "delete": "Șterge", "delete_confirm": "Ești sigur că vrei să ștergi?", @@ -1208,10 +1230,10 @@ "duplicate": "Duplică", "edit": "Editează", "enabled": "Activat", - "error": "eroare", + "error": "Eroare", "errors": { "create_message": "Nu s-a putut crea mesajul", - "validation": "Verificarea a eșuat" + "validation": "Validarea a eșuat" }, "expand": "Extinde", "file": { @@ -1220,9 +1242,15 @@ "footnote": "Conținut de referință", "footnotes": "Referințe", "fullscreen": "S-a intrat în modul ecran complet. Apasă F11 pentru a ieși", + "generate_random_seed": "Generează sămânță aleatoare", + "get_embedding_dimension": "Obține dimensiunea de înglobare", "go_to_settings": "Mergi la setări", + "html_preview": "Previzualizare HTML", "i_know": "Am înțeles", "ignore": "Ignoră", + "image_preview": "Previzualizare imagine", + "image_url": "URL imagine", + "image_url_or_upload": "Introdu URL-ul imaginii sau încarcă fișierul", "inspect": "Inspectează", "invalid_value": "Valoare invalidă", "knowledge_base": "Bază de cunoștințe", @@ -1232,6 +1260,7 @@ "models": "Modele", "more": "Mai mult", "name": "Nume", + "next_match": "Următorul meci", "no_results": "Niciun rezultat", "none": "Nimic", "off": "Oprit", @@ -1243,13 +1272,17 @@ "model": "Selectează un model" } }, + "powered_by": "Cu tehnologia", "preview": "Previzualizare", + "previous_match": "Meciul anterior", "prompt": "Prompt", "provider": "Furnizor", "reasoning_content": "Raționament profund", "refresh": "Reîmprospătează", "regenerate": "Regenerează", + "remove_image": "Elimină imaginea", "rename": "Redenumește", + "required_field": "Câmp obligatoriu", "reset": "Resetează", "save": "Salvează", "saved": "Salvat", @@ -1272,14 +1305,29 @@ "success": "Succes", "swap": "Schimbă", "topics": "Subiecte", + "translate_text": "Traduceți textul", "unknown": "Necunoscut", "unnamed": "Fără nume", "unsubscribe": "Dezabonează-te", "update_success": "Actualizat cu succes", "upload_files": "Încarcă fișier", + "upload_image": "Încarcă fișier imagine", + "uploaded_image": "Imagine încărcată", "warning": "Avertisment", "you": "Tu" }, + "dialog": { + "all_files": "Toate fișierele", + "html_files": "Fișiere HTML", + "open_file": "Deschide fișier", + "pdf_files": "Fișiere PDF", + "png_image": "Imagine PNG", + "save_as_html": "Salvează ca HTML", + "save_as_pdf": "Salvează ca PDF", + "save_file": "Salvează fișier", + "select_folder": "Selectează folderul", + "word_document": "Document Word" + }, "docs": { "title": "Documentație" }, @@ -1297,6 +1345,7 @@ "backup": { "file_format": "Eroare format fișier backup" }, + "base64DataTruncated": "Datele imagine Base64 sunt trunchiate, dimensiunea", "boundary": { "default": { "devtools": "Deschide panoul de depanare", @@ -1377,6 +1426,8 @@ "text": "Text", "toolInput": "Intrare instrument", "toolName": "Nume instrument", + "truncated": "Date trunchiate, dimensiunea originală", + "truncatedBadge": "Trunchiat", "unknown": "Eroare necunoscută", "usage": "Utilizare", "user_message_not_found": "Nu se poate găsi mesajul original al utilizatorului pentru a retrimite", @@ -1594,7 +1645,7 @@ "title": "Setări bază de cunoștințe" }, "sitemap_added": "Adăugat cu succes", - "sitemap_placeholder": "Introdu URL-ul hărții site-ului", + "sitemap_placeholder": "Introdu URL-ul sitemap-ului", "sitemaps": "Site-uri web", "source": "Sursă", "status": "Stare", @@ -1606,7 +1657,7 @@ "status_pending": "În așteptare", "status_preprocess_completed": "Preprocesare finalizată", "status_preprocess_failed": "Preprocesare eșuată", - "status_processing": "Se procesează", + "status_processing": "În procesare", "subtitle_file": "fișier subtitrare", "threshold": "Prag de potrivire", "threshold_placeholder": "Nesetat", @@ -1616,9 +1667,9 @@ "topN": "Număr rezultate returnate", "topN_placeholder": "Nesetat", "topN_too_large_or_small": "Numărul de rezultate returnate nu poate fi mai mare de 30 sau mai mic de 1.", - "topN_tooltip": "Numărul de rezultate potrivite returnate; cu cât valoarea este mai mare, cu atât mai multe rezultate, dar și mai mulți tokeni consumați.", + "topN_tooltip": "Numărul de rezultate potrivite returnate; cu cât valoarea este mai mare, cu atât mai multe rezultate, dar și un consum mai mare de tokeni.", "url_added": "URL adăugat", - "url_placeholder": "Introdu URL, separă URL-urile multiple prin Enter", + "url_placeholder": "Introdu URL-ul; separă mai multe URL-uri prin Enter", "urls": "URL-uri", "videos": "video", "videos_file": "fișier video" @@ -1743,7 +1794,7 @@ "switch_user_confirm": "Schimbi contextul de utilizator la {{user}}?", "time": "Timp", "title": "Amintiri", - "total_memories": "total amintiri", + "total_memories": "Total amintiri", "try_different_filters": "Încearcă să ajustezi criteriile de căutare", "update_failed": "Nu s-a putut actualiza amintirea", "update_success": "Amintire actualizată cu succes", @@ -1762,7 +1813,7 @@ "user_memories_reset": "Toate amintirile pentru {{user}} au fost resetate", "user_switch_failed": "Nu s-a putut schimba utilizatorul", "user_switched": "Contextul de utilizator a fost schimbat la {{user}}", - "users": "utilizatori" + "users": "Utilizatori" }, "message": { "agents": { @@ -1998,10 +2049,65 @@ "cancelled": "Anulat", "completed": "Finalizat", "error": "A apărut o eroare", + "groupHeader": "{{count}} apeluri de instrumente", "invoking": "Se invocă", + "labels": { + "bash": "Bash", + "edit": "Editare", + "exitPlanMode": "ExitPlanMode", + "glob": "Glob", + "grep": "Grep", + "mcpServerTool": "Instrument Server MCP", + "multiEdit": "MultiEdit", + "notebookEdit": "NotebookEdit", + "readFile": "Citește fișier", + "search": "Căutare", + "skill": "Abilitate", + "task": "Sarcină", + "todoWrite": "Totul Scrie", + "tool": "Unelte", + "webFetch": "Preluare web", + "webSearch": "Căutare pe web", + "write": "Scrie" + }, + "noData": "Nu sunt disponibile date pentru acest instrument", "pending": "În așteptare", "preview": "Previzualizare", - "raw": "Brut" + "raw": "Brut", + "runningCount": "{{count}} instrumente în execuție", + "sections": { + "command": "Comandă", + "exitCode": "Cod de ieșire", + "input": "Intrare", + "output": "Ieșire", + "prompt": "Punct", + "searchQuery": "Interogare de căutare", + "searchResults": "Rezultatele căutării", + "stderr": "stderr", + "stdout": "stdout" + }, + "status": { + "done": "Gata", + "error": "Eroare", + "failed": "Eșuat", + "running": "Alergare", + "success": "Succes" + }, + "truncated": "Ieșire trunchiată (original: {{size}})", + "units": { + "done_one": "{{count}} Finalizat", + "done_other": "{{count}} Finalizat", + "file_one": "{{count}} fișier", + "file_other": "{{count}} fișiere", + "item_one": "{{count}} articol", + "item_other": "{{count}} articole", + "line_one": "{{count}} linie", + "line_other": "{{count}} linii", + "plan_one": "{{count}} plan", + "plan_other": "{{count}} planuri", + "result_one": "{{count}} rezultat", + "result_other": "{{count}} rezultate" + } }, "topic": { "added": "Subiect nou adăugat" @@ -2179,7 +2285,7 @@ "navbar": { "expand": "Extinde dialogul", "hide_sidebar": "Ascunde bara laterală", - "show_sidebar": "Arată bara laterală", + "show_sidebar": "Afișează bara laterală", "window": { "close": "Închide", "maximize": "Maximizează", @@ -2199,27 +2305,31 @@ }, "characters": "Caractere", "collapse": "Restrânge", - "content_placeholder": "Te rugăm să introduci conținutul notiței...", + "content_placeholder": "Introdu conținutul notiței...", "copyContent": "Copiază conținutul", "crossPlatformRestoreWarning": "Configurația multi-platformă a fost restaurată, dar directorul de notițe este gol. Te rugăm să copiezi fișierele notițelor în: {{path}}", - "delete": "șterge", + "delete": "Șterge", "delete_confirm": "Ești sigur că vrei să ștergi acest {{type}}?", "delete_folder_confirm": "Ești sigur că vrei să ștergi dosarul \"{{name}}\" și tot conținutul său?", "delete_note_confirm": "Ești sigur că vrei să ștergi notița \"{{name}}\"?", "drop_markdown_hint": "Trage fișiere sau dosare .md aici pentru a importa", "empty": "Încă nu există notițe disponibile", - "expand": "desfășoară", + "expand": "Extinde", + "exportToWord": "Exportă în Word", "export_failed": "Exportul în baza de cunoștințe a eșuat", "export_knowledge": "Exportă notițele în baza de cunoștințe", "export_success": "Exportat cu succes în baza de cunoștințe", - "folder": "dosar", + "export_to_word_failed": "Exportul în Word a eșuat", + "folder": "Dosar", "new_folder": "Dosar nou", "new_note": "Creează o notiță nouă", "no_content_to_copy": "Niciun conținut de copiat", + "no_content_to_export": "Niciun conținut de exportat", "no_file_selected": "Te rugăm să selectezi fișierul de încărcat", + "no_note_selected": "Te rog selectează mai întâi o notă", "no_valid_files": "Nu a fost încărcat niciun fișier valid", "open_folder": "Deschide un dosar extern", - "open_outside": "Deschide din exterior", + "open_outside": "Deschide extern", "rename": "Redenumește", "rename_changed": "Din cauza politicilor de securitate, numele fișierului a fost schimbat din {{original}} în {{final}}", "save": "Salvează în Notițe", @@ -2229,7 +2339,7 @@ "found_results": "S-au găsit {{count}} rezultate (Nume: {{nameCount}}, Conținut: {{contentCount}})", "more_matches": "mai multe potriviri", "searching": "Se caută...", - "show_less": "Arată mai puțin" + "show_less": "Afișează mai puțin" }, "settings": { "data": { @@ -2258,7 +2368,7 @@ "font_size_small": "Mic", "font_title": "Setări font", "serif_font": "Font cu serife", - "show_table_of_contents": "Arată cuprinsul", + "show_table_of_contents": "Afișează cuprinsul", "show_table_of_contents_description": "Afișează o bară laterală cu cuprinsul pentru o navigare ușoară în documente", "title": "Setări afișare" }, @@ -2280,7 +2390,7 @@ }, "title": "Notițe" }, - "show_starred": "Arată notițele favorite", + "show_starred": "Afișează notițele favorite", "sort_a2z": "Nume fișier (A-Z)", "sort_created_asc": "Ora creării (cele mai vechi întâi)", "sort_created_desc": "Ora creării (cele mai noi întâi)", @@ -2422,6 +2532,39 @@ } }, "custom_size": "Dimensiune personalizată", + "dmxapi": { + "generating_tip": "Generarea cu modelul oficial, timpul estimat de așteptare este de 2-5 minute pentru cele mai bune rezultate. Te rugăm să verifici logurile backend DMXAPI pentru costul acestei operațiuni.", + "style": ", Stil:", + "style_types": { + "25d_animation": "Animație 2.5D", + "3d_cartoon": "Desen animat 3D", + "american_retro": "Retro American", + "baroque": "Baroc", + "cartoon_illustration": "Ilustrație de desen animat", + "chinese_gongbi": "Gongbi chinezesc", + "clay": "Argilă", + "cyberpunk": "Cyberpunk", + "felt": "Filț", + "flat": "Plat", + "fresh_anime": "Anime Proaspăt", + "ghibli": "Ghibli", + "japanese_anime": "Anime japonez", + "little_people_book": "Cartea Oamenilor Mici", + "monet_garden": "Grădina Monet", + "oil_painting": "Pictură în ulei", + "pixar": "Pixar", + "pixel_art": "Artă pixelată", + "poetic_ancient": "Poetic Antic", + "psychedelic": "Psichedelic", + "sketch": "Schiță", + "street_art": "Artă stradală", + "texture": "Textură", + "ukiyo_e": "Ukiyo-e", + "watercolor": "Acuarelă", + "wood_carving": "Sculptură în lemn", + "yarn_doll": "Păpușă din fire de lână" + } + }, "edit": { "image_file": "Imagine editată", "magic_prompt_option_tip": "Îmbunătățește inteligent prompturile de editare", @@ -2446,6 +2589,7 @@ "style_type_tip": "Stil generare imagine pentru V_2 și versiuni ulterioare", "width": "Lățime" }, + "generate_failed": "Nu s-a putut genera imaginea", "generated_image": "Imagine generată", "go_to_settings": "Mergi la Setări", "guidance_scale": "Scară de ghidare", @@ -2456,6 +2600,7 @@ "image_file_required": "Te rugăm să încarci mai întâi o imagine", "image_file_retry": "Te rugăm să reîncarci mai întâi o imagine", "image_handle_required": "Te rugăm să încarci mai întâi o imagine.", + "image_mix_failed": "Nu s-au putut combina imaginile", "image_placeholder": "Nicio imagine disponibilă", "image_retry": "Reîncearcă", "image_size_options": { @@ -2465,6 +2610,7 @@ "inference_steps_tip": "Numărul de pași de inferență de efectuat. Mai mulți pași produc o calitate mai mare, dar durează mai mult", "input_image": "Imagine de intrare", "input_parameters": "Parametri de intrare", + "invalid_image_url": "Formatul URL-ului imaginii este invalid", "learn_more": "Află mai multe", "magic_prompt_option": "Prompt magic", "mode": { @@ -2486,6 +2632,7 @@ "no_image_generation_model": "Niciun model de generare imagini disponibil, te rugăm să adaugi un model și să setezi tipul endpoint-ului la {{endpoint_type}}", "number_images": "Număr imagini", "number_images_tip": "Numărul de imagini de generat (1-4)", + "operation_failed": "Operațiunea a eșuat, vă rugăm să încercați din nou mai târziu", "paint_course": "tutorial", "per_image": "pe imagine", "per_images": "pe imagini", @@ -2561,6 +2708,26 @@ "resemblance": "Similaritate", "resemblance_tip": "Controlează similaritatea cu imaginea originală", "seed_tip": "Controlează aleatoriul scalării" + }, + "zhipu": { + "custom_size_divisible": "Dimensiunea personalizată trebuie să fie divizibilă cu 16", + "custom_size_hint": "Lățimea și înălțimea trebuie să fie între 512px-2048px, divizibile cu 16, iar numărul total de pixeli nu poate depăși 2^21px", + "custom_size_pixels": "Numărul total de pixeli ai dimensiunii personalizate nu poate depăși 2.097.152", + "custom_size_range": "Dimensiunea personalizată trebuie să fie între 512px și 2048px.", + "custom_size_required": "Vă rugăm să setați lățimea și înălțimea personalizate", + "image_sizes": { + "1024x1024_default": "1024x1024 (Implicit)", + "1152x864": "1152x864", + "1344x768": "1344x768", + "1440x720": "1440x720", + "720x1440": "720x1440", + "768x1344": "768x1344", + "864x1152": "864x1152" + }, + "quality_options": { + "hd": "HD", + "standard_default": "Standard (Implicit)" + } } }, "plugins": { @@ -2571,6 +2738,20 @@ "category": "Categorie", "commands": "Comenzi", "confirm_uninstall": "Ești sigur că vrei să dezinstalezi {{name}}?", + "content_saved": "Conținutul plugin-ului a fost salvat cu succes", + "detail": { + "allowed_tools": "Unelte Permise", + "author": "Autor", + "content": "Conținut", + "description": "Descriere", + "file": "Fișier", + "installed": "Instalat", + "metadata": "Metadate", + "size": "Dimensiune", + "source": "Sursă", + "tags": "Etichete", + "tools": "Unelte" + }, "install": "Instalează", "install_plugins_from_browser": "Răsfoiește pluginurile disponibile pentru a începe", "installing": "Se instalează...", @@ -2921,6 +3102,11 @@ "summary": "Rezumat", "translate": "Tradu" }, + "prompt": { + "explain": "Te rog să explici următorul conținut. Cerințe: Răspunde în {{language}}; nu include nicio explicație a acestui prompt, oferă direct răspunsul:", + "refine": "Te rugăm să optimizezi sau să îmbunătățești conținutul introdus de utilizator, cuprins în eticheta XML <INPUT>, păstrând sensul și integritatea textului original. Cerințe: rezultatul tău trebuie să fie în aceeași limbă cu cel introdus de utilizator; nu include nicio explicație referitoare la acest prompt, oferă direct răspunsul; nu afișa etichetele XML, ci doar conținutul optimizat.", + "summary": "Vă rog să rezumați următorul conținut. Cerințe: Răspundeți în {{language}}; nu includeți nicio explicație a acestui prompt, oferiți direct răspunsul:" + }, "translate": { "smart_translate_tips": "Traducere inteligentă: Conținutul va fi tradus mai întâi în limba țintă; conținutul aflat deja în limba țintă va fi tradus în limba alternativă" }, @@ -2931,7 +3117,7 @@ "opacity": "Opacitate fereastră", "original_copy": "Copiază originalul", "original_hide": "Ascunde originalul", - "original_show": "Arată originalul", + "original_show": "Afișează originalul", "pin": "Fixează", "pinned": "Fixat", "r_regenerate": "R: Regenerează" @@ -3029,7 +3215,7 @@ "windows": "Unele aplicații nu acceptă selectarea textului cu tasta Ctrl. Dacă ai remapat tasta Ctrl folosind instrumente precum AHK, acest lucru poate cauza eșecul selecției textului în unele aplicații." }, "selected": "Selecție", - "selected_note": "Arată bara de instrumente imediat ce textul este selectat", + "selected_note": "Afișează bara de instrumente la selectarea textului", "shortcut": "Comandă rapidă", "shortcut_link": "Mergi la Setările comenzilor rapide", "shortcut_note": "După selecție, folosește comanda rapidă pentru a afișa bara de instrumente. Te rugăm să setezi comanda rapidă în pagina de setări și să o activezi. ", @@ -3098,6 +3284,10 @@ }, "settings": { "about": { + "careers": { + "button": "Vedere", + "title": "Carieră" + }, "checkUpdate": { "available": "Actualizare", "label": "Verifică actualizări" @@ -3420,7 +3610,7 @@ }, "show_model_provider": { "help": "Afișează furnizorul modelului (de ex., OpenAI, Gemini) la exportul în Markdown", - "title": "Arată furnizorul modelului" + "title": "Afișează furnizorul modelului" }, "standardize_citations": { "help": "Când este activat, marcatorii de citare vor fi convertiți în format standard de notă de subsol Markdown [^1], iar listele de citare vor fi formatate.", @@ -3805,22 +3995,22 @@ "disabled": "Ascunde pictograme", "empty": "Trage funcția ascunsă din partea stângă aici", "files": { - "icon": "Arată pictograma Fișiere" + "icon": "Afișează pictograma Fișiere" }, "knowledge": { - "icon": "Arată pictograma Cunoștințe" + "icon": "Afișează pictograma Cunoștințe" }, "minapp": { - "icon": "Arată pictograma MinApp" + "icon": "Afișează pictograma MinApp" }, "painting": { - "icon": "Arată pictograma Pictură" + "icon": "Afișează pictograma Imagini" }, "title": "Setări bară laterală", "translate": { - "icon": "Arată pictograma Traducere" + "icon": "Afișează pictograma Traducere" }, - "visible": "Arată pictograme" + "visible": "Afișează pictograme" }, "title": "Setări afișare", "topic": { @@ -3896,7 +4086,7 @@ "knowledge_base": "Golește bazele de cunoștințe selectate", "models": "Golește toate modelele" }, - "show_translate_confirm": "Arată dialogul de confirmare a traducerii", + "show_translate_confirm": "Afișează fereastra de confirmare a traducerii", "target_language": { "chinese": "Chineză simplificată", "chinese-traditional": "Chineză tradițională", @@ -4010,6 +4200,10 @@ "jsonModeHint": "Editează reprezentarea JSON a configurației serverului MCP. Te rugăm să te asiguri că formatul este corect înainte de salvare.", "jsonSaveError": "Nu s-a putut salva configurația JSON.", "jsonSaveSuccess": "Configurația JSON a fost salvată.", + "lanyun": { + "description": "Lanyun Technology Cloud Platform MCP Service", + "name": "Lanyun Technology" + }, "logoUrl": "URL logo", "logs": "Jurnale", "longRunning": "Mod rulare lungă", @@ -4106,6 +4300,14 @@ "getToken": "Obține token API", "getTokenDescription": "Obține tokenul tău personal API din contul tău", "noServersAvailable": "Nu există servere MCP disponibile", + "providerDescriptions": { + "302ai": "Serviciul MCP al Platformei 302.AI", + "bailian": "Serviciul MCP al Platformei Bailian Alibaba Cloud", + "lanyun": "Platforma Cloud Lanyun Technology - Serviciul MCP", + "mcprouter": "Platformă Router MCP Serviciu MCP", + "modelscope": "Serviciul MCP al Platformei ModelScope", + "tokenflux": "Serviciul MCP al Platformei TokenFlux" + }, "selectProvider": "Selectează furnizor:", "setToken": "Introdu tokenul tău", "success": "Sincronizare servere MCP reușită", @@ -4163,7 +4365,7 @@ }, "messages": { "divider": { - "label": "Arată divizor între mesaje", + "label": "Afișează divizor între mesaje", "tooltip": "Nu se aplică mesajelor stil bulă" }, "grid_columns": "Coloane afișare grilă mesaje", @@ -4179,7 +4381,7 @@ "paste_long_text_as_file": "Lipește text lung ca fișier", "paste_long_text_threshold": "Lungime lipire text lung", "send_shortcuts": "Comenzi rapide trimitere", - "show_estimated_tokens": "Arată tokeni estimați", + "show_estimated_tokens": "Afișează numărul estimat de tokeni", "title": "Setări intrare" }, "markdown_rendering_input_message": "Randare Markdown mesaj intrare", @@ -4193,14 +4395,11 @@ "label": "Bară navigare", "none": "Niciunul" }, - "prompt": "Arată prompt", - "show_message_outline": "Arată contur mesaj", + "prompt": "Afișează prompt", + "show_message_outline": "Afișează conturul mesajului", "title": "Setări mesaje", "use_serif_font": "Folosește font serif" }, - "mineru": { - "api_key": "Mineru oferă acum o cotă zilnică gratuită de 500 de pagini și nu este nevoie să introduci o cheie." - }, "miniapps": { "cache_change_notice": "Modificările vor intra în vigoare când numărul de mini-aplicații deschise atinge valoarea setată", "cache_description": "Setează numărul maxim de mini-aplicații active de păstrat în memorie", @@ -4241,10 +4440,10 @@ "display_title": "Setări afișare mini-aplicații", "empty": "Trage mini-aplicațiile din stânga pentru a le ascunde", "open_link_external": { - "title": "Deschide linkurile de fereastră nouă în browser" + "title": "Deschide în browser linkurile care deschid ferestre noi" }, "reset_tooltip": "Resetează la implicit", - "sidebar_description": "Arată mini-aplicațiile active în bara laterală", + "sidebar_description": "Afișează mini-aplicațiile active în bara laterală", "sidebar_title": "Afișare mini-aplicații active în bara laterală", "title": "Setări mini-aplicații", "visible": "Mini-aplicații vizibile" @@ -4341,7 +4540,7 @@ "description": "Model folosit pentru sarcini simple, cum ar fi numirea subiectelor și extragerea cuvintelor cheie", "label": "Model rapid", "setting_title": "Configurare model rapid", - "tooltip": "Se recomandă alegerea unui model ușor și nu se recomandă alegerea unui model de gândire." + "tooltip": "Se recomandă alegerea unui model ușor, nu a unui model de raționament complex." }, "topic_naming": { "auto": "Numire automată subiect", @@ -4458,6 +4657,14 @@ } }, "options": { + "anthropic_cache": { + "cache_last_n": "Cache Ultimelor N Mesaje", + "cache_last_n_help": "Stochează ultimele N mesaje din conversație (excluzând mesajele de sistem)", + "cache_system": "Mesaj de sistem Cache", + "cache_system_help": "Dacă să se memoreze în cache promptul de sistem", + "token_threshold": "Prag de Token Cache", + "token_threshold_help": "Mesajele care depășesc acest număr de tokeni vor fi memorate în cache. Setați la 0 pentru a dezactiva memorarea în cache." + }, "array_content": { "help": "Furnizorul acceptă ca câmpul content al mesajului să fie de tip array?", "label": "Acceptă conținut mesaj în format array" @@ -4578,6 +4785,9 @@ "title": "Șterge furnizor" }, "dmxapi": { + "platform_enterprise": "ssvip.DMXAPI.com (Întreprindere)", + "platform_international": "www.DMXAPI.com (Internațional)", + "platform_official": "www.DMXAPI.cn (CNY)", "select_platform": "Selectează platforma" }, "docs_check": "Verifică", @@ -4673,7 +4883,7 @@ }, "shortcuts": { "action": "Acțiune", - "actions": "operațiune", + "actions": "Comandă", "clear_shortcut": "Șterge comanda rapidă", "clear_topic": "Șterge mesajele", "copy_last_message": "Copiază ultimul mesaj", @@ -4692,7 +4902,7 @@ "search_message_in_chat": "Caută mesaj în chat-ul curent", "selection_assistant_select_text": "Asistent de selecție: Selectează text", "selection_assistant_toggle": "Comută Asistentul de selecție", - "show_app": "Arată/Ascunde aplicația", + "show_app": "Afișează/Ascunde aplicația", "show_settings": "Deschide setările", "title": "Comenzi rapide de la tastatură", "toggle_new_context": "Șterge contextul", @@ -4704,8 +4914,8 @@ }, "theme": { "color_primary": "Culoare primară", - "dark": "Întunecat", - "light": "Luminos", + "dark": "Întunecată", + "light": "Luminoasă", "system": "Sistem", "title": "Temă", "window": { @@ -4737,10 +4947,8 @@ "image_provider": "Furnizor serviciu OCR", "paddleocr": { "aistudio_access_token": "Token de acces Comunitatea AI Studio", - "aistudio_url_label": "Comunitatea AI Studio", "api_url": "URL API", - "serving_doc_url_label": "Documentație servire PaddleOCR", - "tip": "Poți consulta documentația oficială PaddleOCR pentru a implementa un serviciu local sau poți implementa un serviciu cloud pe Comunitatea PaddlePaddle AI Studio. Pentru ultimul caz, te rugăm să furnizezi tokenul de acces al Comunității AI Studio." + "api_url_label": "Obțineți token-ul de acces și URL-ul API" }, "system": { "win": { @@ -4753,6 +4961,12 @@ "title": "Serviciu OCR" }, "preprocess": { + "paddleocr": { + "aistudio_access_token": "Token de acces al Comunității AI Studio", + "api_url": "URL API", + "api_url_label": "Obțineți token-ul de acces și URL-ul API", + "paddleocr_url_label": "Site-ul oficial PaddleOCR" + }, "provider": "Furnizor procesare documente", "provider_placeholder": "Alege un furnizor de procesare documente", "title": "Procesare documente", @@ -4861,27 +5075,27 @@ "right": "Dreapta" }, "show": { - "time": "Arată ora subiectului" + "time": "Afișează ora subiectului" } }, "translate": { "custom": { "delete": { - "description": "Ești sigur că vrei să ștergi?", + "description": "Ești sigur că vrei să ștergi această limbă?", "title": "Șterge limba personalizată" }, "error": { "add": "Adăugarea a eșuat", "delete": "Ștergerea a eșuat", "langCode": { - "builtin": "Limba are suport integrat", - "empty": "Codul limbii este gol", + "builtin": "Limbă deja integrată", + "empty": "Codul limbii lipsește", "exists": "Limba există deja", "invalid": "Cod limbă invalid" }, "update": "Actualizarea a eșuat", "value": { - "empty": "Numele limbii nu poate fi gol", + "empty": "Numele limbii este obligatoriu", "too_long": "Numele limbii este prea lung" } }, @@ -4891,13 +5105,13 @@ "placeholder": "en-us" }, "success": { - "add": "Adăugat cu succes", - "delete": "Șters cu succes", - "update": "Actualizare reușită" + "add": "Adăugată cu succes", + "delete": "Ștearsă cu succes", + "update": "Actualizată cu succes" }, "table": { "action": { - "title": "Operațiune" + "title": "Acțiuni" } }, "value": { @@ -4911,9 +5125,16 @@ }, "tray": { "onclose": "Minimizează în zona de notificare la închidere", - "show": "Arată pictograma în zona de notificare", + "show": "Afișează pictograma în zona de notificare", "title": "Zonă de notificare" }, + "use_system_title_bar": { + "confirm": { + "content": "Schimbarea stilului barei de titlu necesită repornirea aplicației pentru a intra în vigoare. Doriți să reporniți acum?", + "title": "Repornire necesară" + }, + "title": "Folosește bara de titlu a sistemului (Linux)" + }, "zoom": { "reset": "Resetează", "title": "Zoom pagină" @@ -4929,7 +5150,7 @@ "mcp-servers": "Servere MCP", "memories": "Amintiri", "notes": "Notițe", - "paintings": "Picturi", + "paintings": "Imagini", "settings": "Setări", "store": "Bibliotecă asistenți", "translate": "Traducere" @@ -4973,7 +5194,7 @@ "detect": { "method": { "algo": { - "label": "algoritm", + "label": "Algoritm", "tip": "Folosește biblioteca franc pentru detectarea limbii" }, "auto": { @@ -4991,6 +5212,8 @@ "detected": { "language": "Detectare automată" }, + "detected_source": "Detectat", + "detecting": "Se detectează...", "empty": "Conținutul traducerii este gol", "error": { "chat_qwen_mt": "Modelul Qwen MT nu poate fi folosit în chat. Te rugăm să mergi la pagina de traducere.", @@ -5043,6 +5266,7 @@ "not_pair": "Limba sursă este diferită de limba setată", "same": "Limbile sursă și țintă sunt aceleași" }, + "language_settings": "Setări limbă", "menu": { "description": "Tradu conținutul casetei de intrare curente" }, @@ -5052,6 +5276,7 @@ "output": { "placeholder": "Traducere" }, + "preferred_target": "Țintă Preferată", "processing": "Traducere în curs...", "settings": { "autoCopy": "Copiază după traducere ", @@ -5080,7 +5305,7 @@ "tray": { "quit": "Ieșire", "show_mini_window": "Asistent rapid", - "show_window": "Arată fereastra" + "show_window": "Afișează fereastra" }, "update": { "install": "Instalează", @@ -5096,7 +5321,7 @@ "words": { "knowledgeGraph": "Grafic de cunoștințe", "quit": "Ieșire", - "show_window": "Arată fereastra", + "show_window": "Afișează fereastra", "visualization": "Vizualizare" } } diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 74ce3df5fb..8524f37db6 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -271,6 +271,7 @@ }, "toolPermission": { "aria": { + "allowAllRequest": "Всегда разрешать этот инструмент", "allowRequest": "Разрешить запрос инструмента", "denyRequest": "Отклонить запрос на инструмент", "hideDetails": "Скрыть сведения об инструменте", @@ -278,6 +279,7 @@ "showDetails": "Показать сведения об инструменте" }, "button": { + "allowAll": "Всегда разрешать", "cancel": "Отмена", "run": "Беги" }, @@ -544,6 +546,20 @@ "description": "Серверы MCP, включенные по умолчанию", "enableFirst": "Сначала включите этот сервер в настройках MCP", "label": "Серверы MCP", + "mode": { + "auto": { + "description": "ИИ самостоятельно обнаруживает и использует инструменты", + "label": "Авто" + }, + "disabled": { + "description": "Нет инструментов MCP", + "label": "Отключено" + }, + "manual": { + "description": "Выберите конкретные MCP-серверы", + "label": "Руководство" + } + }, "noServersAvailable": "Нет доступных серверов MCP. Добавьте серверы в настройках", "title": "Настройки MCP" }, @@ -653,6 +669,10 @@ "title": "Новый топик" } }, + "alerts": { + "create_session": "Создать сессию", + "select_agent": "Выберите агента" + }, "artifacts": { "button": { "download": "Скачать", @@ -1186,6 +1206,8 @@ "cancel": "Отмена", "chat": "Чат", "clear": "Очистить", + "clear_all": "Очистить всё", + "click_to_replace": "Щёлкните для замены", "close": "Закрыть", "collapse": "Свернуть", "completed": "Завершено", @@ -1220,9 +1242,15 @@ "footnote": "Цитируемый контент", "footnotes": "Сноски", "fullscreen": "Вы вошли в полноэкранный режим. Нажмите F11 для выхода", + "generate_random_seed": "Сгенерировать случайное зерно", + "get_embedding_dimension": "Получить размерность вложения", "go_to_settings": "Перейти в настройки", + "html_preview": "Предпросмотр HTML", "i_know": "Я понял", "ignore": "Игнорировать", + "image_preview": "Предпросмотр изображения", + "image_url": "URL изображения", + "image_url_or_upload": "Введите URL изображения или загрузите файл", "inspect": "Осмотреть", "invalid_value": "недопустимое значение", "knowledge_base": "База знаний", @@ -1232,6 +1260,7 @@ "models": "Модели", "more": "Ещё", "name": "Имя", + "next_match": "Следующий матч", "no_results": "Результатов не найдено", "none": "без", "off": "Выкл", @@ -1243,13 +1272,17 @@ "model": "Выбор модели" } }, + "powered_by": "С поддержкой", "preview": "Предварительный просмотр", + "previous_match": "Предыдущий матч", "prompt": "Промпт", "provider": "Провайдер", "reasoning_content": "Глубокий анализ", "refresh": "Обновить", "regenerate": "Пересоздать", + "remove_image": "Удалить изображение", "rename": "Переименовать", + "required_field": "Обязательное поле", "reset": "Сбросить", "save": "Сохранить", "saved": "Сохранено", @@ -1272,14 +1305,29 @@ "success": "Успешно", "swap": "Поменять местами", "topics": "Топики", + "translate_text": "Перевести текст", "unknown": "Неизвестно", "unnamed": "Без имени", "unsubscribe": "Отписаться", "update_success": "Обновление выполнено успешно", "upload_files": "Загрузить файл", + "upload_image": "Загрузить файл изображения", + "uploaded_image": "Загруженное изображение", "warning": "Предупреждение", "you": "Вы" }, + "dialog": { + "all_files": "Все файлы", + "html_files": "Файлы HTML", + "open_file": "Открыть файл", + "pdf_files": "Файлы PDF", + "png_image": "PNG-изображение", + "save_as_html": "Сохранить как HTML", + "save_as_pdf": "Сохранить как PDF", + "save_file": "Сохранить файл", + "select_folder": "Выбрать папку", + "word_document": "Документ Word" + }, "docs": { "title": "Документация" }, @@ -1297,6 +1345,7 @@ "backup": { "file_format": "Ошибка формата файла резервной копии" }, + "base64DataTruncated": "Данные изображения в формате Base64 усечены, размер", "boundary": { "default": { "devtools": "Открыть панель отладки", @@ -1377,6 +1426,8 @@ "text": "текст", "toolInput": "ввод инструмента", "toolName": "имя инструмента", + "truncated": "Данные усечены, исходный размер", + "truncatedBadge": "Усечённый", "unknown": "Неизвестная ошибка", "usage": "Дозировка", "user_message_not_found": "Не удалось найти исходное сообщение пользователя", @@ -1998,10 +2049,65 @@ "cancelled": "Отменено", "completed": "Завершено", "error": "Произошла ошибка", + "groupHeader": "{{count}} вызовов инструментов", "invoking": "Вызов", + "labels": { + "bash": "Баш", + "edit": "Редактировать", + "exitPlanMode": "РежимВыходаПлана", + "glob": "Глоб", + "grep": "Grep", + "mcpServerTool": "Инструмент сервера MCP", + "multiEdit": "МультиРедакт", + "notebookEdit": "РедакторБлокнота", + "readFile": "Прочитать файл", + "search": "Поиск", + "skill": "Навык", + "task": "Задача", + "todoWrite": "Написать", + "tool": "Инструмент", + "webFetch": "Веб-запрос", + "webSearch": "Поиск в интернете", + "write": "Написать" + }, + "noData": "Нет доступных данных для этого инструмента", "pending": "Ожидание", "preview": "Предпросмотр", - "raw": "Исходный" + "raw": "Исходный", + "runningCount": "{{count}} инструментов запущено", + "sections": { + "command": "Команда", + "exitCode": "Код выхода", + "input": "Ввод", + "output": "Вывод", + "prompt": "Запрос", + "searchQuery": "поисковый запрос", + "searchResults": "Результаты поиска", + "stderr": "stderr", + "stdout": "stdout" + }, + "status": { + "done": "Готово", + "error": "Ошибка", + "failed": "Не удалось", + "running": "Бег", + "success": "Успех" + }, + "truncated": "Вывод усечён (оригинал: {{size}})", + "units": { + "done_one": "{{count}} Готово", + "done_other": "{{count}} Готово", + "file_one": "{{count}} файл", + "file_other": "{{count}} файлов", + "item_one": "{{count}} элемент", + "item_other": "{{count}} предметов", + "line_one": "{{count}} строка", + "line_other": "{{count}} строк", + "plan_one": "{{count}} план", + "plan_other": "{{count}} планов", + "result_one": "{{count}} результат", + "result_other": "{{count}} результатов" + } }, "topic": { "added": "Новый топик добавлен" @@ -2209,14 +2315,18 @@ "drop_markdown_hint": "Перетащите сюда файлы или папки .md для импорта", "empty": "заметок пока нет", "expand": "развернуть", + "exportToWord": "Экспорт в Word", "export_failed": "Экспорт в базу знаний не выполнен", "export_knowledge": "Экспортировать заметки в базу знаний", "export_success": "Успешно экспортировано в базу знаний", + "export_to_word_failed": "Не удалось экспортировать в Word", "folder": "папка", "new_folder": "Новая папка", "new_note": "Создать заметку", "no_content_to_copy": "Нет контента для копирования", + "no_content_to_export": "Нет содержимого для экспорта", "no_file_selected": "Пожалуйста, выберите файл для загрузки", + "no_note_selected": "Пожалуйста, сначала выберите заметку", "no_valid_files": "Не загружен действительный файл", "open_folder": "Откройте внешнюю папку", "open_outside": "открыть снаружи", @@ -2422,6 +2532,39 @@ } }, "custom_size": "Пользовательский размер", + "dmxapi": { + "generating_tip": "Генерация с использованием официальной модели, ожидаемое время ожидания — 2–5 минут для достижения наилучших результатов. Пожалуйста, проверьте логи бэкенда DMXAPI для получения информации о стоимости этой операции.", + "style": ", Стиль:", + "style_types": { + "25d_animation": "2.5D анимация", + "3d_cartoon": "3D мультфильм", + "american_retro": "Американский ретро", + "baroque": "Барокко", + "cartoon_illustration": "Мультяшная иллюстрация", + "chinese_gongbi": "Китайский гунби", + "clay": "Глина", + "cyberpunk": "Киберпанк", + "felt": "Фетр", + "flat": "Плоский", + "fresh_anime": "Свежее аниме", + "ghibli": "Гибли", + "japanese_anime": "Японское аниме", + "little_people_book": "Книжка о маленьких людях", + "monet_garden": "Сад Моне", + "oil_painting": "Живопись маслом", + "pixar": "Пиксар", + "pixel_art": "Пиксель-арт", + "poetic_ancient": "Поэтическое древнее", + "psychedelic": "Психоделический", + "sketch": "Эскиз", + "street_art": "Уличное искусство", + "texture": "Текстура", + "ukiyo_e": "Укиё-э", + "watercolor": "Акварель", + "wood_carving": "Резьба по дереву", + "yarn_doll": "Кукла из пряжи" + } + }, "edit": { "image_file": "Изображение для редактирования", "magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта редактирования", @@ -2446,6 +2589,7 @@ "style_type_tip": "Стиль генерации изображений, доступен только для версий V_2 и выше", "width": "Ширина" }, + "generate_failed": "Не удалось сгенерировать изображение", "generated_image": "Сгенерированное изображение", "go_to_settings": "Перейти в настройки", "guidance_scale": "Масштаб руководства", @@ -2456,6 +2600,7 @@ "image_file_required": "Пожалуйста, сначала загрузите изображение", "image_file_retry": "Пожалуйста, сначала загрузите изображение", "image_handle_required": "Пожалуйста, сначала загрузите изображение.", + "image_mix_failed": "Не удалось объединить изображения", "image_placeholder": "Изображение недоступно", "image_retry": "Повторить", "image_size_options": { @@ -2465,6 +2610,7 @@ "inference_steps_tip": "Количество шагов вывода для выполнения. Больше шагов производят более высокое качество, но занимают больше времени", "input_image": "Входное изображение", "input_parameters": "Ввести параметры", + "invalid_image_url": "Неверный формат URL изображения", "learn_more": "Узнать больше", "magic_prompt_option": "Улучшение промпта", "mode": { @@ -2486,6 +2632,7 @@ "no_image_generation_model": "Нет доступных моделей изображения, пожалуйста, добавьте модель и установите тип конечной точки на {{endpoint_type}}", "number_images": "Количество изображений", "number_images_tip": "Количество изображений для генерации (1-4)", + "operation_failed": "Операция не удалась, повторите попытку позже", "paint_course": "Руководство / Учебник", "per_image": "за изображение", "per_images": "за изображения", @@ -2561,6 +2708,26 @@ "resemblance": "Сходство", "resemblance_tip": "Насколько близко результат увеличения к исходному изображению", "seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов" + }, + "zhipu": { + "custom_size_divisible": "Пользовательский размер должен быть кратен 16", + "custom_size_hint": "Ширина и высота должны быть от 512 до 2048 пикселей, кратны 16, а общее число пикселей не должно превышать 2^21 пиксель.", + "custom_size_pixels": "Общее количество пикселей пользовательского размера не может превышать 2 097 152", + "custom_size_range": "Пользовательский размер должен быть от 512 до 2048 пикселей", + "custom_size_required": "Пожалуйста, задайте пользовательскую ширину и высоту", + "image_sizes": { + "1024x1024_default": "1024x1024 (По умолчанию)", + "1152x864": "1152x864", + "1344x768": "1344x768", + "1440x720": "1440x720", + "720x1440": "720x1440", + "768x1344": "768x1344", + "864x1152": "864x1152" + }, + "quality_options": { + "hd": "HD", + "standard_default": "Стандарт (по умолчанию)" + } } }, "plugins": { @@ -2571,6 +2738,20 @@ "category": "категория", "commands": "команда", "confirm_uninstall": "Вы уверены, что хотите удалить {{name}}?", + "content_saved": "Содержимое плагина успешно сохранено", + "detail": { + "allowed_tools": "Разрешённые инструменты", + "author": "Автор", + "content": "Контент", + "description": "Описание", + "file": "Файл", + "installed": "Установлено", + "metadata": "Метаданные", + "size": "Размер", + "source": "Источник", + "tags": "Теги", + "tools": "Инструменты" + }, "install": "установка", "install_plugins_from_browser": "Просмотрите доступные плагины, чтобы начать работу", "installing": "Установка...", @@ -2921,6 +3102,11 @@ "summary": "Суммаризировать", "translate": "Перевести" }, + "prompt": { + "explain": "Пожалуйста, объясните следующее содержание. Требования: ответ на {{language}}; не включайте никаких пояснений к этому запросу, просто дайте ответ напрямую:", + "refine": "Пожалуйста, оптимизируйте или отполируйте содержимое пользовательского ввода, заключённое в XML-тег <INPUT>, сохраняя смысл и целостность исходного текста. Требования: ваш ответ должен быть на том же языке, что и пользовательский ввод; не включайте никаких объяснений этого запроса, просто выдайте результат напрямую; не выводите XML-теги, выводите оптимизированное содержимое напрямую:", + "summary": "Пожалуйста, кратко изложите следующий контент. Требования: ответить на {{language}}; не включать никаких пояснений к этому запросу, просто дать ответ напрямую." + }, "translate": { "smart_translate_tips": "Смарт-перевод: содержимое будет переведено на целевой язык; содержимое уже на целевом языке будет переведено на альтернативный язык" }, @@ -3098,6 +3284,10 @@ }, "settings": { "about": { + "careers": { + "button": "Вид", + "title": "Карьера" + }, "checkUpdate": { "available": "Обновить", "label": "Проверить обновления" @@ -4010,6 +4200,10 @@ "jsonModeHint": "Редактируйте JSON-форматирование конфигурации сервера MCP. Перед сохранением убедитесь, что формат правильный.", "jsonSaveError": "Не удалось сохранить конфигурацию JSON", "jsonSaveSuccess": "JSON конфигурация сохранена", + "lanyun": { + "description": "Lanyun Technology Cloud Platform MCP Service", + "name": "Ланьюнь Технологии" + }, "logoUrl": "URL логотипа", "logs": "Журналы", "longRunning": "Длительный режим работы", @@ -4106,6 +4300,14 @@ "getToken": "Получить API токен", "getTokenDescription": "Получите персональный API токен из вашей учетной записи", "noServersAvailable": "Нет доступных серверов MCP", + "providerDescriptions": { + "302ai": "302.Сервис MCP Платформы AI", + "bailian": "Сервис MCP платформы Alibaba Cloud Bailian", + "lanyun": "Lanyun Technology Cloud Platform MCP Service", + "mcprouter": "Платформа маршрутизатора MCP Сервис MCP", + "modelscope": "Служба MCP платформы ModelScope", + "tokenflux": "Сервис MCP платформы TokenFlux" + }, "selectProvider": "Выберите провайдера:", "setToken": "Введите ваш токен", "success": "Синхронизация серверов MCP успешна", @@ -4198,9 +4400,6 @@ "title": "Настройки сообщений", "use_serif_font": "Использовать serif шрифт" }, - "mineru": { - "api_key": "Mineru теперь предлагает ежедневную бесплатную квоту в 500 страниц, и вам не нужно вводить ключ." - }, "miniapps": { "cache_change_notice": "Изменения вступят в силу, когда количество открытых мини-приложений достигнет установленного значения", "cache_description": "Установить максимальное количество активных мини-приложений в памяти", @@ -4458,6 +4657,14 @@ } }, "options": { + "anthropic_cache": { + "cache_last_n": "Кэшировать последние N сообщений", + "cache_last_n_help": "Кэшировать последние N сообщений разговора (исключая системные сообщения)", + "cache_system": "Сообщение системы кэша", + "cache_system_help": "Кэшировать ли системный промпт", + "token_threshold": "Порог токена кэша", + "token_threshold_help": "Сообщения, превышающие это количество токенов, будут кэшироваться. Установите значение 0, чтобы отключить кэширование." + }, "array_content": { "help": "Поддерживает ли данный провайдер тип массива для поля content в сообщении", "label": "поддержка формата массива для содержимого сообщения" @@ -4578,6 +4785,9 @@ "title": "Удалить провайдер" }, "dmxapi": { + "platform_enterprise": "ssvip.DMXAPI.com (Корпоративный)", + "platform_international": "www.DMXAPI.com (Международный)", + "platform_official": "www.DMXAPI.cn (CNY)", "select_platform": "Выберите платформу" }, "docs_check": "Проверить", @@ -4737,10 +4947,8 @@ "image_provider": "Поставщик услуг OCR", "paddleocr": { "aistudio_access_token": "Токен доступа сообщества AI Studio", - "aistudio_url_label": "Сообщество AI Studio", "api_url": "URL API", - "serving_doc_url_label": "Документация по PaddleOCR Serving", - "tip": "Вы можете обратиться к официальной документации PaddleOCR, чтобы развернуть локальный сервис, либо развернуть облачный сервис в сообществе PaddlePaddle AI Studio. В последнем случае, пожалуйста, предоставьте токен доступа сообщества AI Studio." + "api_url_label": "Получение токена доступа и URL API" }, "system": { "win": { @@ -4753,6 +4961,12 @@ "title": "OCR-сервис" }, "preprocess": { + "paddleocr": { + "aistudio_access_token": "Токен доступа к сообществу AI Studio", + "api_url": "URL API", + "api_url_label": "Получение токена доступа и URL API", + "paddleocr_url_label": "Официальный сайт PaddleOCR" + }, "provider": "Поставщик обработки документов", "provider_placeholder": "Выберите поставщика услуг обработки документов", "title": "Обработка документов", @@ -4914,6 +5128,13 @@ "show": "Показать значок в трее", "title": "Трей" }, + "use_system_title_bar": { + "confirm": { + "content": "Изменение стиля заголовка требует перезапуска приложения. Перезапустить сейчас?", + "title": "Требуется перезагрузка" + }, + "title": "Использовать системную строку заголовка (Linux)" + }, "zoom": { "reset": "Сбросить", "title": "Масштаб страницы" @@ -4991,6 +5212,8 @@ "detected": { "language": "Автоматическое обнаружение" }, + "detected_source": "Обнаружено", + "detecting": "Обнаружение...", "empty": "Содержимое перевода пусто", "error": { "chat_qwen_mt": "Модель Qwen MT недоступна для использования в диалоге, перейдите на страницу перевода", @@ -5043,6 +5266,7 @@ "not_pair": "Исходный язык отличается от настроенного", "same": "Исходный и целевой языки совпадают" }, + "language_settings": "Языковые настройки", "menu": { "description": "Перевести содержимое текущего ввода" }, @@ -5052,6 +5276,7 @@ "output": { "placeholder": "Перевод" }, + "preferred_target": "Предпочтительная цель", "processing": "Перевод в процессе...", "settings": { "autoCopy": "Автоматически копировать после завершения перевода", diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx index fcb2dbf482..a19ad70060 100644 --- a/src/renderer/src/pages/code/CodeToolsPage.tsx +++ b/src/renderer/src/pages/code/CodeToolsPage.tsx @@ -86,6 +86,11 @@ const CodeToolsPage: FC = () => { if (m.provider === 'silicon') { return isSiliconAnthropicCompatibleModel(m.id) } + // Check if model belongs to an anthropic type provider (including custom providers) + const anthropicProvider = providers.find((p) => p.id === m.provider) + if (anthropicProvider?.type === 'anthropic') { + return true + } return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider) } @@ -102,6 +107,11 @@ const CodeToolsPage: FC = () => { m.supported_endpoint_types?.includes(type as EndpointType) ) } + // Check if model belongs to an openai-response type provider (including custom providers) + const openaiProvider = providers.find((p) => p.id === m.provider) + if (openaiProvider?.type === 'openai-response') { + return true + } return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider) } @@ -120,7 +130,7 @@ const CodeToolsPage: FC = () => { return true }, - [selectedCliTool] + [selectedCliTool, providers] ) const availableProviders = useMemo(() => { @@ -215,10 +225,12 @@ const CodeToolsPage: FC = () => { } // 准备启动环境 - const prepareLaunchEnvironment = async (): Promise<Record<string, string> | null> => { + const prepareLaunchEnvironment = async (): Promise<{ + env: Record<string, string> + } | null> => { if (selectedCliTool === codeTools.githubCopilotCli) { const userEnv = parseEnvironmentVariables(environmentVariables) - return userEnv + return { env: userEnv } } if (!selectedModel) return null @@ -229,7 +241,7 @@ const CodeToolsPage: FC = () => { const apiKey = aiProvider.getApiKey() // 生成工具特定的环境变量 - const toolEnv = generateToolEnvironment({ + const { env: toolEnv } = generateToolEnvironment({ tool: selectedCliTool, model: selectedModel, modelProvider, @@ -240,7 +252,7 @@ const CodeToolsPage: FC = () => { // 合并用户自定义的环境变量 const userEnv = parseEnvironmentVariables(environmentVariables) - return { ...toolEnv, ...userEnv } + return { env: { ...toolEnv, ...userEnv } } } // 执行启动操作 @@ -291,13 +303,13 @@ const CodeToolsPage: FC = () => { setIsLaunching(true) try { - const env = await prepareLaunchEnvironment() - if (!env) { + const result = await prepareLaunchEnvironment() + if (!result) { window.toast.error(t('code.model_required')) return } - await executeLaunch(env) + await executeLaunch(result.env) } catch (error) { logger.error('start code tools failed:', error as Error) window.toast.error(t('code.launch.error')) diff --git a/src/renderer/src/pages/code/__tests__/index.test.ts b/src/renderer/src/pages/code/__tests__/index.test.ts new file mode 100644 index 0000000000..f781280c02 --- /dev/null +++ b/src/renderer/src/pages/code/__tests__/index.test.ts @@ -0,0 +1,231 @@ +import type { Model, Provider } from '@renderer/types' +import { codeTools } from '@shared/config/constant' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock CodeToolsPage which is the default export +vi.mock('../CodeToolsPage', () => ({ default: () => null })) + +// Mock dependencies needed by CodeToolsPage +vi.mock('@renderer/hooks/useCodeTools', () => ({ + useCodeTools: () => ({ + selectedCliTool: codeTools.qwenCode, + selectedModel: null, + selectedTerminal: 'systemDefault', + environmentVariables: '', + directories: [], + currentDirectory: '', + canLaunch: true, + setCliTool: vi.fn(), + setModel: vi.fn(), + setTerminal: vi.fn(), + setEnvVars: vi.fn(), + setCurrentDir: vi.fn(), + removeDir: vi.fn(), + selectFolder: vi.fn() + }) +})) + +vi.mock('@renderer/hooks/useProvider', () => ({ + useProviders: () => ({ providers: [] }), + useAllProviders: () => [] +})) + +vi.mock('@renderer/services/AssistantService', () => ({ + getProviderByModel: vi.fn() +})) + +vi.mock('@renderer/services/LoggerService', () => ({ + loggerService: { + withContext: () => ({ + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + warn: vi.fn() + }) + } +})) + +vi.mock('@renderer/store', () => ({ + useAppDispatch: () => vi.fn(), + useAppSelector: () => false +})) + +vi.mock('@renderer/aiCore', () => ({ + default: class { + getBaseURL() { + return '' + } + getApiKey() { + return '' + } + } +})) + +vi.mock('@renderer/utils/api', () => ({ + formatApiHost: vi.fn((host) => { + if (!host) return '' + const normalized = host.replace(/\/$/, '').trim() + if (normalized.endsWith('#')) { + return normalized.replace(/#$/, '') + } + if (/\/v\d+(?:alpha|beta)?(?=\/|$)/i.test(normalized)) { + return normalized + } + return `${normalized}/v1` + }) +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +describe('generateToolEnvironment', () => { + const createMockModel = (id: string, provider: string): Model => ({ + id, + name: id, + provider, + group: provider + }) + + const createMockProvider = (id: string, apiHost: string): Provider => ({ + id, + type: 'openai', + name: id, + apiKey: 'test-key', + apiHost, + models: [], + isSystem: true + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should format baseUrl with /v1 for qwenCode when missing', async () => { + const { generateToolEnvironment } = await import('../index') + const model = createMockModel('qwen-turbo', 'dashscope') + const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/compatible-mode') + + const { env } = generateToolEnvironment({ + tool: codeTools.qwenCode, + model, + modelProvider: provider, + apiKey: 'test-key', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode' + }) + + expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1') + }) + + it('should not duplicate /v1 when already present for qwenCode', async () => { + const { generateToolEnvironment } = await import('../index') + const model = createMockModel('qwen-turbo', 'dashscope') + const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/compatible-mode/v1') + + const { env } = generateToolEnvironment({ + tool: codeTools.qwenCode, + model, + modelProvider: provider, + apiKey: 'test-key', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' + }) + + expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1') + }) + + it('should handle empty baseUrl gracefully', async () => { + const { generateToolEnvironment } = await import('../index') + const model = createMockModel('qwen-turbo', 'dashscope') + const provider = createMockProvider('dashscope', '') + + const { env } = generateToolEnvironment({ + tool: codeTools.qwenCode, + model, + modelProvider: provider, + apiKey: 'test-key', + baseUrl: '' + }) + + expect(env.OPENAI_BASE_URL).toBe('') + }) + + it('should preserve other API versions when present', async () => { + const { generateToolEnvironment } = await import('../index') + const model = createMockModel('qwen-plus', 'dashscope') + const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/v2') + + const { env } = generateToolEnvironment({ + tool: codeTools.qwenCode, + model, + modelProvider: provider, + apiKey: 'test-key', + baseUrl: 'https://dashscope.aliyuncs.com/v2' + }) + + expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/v2') + }) + + it('should format baseUrl with /v1 for openaiCodex when missing', async () => { + const { generateToolEnvironment } = await import('../index') + const model = createMockModel('gpt-4', 'openai') + const provider = createMockProvider('openai', 'https://api.openai.com') + + const { env } = generateToolEnvironment({ + tool: codeTools.openaiCodex, + model, + modelProvider: provider, + apiKey: 'test-key', + baseUrl: 'https://api.openai.com' + }) + + expect(env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1') + }) + + it('should format baseUrl with /v1 for iFlowCli when missing', async () => { + const { generateToolEnvironment } = await import('../index') + const model = createMockModel('gpt-4', 'iflow') + const provider = createMockProvider('iflow', 'https://api.iflow.cn') + + const { env } = generateToolEnvironment({ + tool: codeTools.iFlowCli, + model, + modelProvider: provider, + apiKey: 'test-key', + baseUrl: 'https://api.iflow.cn' + }) + + expect(env.IFLOW_BASE_URL).toBe('https://api.iflow.cn/v1') + }) + + it('should handle trailing slash correctly', async () => { + const { generateToolEnvironment } = await import('../index') + const model = createMockModel('qwen-turbo', 'dashscope') + const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/compatible-mode/') + + const { env } = generateToolEnvironment({ + tool: codeTools.qwenCode, + model, + modelProvider: provider, + apiKey: 'test-key', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/' + }) + + expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1') + }) + + it('should handle v2beta version correctly', async () => { + const { generateToolEnvironment } = await import('../index') + const model = createMockModel('qwen-plus', 'dashscope') + const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/v2beta') + + const { env } = generateToolEnvironment({ + tool: codeTools.qwenCode, + model, + modelProvider: provider, + apiKey: 'test-key', + baseUrl: 'https://dashscope.aliyuncs.com/v2beta' + }) + + expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/v2beta') + }) +}) diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index 81f5ddddc3..53b3031ffb 100644 --- a/src/renderer/src/pages/code/index.ts +++ b/src/renderer/src/pages/code/index.ts @@ -1,4 +1,5 @@ import { type EndpointType, type Model, type Provider, SystemProviderIds } from '@renderer/types' +import { formatApiHost } from '@renderer/utils/api' import { codeTools } from '@shared/config/constant' export interface LaunchValidationResult { @@ -21,7 +22,8 @@ export const CLI_TOOLS = [ { value: codeTools.geminiCli, label: 'Gemini CLI' }, { value: codeTools.openaiCodex, label: 'OpenAI Codex' }, { value: codeTools.iFlowCli, label: 'iFlow CLI' }, - { value: codeTools.githubCopilotCli, label: 'GitHub Copilot CLI' } + { value: codeTools.githubCopilotCli, label: 'GitHub Copilot CLI' }, + { value: codeTools.kimiCli, label: 'Kimi CLI' } ] export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin'] @@ -56,9 +58,10 @@ export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Pr providers.filter((p) => p.type === 'gemini' || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)), [codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')), [codeTools.openaiCodex]: (providers) => - providers.filter((p) => p.id === 'openai' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)), + providers.filter((p) => p.type === 'openai-response' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)), [codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai')), - [codeTools.githubCopilotCli]: () => [] + [codeTools.githubCopilotCli]: () => [], + [codeTools.kimiCli]: (providers) => providers.filter((p) => p.type.includes('openai')) } export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => { @@ -143,8 +146,9 @@ export const generateToolEnvironment = ({ modelProvider: Provider apiKey: string baseUrl: string -}): Record<string, string> => { +}): { env: Record<string, string> } => { const env: Record<string, string> = {} + const formattedBaseUrl = formatApiHost(baseUrl) switch (tool) { case codeTools.claudeCode: @@ -169,28 +173,35 @@ export const generateToolEnvironment = ({ case codeTools.qwenCode: env.OPENAI_API_KEY = apiKey - env.OPENAI_BASE_URL = baseUrl + env.OPENAI_BASE_URL = formattedBaseUrl env.OPENAI_MODEL = model.id break case codeTools.openaiCodex: env.OPENAI_API_KEY = apiKey - env.OPENAI_BASE_URL = baseUrl + env.OPENAI_BASE_URL = formattedBaseUrl env.OPENAI_MODEL = model.id env.OPENAI_MODEL_PROVIDER = modelProvider.id + env.OPENAI_MODEL_PROVIDER_NAME = modelProvider.name break case codeTools.iFlowCli: env.IFLOW_API_KEY = apiKey - env.IFLOW_BASE_URL = baseUrl + env.IFLOW_BASE_URL = formattedBaseUrl env.IFLOW_MODEL_NAME = model.id break case codeTools.githubCopilotCli: env.GITHUB_TOKEN = apiKey || '' break + + case codeTools.kimiCli: + env.KIMI_API_KEY = apiKey + env.KIMI_BASE_URL = formattedBaseUrl + env.KIMI_MODEL_NAME = model.id + break } - return env + return { env } } export { default } from './CodeToolsPage' diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index fb24d55d65..7036158e3e 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -163,17 +163,17 @@ const Chat: FC<Props> = (props) => { // TODO: more info const AgentInvalid = useCallback(() => { - return <Alert type="warning" message="Select an agent" style={{ margin: '5px 16px' }} /> - }, []) + return <Alert type="warning" message={t('chat.alerts.select_agent')} style={{ margin: '5px 16px' }} /> + }, [t]) // TODO: more info const SessionInvalid = useCallback(() => { return ( <div className="flex h-full w-full items-center justify-center"> - <Alert type="warning" message="Create a session" style={{ margin: '5px 16px' }} /> + <Alert type="warning" message={t('chat.alerts.create_session')} style={{ margin: '5px 16px' }} /> </div> ) - }, []) + }, [t]) return ( <Container id="chat" className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}> diff --git a/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx b/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx index 850be7f727..c229e3e2cf 100644 --- a/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx @@ -427,10 +427,24 @@ const AgentSessionInputbarInner: FC<InnerProps> = ({ assistant, agentId, session // Clear text after successful send (draft is cleared automatically via onChange) setText('') setTimeoutTimer('agentSession_sendMessage', () => setText(''), 500) + // Restore focus to textarea after sending to maintain IME state (fcitx5 issue) + focusTextarea() } catch (error) { logger.warn('Failed to send message:', error as Error) } - }, [sendDisabled, agentId, dispatch, assistant, sessionId, sessionTopicId, setText, setTimeoutTimer, text, files]) + }, [ + sendDisabled, + agentId, + dispatch, + assistant, + sessionId, + sessionTopicId, + setText, + setTimeoutTimer, + text, + files, + focusTextarea + ]) useEffect(() => { if (!document.querySelector('.topview-fullscreen-container')) { diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index fc95082e50..c567108820 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -260,11 +260,25 @@ const InputbarInner: FC<InputbarInnerProps> = ({ assistant: initialAssistant, se setFiles([]) setTimeoutTimer('sendMessage_1', () => setText(''), 500) setTimeoutTimer('sendMessage_2', () => resizeTextArea(), 0) + // Restore focus to textarea after sending to maintain IME state (fcitx5 issue) + focusTextarea() } catch (error) { logger.warn('Failed to send message:', error as Error) parent?.recordException(error as Error) } - }, [assistant, topic, text, mentionedModels, files, dispatch, setText, setFiles, setTimeoutTimer, resizeTextArea]) + }, [ + assistant, + topic, + text, + mentionedModels, + files, + dispatch, + setText, + setFiles, + setTimeoutTimer, + resizeTextArea, + focusTextarea + ]) const tokenCountProps = useMemo(() => { if (!config.showTokenCount || estimateTokenCount === undefined || !showInputEstimatedTokens) { diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx index 0b0610e1d6..a54af49cb4 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx @@ -8,11 +8,12 @@ import { useTimer } from '@renderer/hooks/useTimer' import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types' import { getProviderByModel } from '@renderer/services/AssistantService' import { EventEmitter } from '@renderer/services/EventService' -import type { MCPPrompt, MCPResource, MCPServer } from '@renderer/types' +import type { McpMode, MCPPrompt, MCPResource, MCPServer } from '@renderer/types' +import { getEffectiveMcpMode } from '@renderer/types' import { isToolUseModeFunction } from '@renderer/utils/assistant' import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/utils/provider' import { Form, Input, Tooltip } from 'antd' -import { CircleX, Hammer, Plus } from 'lucide-react' +import { CircleX, Hammer, Plus, Sparkles } from 'lucide-react' import type { FC } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -25,7 +26,6 @@ interface Props { resizeTextArea: () => void } -// 添加类型定义 interface PromptArgument { name: string description?: string @@ -44,24 +44,19 @@ interface ResourceData { uri?: string } -// 提取到组件外的工具函数 const extractPromptContent = (response: any): string | null => { - // Handle string response (backward compatibility) if (typeof response === 'string') { return response } - // Handle GetMCPPromptResponse format if (response && Array.isArray(response.messages)) { let formattedContent = '' for (const message of response.messages) { if (!message.content) continue - // Add role prefix if available const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : '' - // Process different content types switch (message.content.type) { case 'text': formattedContent += `${rolePrefix}${message.content.text}\n\n` @@ -98,7 +93,6 @@ const extractPromptContent = (response: any): string | null => { return formattedContent.trim() } - // Fallback handling for single message format if (response && response.messages && response.messages.length > 0) { const message = response.messages[0] if (message.content && message.content.text) { @@ -121,7 +115,6 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, const model = assistant.model const { setTimeoutTimer } = useTimer() - // 使用 useRef 存储不需要触发重渲染的值 const isMountedRef = useRef(true) useEffect(() => { @@ -130,11 +123,30 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, } }, []) + const currentMode = useMemo(() => getEffectiveMcpMode(assistant), [assistant]) + const mcpServers = useMemo(() => assistant.mcpServers || [], [assistant.mcpServers]) const assistantMcpServers = useMemo( () => activedMcpServers.filter((server) => mcpServers.some((s) => s.id === server.id)), [activedMcpServers, mcpServers] ) + + const handleModeChange = useCallback( + (mode: McpMode) => { + setTimeoutTimer( + 'updateMcpMode', + () => { + updateAssistant({ + ...assistant, + mcpMode: mode + }) + }, + 200 + ) + }, + [assistant, setTimeoutTimer, updateAssistant] + ) + const handleMcpServerSelect = useCallback( (server: MCPServer) => { const update = { ...assistant } @@ -144,29 +156,24 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, update.mcpServers = [...mcpServers, server] } - // only for gemini if (update.mcpServers.length > 0 && isGeminiModel(model) && isToolUseModeFunction(assistant)) { const provider = getProviderByModel(model) if (isSupportUrlContextProvider(provider) && assistant.enableUrlContext) { window.toast.warning(t('chat.mcp.warning.url_context')) update.enableUrlContext = false } - if ( - // 非官方 API (openrouter etc.) 可能支持同时启用内置搜索和函数调用 - // 这里先假设 gemini type 和 vertexai type 不支持 - isGeminiWebSearchProvider(provider) && - assistant.enableWebSearch - ) { + if (isGeminiWebSearchProvider(provider) && assistant.enableWebSearch) { window.toast.warning(t('chat.mcp.warning.gemini_web_search')) update.enableWebSearch = false } } + + update.mcpMode = 'manual' updateAssistant(update) }, [assistant, assistantMcpServers, mcpServers, model, t, updateAssistant] ) - // 使用 useRef 缓存事件处理函数 const handleMcpServerSelectRef = useRef(handleMcpServerSelect) handleMcpServerSelectRef.current = handleMcpServerSelect @@ -176,23 +183,7 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, return () => EventEmitter.off('mcp-server-select', handler) }, []) - const updateMcpEnabled = useCallback( - (enabled: boolean) => { - setTimeoutTimer( - 'updateMcpEnabled', - () => { - updateAssistant({ - ...assistant, - mcpServers: enabled ? assistant.mcpServers || [] : [] - }) - }, - 200 - ) - }, - [assistant, setTimeoutTimer, updateAssistant] - ) - - const menuItems = useMemo(() => { + const manualModeMenuItems = useMemo(() => { const newList: QuickPanelListItem[] = activedMcpServers.map((server) => ({ label: server.name, description: server.description || server.baseUrl, @@ -207,33 +198,70 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, action: () => navigate('/settings/mcp') }) - newList.unshift({ - label: t('settings.input.clear.all'), - description: t('settings.mcp.disable.description'), - icon: <CircleX />, - isSelected: false, - action: () => { - updateMcpEnabled(false) - quickPanelHook.close() - } - }) - return newList - }, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanelHook]) + }, [activedMcpServers, t, assistantMcpServers, navigate]) - const openQuickPanel = useCallback(() => { + const openManualModePanel = useCallback(() => { quickPanelHook.open({ - title: t('settings.mcp.title'), - list: menuItems, + title: t('assistants.settings.mcp.mode.manual.label'), + list: manualModeMenuItems, symbol: QuickPanelReservedSymbol.Mcp, multiple: true, afterAction({ item }) { item.isSelected = !item.isSelected } }) + }, [manualModeMenuItems, quickPanelHook, t]) + + const menuItems = useMemo(() => { + const newList: QuickPanelListItem[] = [] + + newList.push({ + label: t('assistants.settings.mcp.mode.disabled.label'), + description: t('assistants.settings.mcp.mode.disabled.description'), + icon: <CircleX />, + isSelected: currentMode === 'disabled', + action: () => { + handleModeChange('disabled') + quickPanelHook.close() + } + }) + + newList.push({ + label: t('assistants.settings.mcp.mode.auto.label'), + description: t('assistants.settings.mcp.mode.auto.description'), + icon: <Sparkles />, + isSelected: currentMode === 'auto', + action: () => { + handleModeChange('auto') + quickPanelHook.close() + } + }) + + newList.push({ + label: t('assistants.settings.mcp.mode.manual.label'), + description: t('assistants.settings.mcp.mode.manual.description'), + icon: <Hammer />, + isSelected: currentMode === 'manual', + isMenu: true, + action: () => { + handleModeChange('manual') + openManualModePanel() + } + }) + + return newList + }, [t, currentMode, handleModeChange, quickPanelHook, openManualModePanel]) + + const openQuickPanel = useCallback(() => { + quickPanelHook.open({ + title: t('settings.mcp.title'), + list: menuItems, + symbol: QuickPanelReservedSymbol.Mcp, + multiple: false + }) }, [menuItems, quickPanelHook, t]) - // 使用 useCallback 优化 insertPromptIntoTextArea const insertPromptIntoTextArea = useCallback( (promptText: string) => { setInputValue((prev) => { @@ -245,7 +273,6 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, const selectionEndPosition = cursorPosition + promptText.length const newText = prev.slice(0, cursorPosition) + promptText + prev.slice(cursorPosition) - // 使用 requestAnimationFrame 优化 DOM 操作 requestAnimationFrame(() => { textArea.focus() textArea.setSelectionRange(selectionStart, selectionEndPosition) @@ -424,7 +451,6 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, [activedMcpServers, t, insertPromptIntoTextArea] ) - // 优化 resourcesList 的状态更新 const [resourcesList, setResourcesList] = useState<QuickPanelListItem[]>([]) useEffect(() => { @@ -514,17 +540,26 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, } }, [openPromptList, openQuickPanel, openResourcesList, quickPanel, t]) + const isActive = currentMode !== 'disabled' + + const getButtonIcon = () => { + switch (currentMode) { + case 'auto': + return <Sparkles size={18} /> + case 'disabled': + case 'manual': + default: + return <Hammer size={18} /> + } + } + return ( <Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow> - <ActionIconButton - onClick={handleOpenQuickPanel} - active={assistant.mcpServers && assistant.mcpServers.length > 0} - aria-label={t('settings.mcp.title')}> - <Hammer size={18} /> + <ActionIconButton onClick={handleOpenQuickPanel} active={isActive} aria-label={t('settings.mcp.title')}> + {getButtonIcon()} </ActionIconButton> </Tooltip> ) } -// 使用 React.memo 包装组件 export default React.memo(MCPToolsButton) diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/UrlContextbutton.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/UrlContextbutton.tsx index 2c2b5077a7..aa8aac3bf7 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/UrlContextbutton.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/UrlContextbutton.tsx @@ -1,6 +1,7 @@ import { ActionIconButton } from '@renderer/components/Buttons' import { useAssistant } from '@renderer/hooks/useAssistant' import { useTimer } from '@renderer/hooks/useTimer' +import { getEffectiveMcpMode } from '@renderer/types' import { isToolUseModeFunction } from '@renderer/utils/assistant' import { Tooltip } from 'antd' import { Link } from 'lucide-react' @@ -30,8 +31,7 @@ const UrlContextButton: FC<Props> = ({ assistantId }) => { () => { const update = { ...assistant } if ( - assistant.mcpServers && - assistant.mcpServers.length > 0 && + getEffectiveMcpMode(assistant) !== 'disabled' && urlContentNewState === true && isToolUseModeFunction(assistant) ) { diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx index 5728887af8..262b1f0492 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx @@ -16,7 +16,7 @@ import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders' import type { ToolQuickPanelController, ToolRenderContext } from '@renderer/pages/home/Inputbar/types' import { getProviderByModel } from '@renderer/services/AssistantService' import WebSearchService from '@renderer/services/WebSearchService' -import type { WebSearchProvider, WebSearchProviderId } from '@renderer/types' +import { getEffectiveMcpMode, type WebSearchProvider, type WebSearchProviderId } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' import { isToolUseModeFunction } from '@renderer/utils/assistant' import { isPromptToolUse } from '@renderer/utils/mcp-tools' @@ -108,8 +108,7 @@ export const useWebSearchPanelController = (assistantId: string, quickPanelContr isGeminiModel(model) && isToolUseModeFunction(assistant) && update.enableWebSearch && - assistant.mcpServers && - assistant.mcpServers.length > 0 + getEffectiveMcpMode(assistant) !== 'disabled' ) { update.enableWebSearch = false window.toast.warning(t('chat.mcp.warning.gemini_web_search')) diff --git a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx index 43e06790a3..fa3cfd42c9 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx @@ -1,80 +1,19 @@ -import CodeViewer from '@renderer/components/CodeViewer' -import { useCodeStyle } from '@renderer/context/CodeStyleProvider' +import { ErrorDetailModal } from '@renderer/components/ErrorDetailModal' import { useTimer } from '@renderer/hooks/useTimer' import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label' import { getProviderById } from '@renderer/services/ProviderService' import { useAppDispatch } from '@renderer/store' import { removeBlocksThunk } from '@renderer/store/thunk/messageThunk' -import type { SerializedAiSdkError, SerializedAiSdkErrorUnion, SerializedError } from '@renderer/types/error' -import { - isSerializedAiSdkAPICallError, - isSerializedAiSdkDownloadError, - isSerializedAiSdkError, - isSerializedAiSdkErrorUnion, - isSerializedAiSdkInvalidArgumentError, - isSerializedAiSdkInvalidDataContentError, - isSerializedAiSdkInvalidMessageRoleError, - isSerializedAiSdkInvalidPromptError, - isSerializedAiSdkInvalidToolInputError, - isSerializedAiSdkJSONParseError, - isSerializedAiSdkMessageConversionError, - isSerializedAiSdkNoObjectGeneratedError, - isSerializedAiSdkNoSpeechGeneratedError, - isSerializedAiSdkNoSuchModelError, - isSerializedAiSdkNoSuchProviderError, - isSerializedAiSdkNoSuchToolError, - isSerializedAiSdkRetryError, - isSerializedAiSdkToolCallRepairError, - isSerializedAiSdkTooManyEmbeddingValuesForCallError, - isSerializedAiSdkTypeValidationError, - isSerializedAiSdkUnsupportedFunctionalityError, - isSerializedError -} from '@renderer/types/error' import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage' -import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error' -import { formatFileSize } from '@renderer/utils/file' -import { KB } from '@shared/config/constant' import { Button } from 'antd' -import { Alert as AntdAlert, Modal } from 'antd' -import React, { useEffect, useState } from 'react' +import { Alert as AntdAlert } from 'antd' +import React, { useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import styled from 'styled-components' const HTTP_ERROR_CODES = [400, 401, 403, 404, 429, 500, 502, 503, 504] -const MAX_DISPLAY_SIZE = 100 * KB - -/** - * Truncate large data to prevent OOM when displaying error details. - * Uses simple string operations to avoid regex performance issues with large strings. - */ -const truncateLargeData = ( - data: string, - t: (key: string) => string -): { content: string; truncated: boolean; isLikelyBase64: boolean } => { - if (!data || data.length <= MAX_DISPLAY_SIZE) { - return { content: data, truncated: false, isLikelyBase64: false } - } - - const isLikelyBase64 = data.includes('data:image/') && data.includes(';base64,') - const formattedSize = formatFileSize(data.length) - - if (isLikelyBase64) { - return { - content: `[${t('error.base64DataTruncated')} ~${formattedSize}]`, - truncated: true, - isLikelyBase64: true - } - } - - return { - content: data.slice(0, MAX_DISPLAY_SIZE) + `\n\n... [${t('error.truncated')} ${formattedSize}]`, - truncated: true, - isLikelyBase64: false - } -} - interface Props { block: ErrorMessageBlock message: Message @@ -190,115 +129,6 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }> ) } -interface ErrorDetailModalProps { - open: boolean - onClose: () => void - error?: SerializedError -} - -const ErrorDetailModal: React.FC<ErrorDetailModalProps> = ({ open, onClose, error }) => { - const { t } = useTranslation() - - const copyErrorDetails = () => { - if (!error) return - let errorText: string - if (isSerializedAiSdkError(error)) { - errorText = formatAiSdkError(error) - } else if (isSerializedError(error)) { - errorText = formatError(error) - } else { - // fallback - errorText = safeToString(error) - } - - navigator.clipboard.writeText(errorText) - window.toast.addToast({ title: t('message.copied') }) - } - - const renderErrorDetails = (error?: SerializedError) => { - if (!error) return <div>{t('error.unknown')}</div> - if (isSerializedAiSdkErrorUnion(error)) { - return <AiSdkError error={error} /> - } - return ( - <ErrorDetailList> - <BuiltinError error={error} /> - </ErrorDetailList> - ) - } - - return ( - <Modal - centered - title={t('error.detail')} - open={open} - onCancel={onClose} - footer={[ - <Button key="copy" variant="text" color="default" onClick={copyErrorDetails}> - {t('common.copy')} - </Button>, - <Button key="close" variant="text" color={'default'} onClick={onClose}> - {t('common.close')} - </Button> - ]} - width="80%" - style={{ maxWidth: '1200px', minWidth: '600px' }}> - <ErrorDetailContainer>{renderErrorDetails(error)}</ErrorDetailContainer> - </Modal> - ) -} - -const ErrorDetailContainer = styled.div` - max-height: 60vh; - overflow-y: auto; -` - -const ErrorDetailList = styled.div` - display: flex; - flex-direction: column; - gap: 16px; -` - -const ErrorDetailItem = styled.div` - display: flex; - flex-direction: column; - gap: 8px; -` - -const ErrorDetailLabel = styled.div` - font-weight: 600; - color: var(--color-text); - font-size: 14px; -` - -const ErrorDetailValue = styled.div` - font-family: var(--code-font-family); - font-size: 12px; - padding: 8px; - background: var(--color-code-background); - border-radius: 4px; - border: 1px solid var(--color-border); - word-break: break-word; - color: var(--color-text); -` - -const StackTrace = styled.div` - background: var(--color-background-soft); - border: 1px solid var(--color-error); - border-radius: 6px; - padding: 12px; - - pre { - margin: 0; - white-space: pre-wrap; - word-break: break-word; - font-family: var(--code-font-family); - font-size: 12px; - line-height: 1.4; - color: var(--color-error); - } -` - const Alert = styled(AntdAlert)` margin: 0.5rem 0 !important; padding: 10px; @@ -309,404 +139,4 @@ const Alert = styled(AntdAlert)` } ` -const TruncatedBadge = styled.span` - margin-left: 8px; - padding: 2px 6px; - font-size: 10px; - font-weight: normal; - color: var(--color-warning); - background: var(--color-warning-bg, rgba(250, 173, 20, 0.1)); - border-radius: 4px; -` - -// 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染 -const BuiltinError = ({ error }: { error: SerializedError }) => { - const { t } = useTranslation() - return ( - <> - {error.name && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.name')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.name}</ErrorDetailValue> - </ErrorDetailItem> - )} - {error.message && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.message')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.message}</ErrorDetailValue> - </ErrorDetailItem> - )} - {error.stack && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.stack')}:</ErrorDetailLabel> - <StackTrace> - <pre>{error.stack}</pre> - </StackTrace> - </ErrorDetailItem> - )} - </> - ) -} - -// Base component to render common fields, should be rendered inside ErrorDetailList -const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => { - const { t } = useTranslation() - const { highlightCode } = useCodeStyle() - const [highlightedString, setHighlightedString] = useState('') - const [isTruncated, setIsTruncated] = useState(false) - const cause = error.cause - - useEffect(() => { - const highlight = async () => { - try { - // Truncate large data before processing to prevent OOM - const { content: truncatedCause, truncated, isLikelyBase64 } = truncateLargeData(cause || '', t) - setIsTruncated(truncated) - - // Skip JSON parsing and syntax highlighting for base64 data - if (isLikelyBase64) { - setHighlightedString(truncatedCause) - return - } - - // Try to parse and format JSON - try { - const parsed = JSON.parse(truncatedCause || '{}') - const formatted = JSON.stringify(parsed, null, 2) - const result = await highlightCode(formatted, 'json') - setHighlightedString(result) - } catch { - // If not valid JSON, use as-is - setHighlightedString(truncatedCause || '') - } - } catch { - setHighlightedString(cause || '') - } - } - const timer = setTimeout(highlight, 0) - - return () => clearTimeout(timer) - }, [highlightCode, cause, t]) - - return ( - <> - <BuiltinError error={error} /> - {cause && ( - <ErrorDetailItem> - <ErrorDetailLabel> - {t('error.cause')}:{isTruncated && <TruncatedBadge>{t('error.truncatedBadge')}</TruncatedBadge>} - </ErrorDetailLabel> - <ErrorDetailValue> - <div - className="markdown [&_pre]:!bg-transparent [&_pre_span]:whitespace-pre-wrap" - dangerouslySetInnerHTML={{ __html: highlightedString }} - /> - </ErrorDetailValue> - </ErrorDetailItem> - )} - </> - ) -} - -// Wrapper component to safely display potentially large data in CodeViewer -const TruncatedCodeViewer: React.FC<{ - value: string - label: string - language?: string -}> = ({ value, label, language = 'json' }) => { - const { t } = useTranslation() - const { content, truncated, isLikelyBase64 } = truncateLargeData(value, t) - - return ( - <ErrorDetailItem> - <ErrorDetailLabel> - {label}:{truncated && <TruncatedBadge>{t('error.truncatedBadge')}</TruncatedBadge>} - </ErrorDetailLabel> - {isLikelyBase64 ? ( - <ErrorDetailValue>{content}</ErrorDetailValue> - ) : ( - <CodeViewer value={content} className="source-view" language={language} expanded /> - )} - </ErrorDetailItem> - ) -} - -const AiSdkError = ({ error }: { error: SerializedAiSdkErrorUnion }) => { - const { t } = useTranslation() - - return ( - <ErrorDetailList> - {(isSerializedAiSdkAPICallError(error) || isSerializedAiSdkDownloadError(error)) && ( - <> - {error.url && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.requestUrl')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.url}</ErrorDetailValue> - </ErrorDetailItem> - )} - </> - )} - - {isSerializedAiSdkAPICallError(error) && ( - <>{error.responseBody && <TruncatedCodeViewer value={error.responseBody} label={t('error.responseBody')} />}</> - )} - - {(isSerializedAiSdkAPICallError(error) || isSerializedAiSdkDownloadError(error)) && ( - <> - {error.statusCode && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.statusCode')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.statusCode}</ErrorDetailValue> - </ErrorDetailItem> - )} - </> - )} - - {isSerializedAiSdkAPICallError(error) && ( - <> - {error.responseHeaders && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.responseHeaders')}:</ErrorDetailLabel> - <CodeViewer - value={JSON.stringify(error.responseHeaders, null, 2)} - className="source-view" - language="json" - expanded - /> - </ErrorDetailItem> - )} - - {error.requestBodyValues && ( - <TruncatedCodeViewer value={safeToString(error.requestBodyValues)} label={t('error.requestBodyValues')} /> - )} - - {error.data && <TruncatedCodeViewer value={safeToString(error.data)} label={t('error.data')} />} - </> - )} - - {isSerializedAiSdkDownloadError(error) && ( - <> - {error.statusText && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.statusText')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.statusText}</ErrorDetailValue> - </ErrorDetailItem> - )} - </> - )} - - {isSerializedAiSdkInvalidArgumentError(error) && ( - <> - {error.parameter && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.parameter')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.parameter}</ErrorDetailValue> - </ErrorDetailItem> - )} - </> - )} - - {(isSerializedAiSdkInvalidArgumentError(error) || isSerializedAiSdkTypeValidationError(error)) && ( - <> - {error.value && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.value')}:</ErrorDetailLabel> - <ErrorDetailValue>{safeToString(error.value)}</ErrorDetailValue> - </ErrorDetailItem> - )} - </> - )} - - {isSerializedAiSdkInvalidDataContentError(error) && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.content')}:</ErrorDetailLabel> - <ErrorDetailValue>{safeToString(error.content)}</ErrorDetailValue> - </ErrorDetailItem> - )} - - {isSerializedAiSdkInvalidMessageRoleError(error) && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.role')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.role}</ErrorDetailValue> - </ErrorDetailItem> - )} - - {isSerializedAiSdkInvalidPromptError(error) && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.prompt')}:</ErrorDetailLabel> - <ErrorDetailValue>{safeToString(error.prompt)}</ErrorDetailValue> - </ErrorDetailItem> - )} - - {isSerializedAiSdkInvalidToolInputError(error) && ( - <> - {error.toolName && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.toolName')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.toolName}</ErrorDetailValue> - </ErrorDetailItem> - )} - {error.toolInput && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.toolInput')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.toolInput}</ErrorDetailValue> - </ErrorDetailItem> - )} - </> - )} - - {(isSerializedAiSdkJSONParseError(error) || isSerializedAiSdkNoObjectGeneratedError(error)) && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.text')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.text}</ErrorDetailValue> - </ErrorDetailItem> - )} - - {isSerializedAiSdkMessageConversionError(error) && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.originalMessage')}:</ErrorDetailLabel> - <ErrorDetailValue>{safeToString(error.originalMessage)}</ErrorDetailValue> - </ErrorDetailItem> - )} - - {isSerializedAiSdkNoSpeechGeneratedError(error) && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.responses')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.responses.join(', ')}</ErrorDetailValue> - </ErrorDetailItem> - )} - - {isSerializedAiSdkNoObjectGeneratedError(error) && ( - <> - {error.response && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.response')}:</ErrorDetailLabel> - <ErrorDetailValue>{safeToString(error.response)}</ErrorDetailValue> - </ErrorDetailItem> - )} - {error.usage && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.usage')}:</ErrorDetailLabel> - <ErrorDetailValue>{safeToString(error.usage)}</ErrorDetailValue> - </ErrorDetailItem> - )} - {error.finishReason && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.finishReason')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.finishReason}</ErrorDetailValue> - </ErrorDetailItem> - )} - </> - )} - - {(isSerializedAiSdkNoSuchModelError(error) || - isSerializedAiSdkNoSuchProviderError(error) || - isSerializedAiSdkTooManyEmbeddingValuesForCallError(error)) && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.modelId')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.modelId}</ErrorDetailValue> - </ErrorDetailItem> - )} - - {(isSerializedAiSdkNoSuchModelError(error) || isSerializedAiSdkNoSuchProviderError(error)) && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.modelType')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.modelType}</ErrorDetailValue> - </ErrorDetailItem> - )} - - {isSerializedAiSdkNoSuchProviderError(error) && ( - <> - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.providerId')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.providerId}</ErrorDetailValue> - </ErrorDetailItem> - - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.availableProviders')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.availableProviders.join(', ')}</ErrorDetailValue> - </ErrorDetailItem> - </> - )} - - {isSerializedAiSdkNoSuchToolError(error) && ( - <> - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.toolName')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.toolName}</ErrorDetailValue> - </ErrorDetailItem> - {error.availableTools && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.availableTools')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.availableTools?.join(', ') || t('common.none')}</ErrorDetailValue> - </ErrorDetailItem> - )} - </> - )} - - {isSerializedAiSdkRetryError(error) && ( - <> - {error.reason && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.reason')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.reason}</ErrorDetailValue> - </ErrorDetailItem> - )} - {error.lastError && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.lastError')}:</ErrorDetailLabel> - <ErrorDetailValue>{safeToString(error.lastError)}</ErrorDetailValue> - </ErrorDetailItem> - )} - {error.errors && error.errors.length > 0 && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.errors')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.errors.map((e) => safeToString(e)).join('\n\n')}</ErrorDetailValue> - </ErrorDetailItem> - )} - </> - )} - - {isSerializedAiSdkTooManyEmbeddingValuesForCallError(error) && ( - <> - {error.provider && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.provider')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.provider}</ErrorDetailValue> - </ErrorDetailItem> - )} - {error.maxEmbeddingsPerCall && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.maxEmbeddingsPerCall')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.maxEmbeddingsPerCall}</ErrorDetailValue> - </ErrorDetailItem> - )} - {error.values && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.values')}:</ErrorDetailLabel> - <ErrorDetailValue>{safeToString(error.values)}</ErrorDetailValue> - </ErrorDetailItem> - )} - </> - )} - - {isSerializedAiSdkToolCallRepairError(error) && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.originalError')}:</ErrorDetailLabel> - <ErrorDetailValue>{safeToString(error.originalError)}</ErrorDetailValue> - </ErrorDetailItem> - )} - - {isSerializedAiSdkUnsupportedFunctionalityError(error) && ( - <ErrorDetailItem> - <ErrorDetailLabel>{t('error.functionality')}:</ErrorDetailLabel> - <ErrorDetailValue>{error.functionality}</ErrorDetailValue> - </ErrorDetailItem> - )} - - <AiSdkErrorBase error={error} /> - </ErrorDetailList> - ) -} - export default React.memo(ErrorBlock) diff --git a/src/renderer/src/pages/home/Messages/Blocks/ToolBlockGroup.tsx b/src/renderer/src/pages/home/Messages/Blocks/ToolBlockGroup.tsx new file mode 100644 index 0000000000..534ac96779 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Blocks/ToolBlockGroup.tsx @@ -0,0 +1,292 @@ +import type { MCPToolResponseStatus } from '@renderer/types' +import type { ToolMessageBlock } from '@renderer/types/newMessage' +import { Collapse, type CollapseProps } from 'antd' +import { Wrench } from 'lucide-react' +import { AnimatePresence, motion } from 'motion/react' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { useToolApproval } from '../Tools/hooks/useToolApproval' +import MessageTools from '../Tools/MessageTools' +import ToolApprovalActionsComponent from '../Tools/ToolApprovalActions' +import ToolHeader from '../Tools/ToolHeader' + +// ============ Styled Components ============ + +const Container = styled.div` + width: fit-content; + max-width: 100%; + + /* Only style the direct group collapse, not nested tool collapses */ + > .ant-collapse { + background: transparent; + border: none; + + > .ant-collapse-item { + border: none !important; + + > .ant-collapse-header { + padding: 8px 12px !important; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 0.75rem !important; + display: flex; + align-items: center; + + .ant-collapse-expand-icon { + padding: 0 !important; + margin-left: 8px; + height: auto !important; + } + } + + > .ant-collapse-content { + border: none; + background: transparent; + + > .ant-collapse-content-box { + padding: 4px 0 0 0 !important; + display: flex; + flex-direction: column; + gap: 4px; + } + } + } + } +` + +const GroupHeader = styled.div` + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 500; + + .tool-icon { + color: var(--color-primary); + } + + .tool-count { + color: var(--color-text-1); + } +` + +const ScrollableToolList = styled.div` + max-height: 300px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; +` + +const ToolItem = styled.div<{ $isCompleted: boolean }>` + opacity: ${(props) => (props.$isCompleted ? 0.7 : 1)}; + transition: opacity 0.2s; +` + +const AnimatedHeaderWrapper = styled(motion.div)` + display: inline-block; +` + +const HeaderWithActions = styled.div` + display: flex; + align-items: center; + gap: 8px; + width: 100%; + justify-content: space-between; +` + +// ============ Types & Helpers ============ + +interface Props { + blocks: ToolMessageBlock[] +} + +function isCompletedStatus(status: MCPToolResponseStatus | undefined): boolean { + return status === 'done' || status === 'error' || status === 'cancelled' +} + +function isWaitingStatus(status: MCPToolResponseStatus | undefined): boolean { + return status === 'pending' +} + +// Animation variants for smooth header transitions +const headerVariants = { + enter: { x: 20, opacity: 0 }, + center: { x: 0, opacity: 1, transition: { duration: 0.2, ease: 'easeOut' as const } }, + exit: { x: -20, opacity: 0, transition: { duration: 0.15 } } +} + +// ============ Sub-Components ============ + +// Component for rendering a block with approval actions +interface WaitingToolHeaderProps { + block: ToolMessageBlock +} + +const WaitingToolHeader = React.memo(({ block }: WaitingToolHeaderProps) => { + const approval = useToolApproval(block) + + return ( + <HeaderWithActions> + <ToolHeader block={block} variant="collapse-label" showStatus={false} /> + {(approval.isWaiting || approval.isExecuting) && <ToolApprovalActionsComponent {...approval} compact />} + </HeaderWithActions> + ) +}) +WaitingToolHeader.displayName = 'WaitingToolHeader' + +interface GroupHeaderContentProps { + blocks: ToolMessageBlock[] + allCompleted: boolean +} + +const GroupHeaderContent = React.memo(({ blocks, allCompleted }: GroupHeaderContentProps) => { + const { t } = useTranslation() + + if (allCompleted) { + return ( + <GroupHeader> + <Wrench size={14} className="tool-icon" /> + <span className="tool-count">{t('message.tools.groupHeader', { count: blocks.length })}</span> + </GroupHeader> + ) + } + + // Find blocks needing approval (pending status) + const waitingBlocks = blocks.filter((block) => { + const status = block.metadata?.rawMcpToolResponse?.status + return isWaitingStatus(status) + }) + + // Prioritize showing waiting blocks that need approval + const lastWaitingBlock = waitingBlocks[waitingBlocks.length - 1] + if (lastWaitingBlock) { + return ( + <AnimatePresence mode="wait"> + <AnimatedHeaderWrapper + key={lastWaitingBlock.id} + variants={headerVariants} + initial="enter" + animate="center" + exit="exit"> + <WaitingToolHeader block={lastWaitingBlock} /> + </AnimatedHeaderWrapper> + </AnimatePresence> + ) + } + + const runningBlocks = blocks.filter((block) => { + const status = block.metadata?.rawMcpToolResponse?.status + return !isCompletedStatus(status) && !isWaitingStatus(status) + }) + + // Get the last running block (most recent) and render with animation + const lastRunningBlock = runningBlocks[runningBlocks.length - 1] + if (lastRunningBlock) { + return ( + <AnimatePresence mode="wait"> + <AnimatedHeaderWrapper + key={lastRunningBlock.id} + variants={headerVariants} + initial="enter" + animate="center" + exit="exit"> + <ToolHeader block={lastRunningBlock} variant="collapse-label" /> + </AnimatedHeaderWrapper> + </AnimatePresence> + ) + } + + // Fallback + return ( + <GroupHeader> + <Wrench size={14} className="tool-icon" /> + <span className="tool-count">{t('message.tools.groupHeader', { count: blocks.length })}</span> + </GroupHeader> + ) +}) +GroupHeaderContent.displayName = 'GroupHeaderContent' + +// Component for tool list content with auto-scroll +interface ToolListContentProps { + blocks: ToolMessageBlock[] + scrollRef: React.RefObject<HTMLDivElement | null> +} + +const ToolListContent = React.memo(({ blocks, scrollRef }: ToolListContentProps) => ( + <ScrollableToolList ref={scrollRef}> + {blocks.map((block) => { + const status = block.metadata?.rawMcpToolResponse?.status + const isCompleted = isCompletedStatus(status) + return ( + <ToolItem key={block.id} data-block-id={block.id} $isCompleted={isCompleted}> + <MessageTools block={block} /> + </ToolItem> + ) + })} + </ScrollableToolList> +)) +ToolListContent.displayName = 'ToolListContent' + +// ============ Main Component ============ + +const ToolBlockGroup: React.FC<Props> = ({ blocks }) => { + const [activeKey, setActiveKey] = useState<string[]>([]) + const scrollRef = useRef<HTMLDivElement>(null) + const userExpandedRef = useRef(false) + + const allCompleted = useMemo(() => { + return blocks.every((block) => { + const status = block.metadata?.rawMcpToolResponse?.status + return isCompletedStatus(status) + }) + }, [blocks]) + + const currentRunningBlock = useMemo(() => { + return blocks.find((block) => { + const status = block.metadata?.rawMcpToolResponse?.status + return !isCompletedStatus(status) + }) + }, [blocks]) + + useEffect(() => { + if (activeKey.includes('tool-group') && currentRunningBlock && scrollRef.current) { + const element = scrollRef.current.querySelector(`[data-block-id="${currentRunningBlock.id}"]`) + element?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }, [activeKey, currentRunningBlock]) + + const handleChange = (keys: string | string[]) => { + const keyArray = Array.isArray(keys) ? keys : [keys] + const isExpanding = keyArray.includes('tool-group') + userExpandedRef.current = isExpanding + setActiveKey(keyArray) + } + + const items: CollapseProps['items'] = useMemo(() => { + return [ + { + key: 'tool-group', + label: <GroupHeaderContent blocks={blocks} allCompleted={allCompleted} />, + children: <ToolListContent blocks={blocks} scrollRef={scrollRef} /> + } + ] + }, [blocks, allCompleted]) + + return ( + <Container> + <Collapse + ghost + size="small" + expandIconPosition="end" + activeKey={activeKey} + onChange={handleChange} + items={items} + /> + </Container> + ) +} + +export default React.memo(ToolBlockGroup) diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx index d2771b36f6..349faf53e1 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx @@ -3,7 +3,7 @@ import type { RootState } from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' import type { ImageMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' -import { isMainTextBlock, isMessageProcessing, isVideoBlock } from '@renderer/utils/messageUtils/is' +import { isMainTextBlock, isMessageProcessing, isToolBlock, isVideoBlock } from '@renderer/utils/messageUtils/is' import { AnimatePresence, motion, type Variants } from 'motion/react' import React, { useMemo } from 'react' import { useSelector } from 'react-redux' @@ -18,6 +18,7 @@ import MainTextBlock from './MainTextBlock' import PlaceholderBlock from './PlaceholderBlock' import ThinkingBlock from './ThinkingBlock' import ToolBlock from './ToolBlock' +import ToolBlockGroup from './ToolBlockGroup' import TranslationBlock from './TranslationBlock' import VideoBlock from './VideoBlock' @@ -94,6 +95,14 @@ const groupSimilarBlocks = (blocks: MessageBlock[]): (MessageBlock[] | MessageBl } else { acc.push([currentBlock]) } + } else if (currentBlock.type === MessageBlockType.TOOL) { + // 对于TOOL类型,按连续分组 + const prevGroup = acc[acc.length - 1] + if (Array.isArray(prevGroup) && prevGroup[0].type === MessageBlockType.TOOL) { + prevGroup.push(currentBlock) + } else { + acc.push([currentBlock]) + } } else { acc.push(currentBlock) } @@ -147,6 +156,29 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => { <VideoBlock key={firstVideoBlock.id} block={firstVideoBlock} /> </AnimatedBlockWrapper> ) + } else if (block[0].type === MessageBlockType.TOOL) { + // 对于连续的TOOL,使用分组显示 + if (block.length === 1) { + // 单个工具调用,直接渲染 + if (!isToolBlock(block[0])) { + logger.warn('Expected tool block but got different type', block[0]) + return null + } + return ( + <AnimatedBlockWrapper key={groupKey} enableAnimation={message.status.includes('ing')}> + <ToolBlock key={block[0].id} block={block[0]} /> + </AnimatedBlockWrapper> + ) + } + // 多个工具调用,使用分组组件 + const toolBlocks = block.filter(isToolBlock) + // Use first block ID as stable key to prevent remounting when new blocks are added + const stableGroupKey = `tool-group-${toolBlocks[0].id}` + return ( + <AnimatedBlockWrapper key={stableGroupKey} enableAnimation={message.status.includes('ing')}> + <ToolBlockGroup blocks={toolBlocks} /> + </AnimatedBlockWrapper> + ) } return null } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx index 39d72abcf8..ed99ecc632 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx @@ -1,8 +1,10 @@ import type { CollapseProps } from 'antd' import { Tag } from 'antd' import { CheckCircle, Terminal, XCircle } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' +import { truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' import type { BashOutputToolInput, BashOutputToolOutput } from './types' import { AgentToolsType } from './types' @@ -44,34 +46,6 @@ const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null } } -const getStatusConfig = (parsedOutput: ParsedBashOutput | null) => { - if (!parsedOutput) return null - - if (parsedOutput.tool_use_error) { - return { - color: 'danger', - icon: <XCircle className="h-3.5 w-3.5" />, - text: 'Error' - } as const - } - - const isCompleted = parsedOutput.status === 'completed' - const isSuccess = parsedOutput.exit_code === 0 - - return { - color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning', - icon: - isCompleted && isSuccess ? ( - <CheckCircle className="h-3.5 w-3.5" /> - ) : isCompleted && !isSuccess ? ( - <XCircle className="h-3.5 w-3.5" /> - ) : ( - <Terminal className="h-3.5 w-3.5" /> - ), - text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running' - } as const -} - export function BashOutputTool({ input, output @@ -79,15 +53,62 @@ export function BashOutputTool({ input?: BashOutputToolInput output?: BashOutputToolOutput }): NonNullable<CollapseProps['items']>[number] { + const { t } = useTranslation() const parsedOutput = parseBashOutput(output) + + const getStatusConfig = (parsed: ParsedBashOutput | null) => { + if (!parsed) return null + + if (parsed.tool_use_error) { + return { + color: 'danger', + icon: <XCircle className="h-3.5 w-3.5" />, + text: t('message.tools.status.error') + } as const + } + + const isCompleted = parsed.status === 'completed' + const isSuccess = parsed.exit_code === 0 + + if (isCompleted && isSuccess) { + return { + color: 'success', + icon: <CheckCircle className="h-3.5 w-3.5" />, + text: t('message.tools.status.success') + } as const + } + + if (isCompleted) { + return { + color: 'danger', + icon: <XCircle className="h-3.5 w-3.5" />, + text: t('message.tools.status.failed') + } as const + } + + return { + color: 'warning', + icon: <Terminal className="h-3.5 w-3.5" />, + text: t('message.tools.status.running') + } as const + } + const statusConfig = getStatusConfig(parsedOutput) + // Truncate stdout and stderr separately + const truncatedStdout = truncateOutput(parsedOutput?.stdout) + const truncatedStderr = truncateOutput(parsedOutput?.stderr) + const truncatedError = truncateOutput(parsedOutput?.tool_use_error) + const truncatedRawOutput = truncateOutput(output) + const children = parsedOutput ? ( <div className="flex flex-col gap-4"> {/* Status Info */} <div className="flex flex-wrap items-center gap-2"> {parsedOutput.exit_code !== undefined && ( - <Tag color={parsedOutput.exit_code === 0 ? 'success' : 'danger'}>Exit Code: {parsedOutput.exit_code}</Tag> + <Tag color={parsedOutput.exit_code === 0 ? 'success' : 'danger'}> + {t('message.tools.sections.exitCode')}: {parsedOutput.exit_code} + </Tag> )} {parsedOutput.timestamp && ( <Tag className="py-0 font-mono text-xs">{new Date(parsedOutput.timestamp).toLocaleString()}</Tag> @@ -95,73 +116,78 @@ export function BashOutputTool({ </div> {/* Standard Output */} - {parsedOutput.stdout && ( + {truncatedStdout.data && ( <div> - <div className="mb-2 font-medium text-default-600 text-xs">stdout:</div> + <div className="mb-2 font-medium text-default-600 text-xs">{t('message.tools.sections.stdout')}:</div> <pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300"> - {parsedOutput.stdout} + {truncatedStdout.data} </pre> + {truncatedStdout.isTruncated && <TruncatedIndicator originalLength={truncatedStdout.originalLength} />} </div> )} {/* Standard Error */} - {parsedOutput.stderr && ( + {truncatedStderr.data && ( <div className="border border-danger-200"> - <div className="mb-2 font-medium text-danger-600 text-xs">stderr:</div> + <div className="mb-2 font-medium text-danger-600 text-xs">{t('message.tools.sections.stderr')}:</div> <pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400"> - {parsedOutput.stderr} + {truncatedStderr.data} </pre> + {truncatedStderr.isTruncated && <TruncatedIndicator originalLength={truncatedStderr.originalLength} />} </div> )} {/* Tool Use Error */} - {parsedOutput.tool_use_error && ( + {truncatedError.data && ( <div className="border border-danger-200"> <div className="mb-2 flex items-center gap-2"> <XCircle className="h-4 w-4 text-danger" /> - <span className="font-medium text-danger-600 text-xs">Error:</span> + <span className="font-medium text-danger-600 text-xs">{t('message.tools.status.error')}:</span> </div> <pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400"> - {parsedOutput.tool_use_error} + {truncatedError.data} </pre> + {truncatedError.isTruncated && <TruncatedIndicator originalLength={truncatedError.originalLength} />} </div> )} </div> ) : ( // 原始输出(如果解析失败或非 XML 格式) - output && ( + truncatedRawOutput.data && ( <div> - <pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">{output}</pre> + <pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300"> + {truncatedRawOutput.data} + </pre> + {truncatedRawOutput.isTruncated && <TruncatedIndicator originalLength={truncatedRawOutput.originalLength} />} </div> ) ) return { key: AgentToolsType.BashOutput, label: ( - <> - <ToolTitle - icon={<Terminal className="h-4 w-4" />} - label="Bash Output" - params={ - <div className="flex items-center gap-2"> - <Tag className="py-0 font-mono text-xs">{input?.bash_id}</Tag> - {statusConfig && ( - <Tag - color={statusConfig.color} - icon={statusConfig.icon} - style={{ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: '2px' - }}> - {statusConfig.text} - </Tag> - )} - </div> - } - /> - </> + <ToolHeader + toolName={AgentToolsType.BashOutput} + params={ + <div className="flex items-center gap-2"> + <Tag className="py-0 font-mono text-xs">{input?.bash_id}</Tag> + {statusConfig && ( + <Tag + color={statusConfig.color} + icon={statusConfig.icon} + style={{ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: '2px' + }}> + {statusConfig.text} + </Tag> + )} + </div> + } + variant="collapse-label" + showStatus={false} + /> ), children: children diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx index 798807d4d6..f3d56a6067 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx @@ -1,9 +1,13 @@ import type { CollapseProps } from 'antd' -import { Popover, Tag } from 'antd' -import { Terminal } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' -import type { BashToolInput as BashToolInputType, BashToolOutput as BashToolOutputType } from './types' +import { truncateOutput } from '../shared/truncateOutput' +import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools' +import { + AgentToolsType, + type BashToolInput as BashToolInputType, + type BashToolOutput as BashToolOutputType +} from './types' export function BashTool({ input, @@ -12,33 +16,45 @@ export function BashTool({ input?: BashToolInputType output?: BashToolOutputType }): NonNullable<CollapseProps['items']>[number] { - // 如果有输出,计算输出行数 - const outputLines = output ? output.split('\n').length : 0 - - // 处理命令字符串,添加空值检查 - const command = input?.command ?? '' - - const tagContent = <Tag className="!m-0 max-w-full truncate font-mono">{command}</Tag> + const { t } = useTranslation() + const command = input?.command + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) return { key: 'tool', label: ( - <> - <ToolTitle - icon={<Terminal className="h-4 w-4" />} - label="Bash" - params={input?.description} - stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined} - /> - <div className="mt-1 max-w-full"> - <Popover - content={<div className="max-w-xl whitespace-pre-wrap break-all font-mono text-xs">{command}</div>} - trigger="hover"> - {tagContent} - </Popover> - </div> - </> + <ToolHeader + toolName={AgentToolsType.Bash} + params={<SkeletonValue value={input?.description} width="150px" />} + variant="collapse-label" + showStatus={false} + /> ), - children: <div className="whitespace-pre-line">{output}</div> + children: ( + <div className="flex flex-col gap-3"> + {/* Command 输入区域 */} + {command && ( + <div> + <div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.command')}</div> + <div className="max-h-40 overflow-y-auto rounded-md bg-muted/50 p-2"> + <code className="whitespace-pre-wrap break-all font-mono text-xs">{command}</code> + </div> + </div> + )} + + {/* Output 输出区域 */} + {truncatedOutput ? ( + <div> + <div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.output')}</div> + <div className="max-h-60 overflow-y-auto rounded-md bg-muted/30 p-2"> + <pre className="whitespace-pre-wrap font-mono text-xs">{truncatedOutput}</pre> + </div> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} + </div> + ) : ( + <SkeletonValue value={null} width="100%" fallback={null} /> + )} + </div> + ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx index 3eff8118ef..c01aae2cd6 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx @@ -1,7 +1,6 @@ import type { CollapseProps } from 'antd' -import { FileEdit } from 'lucide-react' -import { ToolTitle } from './GenericTools' +import { ToolHeader } from './GenericTools' import type { EditToolInput, EditToolOutput } from './types' import { AgentToolsType } from './types' @@ -37,7 +36,14 @@ export function EditTool({ }): NonNullable<CollapseProps['items']>[number] { return { key: AgentToolsType.Edit, - label: <ToolTitle icon={<FileEdit className="h-4 w-4" />} label="Edit" params={input?.file_path} />, + label: ( + <ToolHeader + toolName={AgentToolsType.Edit} + params={input?.file_path} + variant="collapse-label" + showStatus={false} + /> + ), children: ( <> {/* Diff View */} diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx index f92116478d..e4609b3ee8 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx @@ -1,8 +1,9 @@ import type { CollapseProps } from 'antd' -import { DoorOpen } from 'lucide-react' +import { useTranslation } from 'react-i18next' import ReactMarkdown from 'react-markdown' -import { ToolTitle } from './GenericTools' +import { truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' import type { ExitPlanModeToolInput, ExitPlanModeToolOutput } from './types' import { AgentToolsType } from './types' @@ -13,16 +14,27 @@ export function ExitPlanModeTool({ input?: ExitPlanModeToolInput output?: ExitPlanModeToolOutput }): NonNullable<CollapseProps['items']>[number] { + const { t } = useTranslation() const plan = input?.plan ?? '' + const combinedContent = plan + '\n\n' + (output ?? '') + const { data: truncatedContent, isTruncated, originalLength } = truncateOutput(combinedContent) + const planCount = plan.split('\n\n').length + return { key: AgentToolsType.ExitPlanMode, label: ( - <ToolTitle - icon={<DoorOpen className="h-4 w-4" />} - label="ExitPlanMode" - stats={`${plan.split('\n\n').length} plans`} + <ToolHeader + toolName={AgentToolsType.ExitPlanMode} + stats={`${planCount} ${t(planCount === 1 ? 'message.tools.units.plan' : 'message.tools.units.plans')}`} + variant="collapse-label" + showStatus={false} /> ), - children: <ReactMarkdown>{plan + '\n\n' + (output ?? '')}</ReactMarkdown> + children: ( + <div> + <ReactMarkdown>{truncatedContent}</ReactMarkdown> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} + </div> + ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx index 2245730ce7..5b5775fbd7 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx @@ -1,31 +1,60 @@ // 通用工具组件 - 减少重复代码 -import type { ReactNode } from 'react' +import { LoadingIcon } from '@renderer/components/Icons' +import type { MCPToolResponseStatus } from '@renderer/types' +import { formatFileSize } from '@renderer/utils/file' +import { Skeleton } from 'antd' +import { Check, Ellipsis, TriangleAlert, X } from 'lucide-react' +import { createContext, type ReactNode, use } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' -// 生成 AccordionItem 的标题 -export function ToolTitle({ - icon, - label, - params, - stats, - className = 'text-sm' -}: { - icon?: ReactNode - label: string - params?: string | ReactNode - stats?: string | ReactNode - className?: string -}) { +export { default as ToolHeader, type ToolHeaderProps } from '../ToolHeader' + +// Streaming context - 用于传递流式状态给子组件 +export const StreamingContext = createContext<boolean>(false) +export const useIsStreaming = () => use(StreamingContext) + +export function SkeletonSpan({ width = '60px' }: { width?: string }) { return ( - <div className={`flex items-center gap-1 ${className}`}> - {icon && <span className="flex flex-shrink-0">{icon}</span>} - {label && <span className="flex-shrink-0 font-medium text-sm">{label}</span>} - {params && <span className="min-w-0 truncate text-muted-foreground text-xs">{params}</span>} - {stats && <span className="flex-shrink-0 text-muted-foreground text-xs">{stats}</span>} - </div> + <Skeleton.Input + active + size="small" + style={{ + width, + minWidth: width, + height: '1em', + verticalAlign: 'middle' + }} + /> ) } +/** + * SkeletonValue - 流式时显示 skeleton,否则显示值 + */ +export function SkeletonValue({ + value, + width = '60px', + fallback +}: { + value: ReactNode + width?: string + fallback?: ReactNode +}) { + const isStreaming = useIsStreaming() + + if (value !== undefined && value !== null && value !== '') { + return <>{value}</> + } + + if (isStreaming) { + return <SkeletonSpan width={width} /> + } + + return <>{fallback ?? ''}</> +} + // 纯字符串输入工具 (Task, Bash, Search) export function StringInputTool({ input, @@ -93,3 +122,112 @@ export function StringOutputTool({ </div> ) } + +// ToolStatus extends MCPToolResponseStatus with UI-derived statuses +// 'waiting' is a UI status derived from 'pending' + needs approval +export type ToolStatus = MCPToolResponseStatus | 'waiting' + +/** + * Convert raw data layer status to UI display status + * @param status - Raw status from MCPToolResponseStatus + * @param isWaiting - Whether the tool is waiting for user approval + * @returns The effective UI status + */ +export function getEffectiveStatus(status: MCPToolResponseStatus | undefined, isWaiting: boolean): ToolStatus { + if (status === 'pending') { + return isWaiting ? 'waiting' : 'invoking' + } + return status ?? 'pending' +} + +// 工具状态指示器 - 显示在 Collapse 标题右侧 +export function ToolStatusIndicator({ status, hasError = false }: { status: ToolStatus; hasError?: boolean }) { + const { t } = useTranslation() + + const getStatusInfo = (): { label: string; icon: ReactNode; color: StatusColor } | null => { + switch (status) { + case 'streaming': + return { label: t('message.tools.streaming', 'Streaming'), icon: <LoadingIcon />, color: 'primary' } + case 'waiting': + return { label: t('message.tools.pending', 'Awaiting Approval'), icon: <LoadingIcon />, color: 'warning' } + case 'pending': + case 'invoking': + return { label: t('message.tools.invoking'), icon: <LoadingIcon />, color: 'primary' } + case 'cancelled': + return { + label: t('message.tools.cancelled'), + icon: <X size={13} className="lucide-custom" />, + color: 'error' + } + case 'done': + return hasError + ? { + label: t('message.tools.error'), + icon: <TriangleAlert size={13} className="lucide-custom" />, + color: 'error' + } + : { + label: t('message.tools.completed'), + icon: <Check size={13} className="lucide-custom" />, + color: 'success' + } + case 'error': + return { + label: t('message.tools.error'), + icon: <TriangleAlert size={13} className="lucide-custom" />, + color: 'error' + } + default: + return null + } + } + + const info = getStatusInfo() + if (!info) return null + + return ( + <StatusIndicatorContainer $color={info.color}> + {info.label} + {info.icon} + </StatusIndicatorContainer> + ) +} + +export type StatusColor = 'primary' | 'success' | 'warning' | 'error' + +function getStatusColor(color: StatusColor): string { + switch (color) { + case 'primary': + case 'success': + return 'var(--color-primary)' + case 'warning': + return 'var(--color-status-warning, #faad14)' + case 'error': + return 'var(--color-status-error, #ff4d4f)' + default: + return 'var(--color-text)' + } +} + +export const StatusIndicatorContainer = styled.span<{ $color: StatusColor }>` + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 4px; + opacity: 0.85; + color: ${(props) => getStatusColor(props.$color)}; +` + +export function TruncatedIndicator({ originalLength }: { originalLength: number }) { + const { t } = useTranslation() + const sizeStr = formatFileSize(originalLength) + + return ( + <div className="mt-2 flex items-center gap-1 text-muted-foreground text-xs"> + <Ellipsis size={14} /> + <span className="rounded bg-muted px-1.5 py-0.5 font-mono"> + {t('message.tools.truncated', { defaultValue: sizeStr, size: sizeStr })} + </span> + </div> + ) +} diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx index b70d6da40e..0efcfe03a8 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx @@ -1,8 +1,13 @@ import type { CollapseProps } from 'antd' -import { FolderSearch } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' -import type { GlobToolInput as GlobToolInputType, GlobToolOutput as GlobToolOutputType } from './types' +import { countLines, truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' +import { + AgentToolsType, + type GlobToolInput as GlobToolInputType, + type GlobToolOutput as GlobToolOutputType +} from './types' export function GlobTool({ input, @@ -11,19 +16,31 @@ export function GlobTool({ input?: GlobToolInputType output?: GlobToolOutputType }): NonNullable<CollapseProps['items']>[number] { + const { t } = useTranslation() // 如果有输出,计算文件数量 - const lineCount = output ? output.split('\n').filter((line) => line.trim()).length : 0 + const lineCount = countLines(output) + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) return { key: 'tool', label: ( - <ToolTitle - icon={<FolderSearch className="h-4 w-4" />} - label="Glob" + <ToolHeader + toolName={AgentToolsType.Glob} params={input?.pattern} - stats={output ? `${lineCount} ${lineCount === 1 ? 'file' : 'files'}` : undefined} + stats={ + output + ? `${lineCount} ${t(lineCount === 1 ? 'message.tools.units.file' : 'message.tools.units.files')}` + : undefined + } + variant="collapse-label" + showStatus={false} /> ), - children: <div>{output}</div> + children: ( + <div> + <div>{truncatedOutput}</div> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} + </div> + ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx index 16149549df..c537d1a7ab 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx @@ -1,8 +1,9 @@ import type { CollapseProps } from 'antd' -import { FileSearch } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' -import type { GrepToolInput, GrepToolOutput } from './types' +import { countLines, truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' +import { AgentToolsType, type GrepToolInput, type GrepToolOutput } from './types' export function GrepTool({ input, @@ -11,24 +12,36 @@ export function GrepTool({ input?: GrepToolInput output?: GrepToolOutput }): NonNullable<CollapseProps['items']>[number] { + const { t } = useTranslation() // 如果有输出,计算结果行数 - const resultLines = output ? output.split('\n').filter((line) => line.trim()).length : 0 + const resultLines = countLines(output) + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) return { key: 'tool', label: ( - <ToolTitle - icon={<FileSearch className="h-4 w-4" />} - label="Grep" + <ToolHeader + toolName={AgentToolsType.Grep} params={ <> {input?.pattern} {input?.output_mode && <span className="ml-1">({input.output_mode})</span>} </> } - stats={output ? `${resultLines} ${resultLines === 1 ? 'line' : 'lines'}` : undefined} + stats={ + output + ? `${resultLines} ${t(resultLines === 1 ? 'message.tools.units.line' : 'message.tools.units.lines')}` + : undefined + } + variant="collapse-label" + showStatus={false} /> ), - children: <div>{output}</div> + children: ( + <div> + <div>{truncatedOutput}</div> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} + </div> + ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx index 00922126e7..dd9c0f18ab 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx @@ -1,8 +1,7 @@ import type { CollapseProps } from 'antd' -import { FileText } from 'lucide-react' import { renderCodeBlock } from './EditTool' -import { ToolTitle } from './GenericTools' +import { ToolHeader } from './GenericTools' import type { MultiEditToolInput, MultiEditToolOutput } from './types' import { AgentToolsType } from './types' @@ -15,7 +14,14 @@ export function MultiEditTool({ const edits = Array.isArray(input?.edits) ? input.edits : [] return { key: AgentToolsType.MultiEdit, - label: <ToolTitle icon={<FileText className="h-4 w-4" />} label="MultiEdit" params={input?.file_path} />, + label: ( + <ToolHeader + toolName={AgentToolsType.MultiEdit} + params={input?.file_path} + variant="collapse-label" + showStatus={false} + /> + ), children: ( <div> {edits.map((edit, index) => ( diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx index fe0638f3c9..c3db3bded7 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx @@ -1,9 +1,9 @@ import type { CollapseProps } from 'antd' import { Tag } from 'antd' -import { FileText } from 'lucide-react' import ReactMarkdown from 'react-markdown' -import { ToolTitle } from './GenericTools' +import { truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' import type { NotebookEditToolInput, NotebookEditToolOutput } from './types' import { AgentToolsType } from './types' @@ -14,16 +14,21 @@ export function NotebookEditTool({ input?: NotebookEditToolInput output?: NotebookEditToolOutput }): NonNullable<CollapseProps['items']>[number] { + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) + return { key: AgentToolsType.NotebookEdit, label: ( - <> - <ToolTitle icon={<FileText className="h-4 w-4" />} label="NotebookEdit" /> - <Tag className="mt-1" color="blue"> - {input?.notebook_path}{' '} - </Tag> - </> + <div className="flex items-center gap-2"> + <ToolHeader toolName={AgentToolsType.NotebookEdit} variant="collapse-label" showStatus={false} /> + <Tag color="blue">{input?.notebook_path}</Tag> + </div> ), - children: <ReactMarkdown>{output ?? ''}</ReactMarkdown> + children: ( + <div> + <ReactMarkdown>{truncatedOutput}</ReactMarkdown> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} + </div> + ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx index 30ae162276..7a17e952af 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx @@ -1,8 +1,10 @@ +import { formatFileSize } from '@renderer/utils/file' import type { CollapseProps } from 'antd' -import { FileText } from 'lucide-react' +import { useTranslation } from 'react-i18next' import ReactMarkdown from 'react-markdown' -import { ToolTitle } from './GenericTools' +import { truncateOutput } from '../shared/truncateOutput' +import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools' import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types' import { AgentToolsType } from './types' @@ -28,17 +30,9 @@ const normalizeOutputString = (output?: ReadToolOutputType): string | null => { const getOutputStats = (outputString: string | null) => { if (!outputString) return null - const bytes = new Blob([outputString]).size - const formatSize = (size: number) => { - if (size < 1024) return `${size} B` - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` - return `${(size / (1024 * 1024)).toFixed(1)} MB` - } - return { lineCount: outputString.split('\n').length, - fileSize: bytes, - formatSize + fileSize: new Blob([outputString]).size } } @@ -49,19 +43,34 @@ export function ReadTool({ input?: ReadToolInputType output?: ReadToolOutputType }): NonNullable<CollapseProps['items']>[number] { + const { t } = useTranslation() const outputString = normalizeOutputString(output) const stats = getOutputStats(outputString) + const filename = input?.file_path?.split('/').pop() + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(outputString) return { key: AgentToolsType.Read, label: ( - <ToolTitle - icon={<FileText className="h-4 w-4" />} - label="Read File" - params={input?.file_path?.split('/').pop()} - stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined} + <ToolHeader + toolName={AgentToolsType.Read} + params={<SkeletonValue value={filename} width="120px" />} + stats={ + stats + ? `${stats.lineCount} ${t(stats.lineCount === 1 ? 'message.tools.units.line' : 'message.tools.units.lines')}, ${formatFileSize(stats.fileSize)}` + : undefined + } + variant="collapse-label" + showStatus={false} /> ), - children: outputString ? <ReactMarkdown>{outputString}</ReactMarkdown> : null + children: truncatedOutput ? ( + <div> + <ReactMarkdown>{truncatedOutput}</ReactMarkdown> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} + </div> + ) : ( + <SkeletonValue value={null} width="100%" fallback={null} /> + ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx index 66bf28c671..261e876c57 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx @@ -1,8 +1,13 @@ import type { CollapseProps } from 'antd' -import { Search } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { StringInputTool, StringOutputTool, ToolTitle } from './GenericTools' -import type { SearchToolInput as SearchToolInputType, SearchToolOutput as SearchToolOutputType } from './types' +import { countLines, truncateOutput } from '../shared/truncateOutput' +import { StringInputTool, StringOutputTool, ToolHeader, TruncatedIndicator } from './GenericTools' +import { + AgentToolsType, + type SearchToolInput as SearchToolInputType, + type SearchToolOutput as SearchToolOutputType +} from './types' export function SearchTool({ input, @@ -11,25 +16,37 @@ export function SearchTool({ input?: SearchToolInputType output?: SearchToolOutputType }): NonNullable<CollapseProps['items']>[number] { + const { t } = useTranslation() // 如果有输出,计算结果数量 - const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0 + const resultCount = countLines(output) + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) return { key: 'tool', label: ( - <ToolTitle - icon={<Search className="h-4 w-4" />} - label="Search" + <ToolHeader + toolName={AgentToolsType.Search} params={input ? `"${input}"` : undefined} - stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined} + stats={ + output + ? `${resultCount} ${t(resultCount === 1 ? 'message.tools.units.result' : 'message.tools.units.results')}` + : undefined + } + variant="collapse-label" + showStatus={false} /> ), children: ( <div> - {input && <StringInputTool input={input} label="Search Query" />} - {output && ( + {input && <StringInputTool input={input} label={t('message.tools.sections.searchQuery')} />} + {truncatedOutput && ( <div> - <StringOutputTool output={output} label="Search Results" textColor="text-yellow-600 dark:text-yellow-400" /> + <StringOutputTool + output={truncatedOutput} + label={t('message.tools.sections.searchResults')} + textColor="text-yellow-600 dark:text-yellow-400" + /> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} </div> )} </div> diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx index 6127984676..b5baabd330 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx @@ -1,8 +1,8 @@ import type { CollapseProps } from 'antd' -import { PencilRuler } from 'lucide-react' -import { ToolTitle } from './GenericTools' -import type { SkillToolInput, SkillToolOutput } from './types' +import { truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' +import { AgentToolsType, type SkillToolInput, type SkillToolOutput } from './types' export function SkillTool({ input, @@ -11,9 +11,18 @@ export function SkillTool({ input?: SkillToolInput output?: SkillToolOutput }): NonNullable<CollapseProps['items']>[number] { + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) + return { key: 'tool', - label: <ToolTitle icon={<PencilRuler className="h-4 w-4" />} label="Skill" params={input?.command} />, - children: <div>{output}</div> + label: ( + <ToolHeader toolName={AgentToolsType.Skill} params={input?.command} variant="collapse-label" showStatus={false} /> + ), + children: ( + <div> + <div>{truncatedOutput}</div> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} + </div> + ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx index 18117590c7..575815f9e9 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx @@ -1,9 +1,15 @@ import type { CollapseProps } from 'antd' -import { Bot } from 'lucide-react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' import Markdown from 'react-markdown' -import { ToolTitle } from './GenericTools' -import type { TaskToolInput as TaskToolInputType, TaskToolOutput as TaskToolOutputType } from './types' +import { truncateOutput } from '../shared/truncateOutput' +import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools' +import { + AgentToolsType, + type TaskToolInput as TaskToolInputType, + type TaskToolOutput as TaskToolOutputType +} from './types' export function TaskTool({ input, @@ -12,17 +18,51 @@ export function TaskTool({ input?: TaskToolInputType output?: TaskToolOutputType }): NonNullable<CollapseProps['items']>[number] { + const { t } = useTranslation() + const hasOutput = Array.isArray(output) && output.length > 0 + + // Combine all text outputs and truncate + const { truncatedText, isTruncated, originalLength } = useMemo(() => { + if (!hasOutput) return { truncatedText: '', isTruncated: false, originalLength: 0 } + const combinedText = output!.map((item) => item.text).join('\n\n') + const result = truncateOutput(combinedText) + return { truncatedText: result.data, isTruncated: result.isTruncated, originalLength: result.originalLength } + }, [output, hasOutput]) + return { key: 'tool', - label: <ToolTitle icon={<Bot className="h-4 w-4" />} label="Task" params={input?.description} />, + label: ( + <ToolHeader + toolName={AgentToolsType.Task} + params={<SkeletonValue value={input?.description} width="150px" />} + variant="collapse-label" + showStatus={false} + /> + ), children: ( - <div> - {Array.isArray(output) && - output.map((item) => ( - <div key={item.type}> - <div>{item.type === 'text' ? <Markdown>{item.text}</Markdown> : item.text}</div> + <div className="flex flex-col gap-3"> + {/* Prompt 输入区域 */} + {input?.prompt && ( + <div> + <div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.prompt')}</div> + <div className="max-h-40 overflow-y-auto rounded-md bg-muted/50 p-2 text-sm"> + <Markdown>{input.prompt}</Markdown> </div> - ))} + </div> + )} + + {/* Output 输出区域 */} + {hasOutput ? ( + <div> + <div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.output')}</div> + <div className="rounded-md bg-muted/30 p-2"> + <Markdown>{truncatedText}</Markdown> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} + </div> + </div> + ) : ( + <SkeletonValue value={null} width="100%" fallback={null} /> + )} </div> ) } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx index a81de46dcd..7f01f0fccc 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx @@ -1,8 +1,9 @@ import type { CollapseProps } from 'antd' import { Card } from 'antd' -import { CheckCircle, Circle, Clock, ListTodo } from 'lucide-react' +import { CheckCircle, Circle, Clock } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' +import { ToolHeader } from './GenericTools' import type { TodoItem, TodoWriteToolInput as TodoWriteToolInputType } from './types' import { AgentToolsType } from './types' @@ -20,12 +21,6 @@ const getStatusConfig = (status: TodoItem['status']) => { opacity: 0.9, icon: <Clock className="h-4 w-4" strokeWidth={2.5} /> } - case 'pending': - return { - color: 'var(--color-border)', - opacity: 0.4, - icon: <Circle className="h-4 w-4" strokeWidth={2.5} /> - } default: return { color: 'var(--color-border)', @@ -40,17 +35,19 @@ export function TodoWriteTool({ }: { input?: TodoWriteToolInputType }): NonNullable<CollapseProps['items']>[number] { + const { t } = useTranslation() const todos = Array.isArray(input?.todos) ? input.todos : [] const doneCount = todos.filter((todo) => todo.status === 'completed').length return { key: AgentToolsType.TodoWrite, label: ( - <ToolTitle - icon={<ListTodo className="h-4 w-4" />} - label="Todo Write" - params={`${doneCount} Done`} - stats={`${todos.length} ${todos.length === 1 ? 'item' : 'items'}`} + <ToolHeader + toolName={AgentToolsType.TodoWrite} + params={`${doneCount} ${t('message.tools.status.done')}`} + stats={`${todos.length} ${t(todos.length === 1 ? 'message.tools.units.item' : 'message.tools.units.items')}`} + variant="collapse-label" + showStatus={false} /> ), children: ( diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx index 8a6965b6f6..7ec5e8b67e 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx @@ -1,9 +1,9 @@ -import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import type { CollapseProps } from 'antd' import { Wrench } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' +import { ToolArgsTable } from '../shared/ArgsTable' +import { ToolHeader } from './GenericTools' interface UnknownToolProps { toolName: string @@ -21,75 +21,54 @@ const getToolDisplayName = (name: string) => { return name } -const getToolDescription = (toolName: string) => { - if (toolName.startsWith('mcp__')) { - return 'MCP Server Tool' - } - return 'Tool' -} - -const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unknown }) => { - const { highlightCode } = useCodeStyle() - const [inputHtml, setInputHtml] = useState<string>('') - const [outputHtml, setOutputHtml] = useState<string>('') - - useEffect(() => { - if (input !== undefined) { - const inputStr = JSON.stringify(input, null, 2) - highlightCode(inputStr, 'json').then(setInputHtml) - } - }, [input, highlightCode]) - - useEffect(() => { - if (output !== undefined) { - const outputStr = JSON.stringify(output, null, 2) - highlightCode(outputStr, 'json').then(setOutputHtml) - } - }, [output, highlightCode]) - - if (input === undefined && output === undefined) { - return <div className="text-foreground-500 text-xs">No data available for this tool</div> - } - - return ( - <div className="space-y-3"> - {input !== undefined && ( - <div> - <div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div> - <div - className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900" - dangerouslySetInnerHTML={{ __html: inputHtml }} - /> - </div> - )} - - {output !== undefined && ( - <div> - <div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div> - <div - className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line" - dangerouslySetInnerHTML={{ __html: outputHtml }} - /> - </div> - )} - </div> - ) -} - +/** + * Fallback renderer for unknown tool types + * Uses shared ArgsTable for consistent styling with MCP tools + */ export function UnknownToolRenderer({ toolName = '', input, output }: UnknownToolProps): NonNullable<CollapseProps['items']>[number] { + const { t } = useTranslation() + + const getToolDescription = (name: string) => { + if (name.startsWith('mcp__')) { + return t('message.tools.labels.mcpServerTool') + } + return t('message.tools.labels.tool') + } + + // Normalize input/output for table display + const normalizeArgs = (value: unknown): Record<string, unknown> | unknown[] | null => { + if (value === undefined || value === null) return null + if (typeof value === 'object') return value as Record<string, unknown> | unknown[] + // Wrap primitive values + return { value } + } + + const normalizedInput = normalizeArgs(input) + const normalizedOutput = normalizeArgs(output) + return { key: 'unknown-tool', label: ( - <ToolTitle + <ToolHeader + toolName={getToolDisplayName(toolName)} icon={<Wrench className="h-4 w-4" />} - label={getToolDisplayName(toolName)} params={getToolDescription(toolName)} + variant="collapse-label" + showStatus={false} /> ), - children: <UnknownToolContent input={input} output={output} /> + children: ( + <div className="space-y-1"> + {normalizedInput && <ToolArgsTable args={normalizedInput} title={t('message.tools.sections.input')} />} + {normalizedOutput && <ToolArgsTable args={normalizedOutput} title={t('message.tools.sections.output')} />} + {!normalizedInput && !normalizedOutput && ( + <div className="p-3 text-foreground-500 text-xs">{t('message.tools.noData')}</div> + )} + </div> + ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx index f8bd27df5f..6d09a54510 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx @@ -1,8 +1,8 @@ import type { CollapseProps } from 'antd' -import { Globe } from 'lucide-react' -import { ToolTitle } from './GenericTools' -import type { WebFetchToolInput, WebFetchToolOutput } from './types' +import { truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' +import { AgentToolsType, type WebFetchToolInput, type WebFetchToolOutput } from './types' export function WebFetchTool({ input, @@ -11,9 +11,18 @@ export function WebFetchTool({ input?: WebFetchToolInput output?: WebFetchToolOutput }): NonNullable<CollapseProps['items']>[number] { + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) + return { key: 'tool', - label: <ToolTitle icon={<Globe className="h-4 w-4" />} label="Web Fetch" params={input?.url} />, - children: <div>{output}</div> + label: ( + <ToolHeader toolName={AgentToolsType.WebFetch} params={input?.url} variant="collapse-label" showStatus={false} /> + ), + children: ( + <div> + <div>{truncatedOutput}</div> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} + </div> + ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx index 4f50839cc9..079c9a9ce3 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx @@ -1,8 +1,9 @@ import type { CollapseProps } from 'antd' -import { Globe } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' -import type { WebSearchToolInput, WebSearchToolOutput } from './types' +import { countLines, truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' +import { AgentToolsType, type WebSearchToolInput, type WebSearchToolOutput } from './types' export function WebSearchTool({ input, @@ -11,19 +12,31 @@ export function WebSearchTool({ input?: WebSearchToolInput output?: WebSearchToolOutput }): NonNullable<CollapseProps['items']>[number] { + const { t } = useTranslation() // 如果有输出,计算结果数量 - const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0 + const resultCount = countLines(output) + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) return { key: 'tool', label: ( - <ToolTitle - icon={<Globe className="h-4 w-4" />} - label="Web Search" + <ToolHeader + toolName={AgentToolsType.WebSearch} params={input?.query} - stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined} + stats={ + output + ? `${resultCount} ${t(resultCount === 1 ? 'message.tools.units.result' : 'message.tools.units.results')}` + : undefined + } + variant="collapse-label" + showStatus={false} /> ), - children: <div>{output}</div> + children: ( + <div> + <div>{truncatedOutput}</div> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} + </div> + ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx index fd0d637f50..2e4846ec49 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx @@ -1,8 +1,7 @@ import type { CollapseProps } from 'antd' -import { FileText } from 'lucide-react' -import { ToolTitle } from './GenericTools' -import type { WriteToolInput, WriteToolOutput } from './types' +import { ToolHeader } from './GenericTools' +import { AgentToolsType, type WriteToolInput, type WriteToolOutput } from './types' export function WriteTool({ input @@ -12,7 +11,14 @@ export function WriteTool({ }): NonNullable<CollapseProps['items']>[number] { return { key: 'tool', - label: <ToolTitle icon={<FileText className="h-4 w-4" />} label="Write" params={input?.file_path} />, + label: ( + <ToolHeader + toolName={AgentToolsType.Write} + params={input?.file_path} + variant="collapse-label" + showStatus={false} + /> + ), children: <div>{input?.content}</div> } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx index e523305277..eed581bedc 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx @@ -1,10 +1,10 @@ -import { loggerService } from '@logger' import { useAppSelector } from '@renderer/store' import { selectPendingPermission } from '@renderer/store/toolPermissions' import type { NormalToolResponse } from '@renderer/types' import type { CollapseProps } from 'antd' -import { Collapse, Spin } from 'antd' -import { useTranslation } from 'react-i18next' +import { Collapse } from 'antd' +import { parse as parsePartialJson } from 'partial-json' +import { useMemo } from 'react' // 导出所有类型 export * from './types' @@ -15,6 +15,7 @@ import { BashOutputTool } from './BashOutputTool' import { BashTool } from './BashTool' import { EditTool } from './EditTool' import { ExitPlanModeTool } from './ExitPlanModeTool' +import { getEffectiveStatus, StreamingContext, type ToolStatus, ToolStatusIndicator } from './GenericTools' import { GlobTool } from './GlobTool' import { GrepTool } from './GrepTool' import { MultiEditTool } from './MultiEditTool' @@ -31,9 +32,7 @@ import { WebFetchTool } from './WebFetchTool' import { WebSearchTool } from './WebSearchTool' import { WriteTool } from './WriteTool' -const logger = loggerService.withContext('MessageAgentTools') - -// 创建工具渲染器映射,这样就实现了完全的类型安全 +// 创建工具渲染器映射 export const toolRenderers = { [AgentToolsType.Read]: ReadTool, [AgentToolsType.Task]: TaskTool, @@ -51,76 +50,116 @@ export const toolRenderers = { [AgentToolsType.NotebookEdit]: NotebookEditTool, [AgentToolsType.ExitPlanMode]: ExitPlanModeTool, [AgentToolsType.Skill]: SkillTool -} as const +} + +/** + * Type-safe tool renderer invocation function. + * Use this function to call a tool renderer with proper type checking, + * avoiding the need for `as any` type assertions at call sites. + * + * @param toolName - The name of the tool (must be a valid AgentToolsType) + * @param input - The input for the tool (accepts various input formats) + * @param output - Optional output from the tool + * @returns The rendered collapse item + */ +export function renderTool( + toolName: AgentToolsType, + input: ToolInput | Record<string, unknown> | string | undefined, + output?: ToolOutput | unknown +): NonNullable<CollapseProps['items']>[number] { + const renderer = toolRenderers[toolName] as (props: { + input?: unknown + output?: unknown + }) => NonNullable<CollapseProps['items']>[number] + return renderer({ input, output }) +} // 类型守卫函数 export function isValidAgentToolsType(toolName: unknown): toolName is AgentToolsType { return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType) } -// 统一的渲染组件 -function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; input: ToolInput; output?: ToolOutput }) { - const Renderer = toolRenderers[toolName] - const renderedItem = Renderer - ? Renderer({ input: input as any, output: output as any }) - : UnknownToolRenderer({ input: input as any, output: output as any, toolName }) +function ToolContent({ + toolName, + input, + output, + isStreaming = false, + status, + hasError = false +}: { + toolName?: string + input?: ToolInput | Record<string, unknown> + output?: ToolOutput | unknown + isStreaming?: boolean + status?: ToolStatus + hasError?: boolean +}) { + const renderedItem = isValidAgentToolsType(toolName) + ? renderTool(toolName, (input ?? {}) as Record<string, unknown>, output) + : UnknownToolRenderer({ toolName: toolName ?? 'Tool', input, output }) const toolContentItem: NonNullable<CollapseProps['items']>[number] = { ...renderedItem, + label: ( + <div className="flex w-full items-start justify-between gap-2"> + <div className="min-w-0">{renderedItem.label}</div> + {status && ( + <div className="shrink-0"> + <ToolStatusIndicator status={status} hasError={hasError} /> + </div> + )} + </div> + ), classNames: { - body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll' + body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 overflow-scroll' } } return ( - <Collapse - className="w-max max-w-full" - expandIconPosition="end" - size="small" - defaultActiveKey={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []} - items={[toolContentItem]} - /> + <StreamingContext value={isStreaming}> + <Collapse + className="w-max max-w-full" + expandIconPosition="end" + size="small" + defaultActiveKey={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []} + items={[toolContentItem]} + /> + </StreamingContext> ) } // 统一的组件渲染入口 export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) { - const { arguments: args, response, tool, status } = toolResponse - logger.debug('Rendering agent tool response', { - tool: tool, - arguments: args, - status, - response - }) + const { arguments: args, response, tool, status, partialArguments } = toolResponse const pendingPermission = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolResponse.toolCallId) ) - if (status === 'pending') { - if (pendingPermission) { - return <ToolPermissionRequestCard toolResponse={toolResponse} /> + const parsedPartialArgs = useMemo(() => { + if (!partialArguments) return undefined + try { + return parsePartialJson(partialArguments) + } catch { + return undefined } - return <ToolPendingIndicator toolName={tool?.name} description={tool?.description} /> + }, [partialArguments]) + + const effectiveStatus = getEffectiveStatus(status, !!pendingPermission) + + if (effectiveStatus === 'waiting') { + return <ToolPermissionRequestCard toolResponse={toolResponse} /> } + const isLoading = effectiveStatus === 'streaming' || effectiveStatus === 'invoking' return ( - <ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} /> - ) -} - -function ToolPendingIndicator({ toolName, description }: { toolName?: string; description?: string }) { - const { t } = useTranslation() - const label = toolName || t('agent.toolPermission.toolPendingFallback', 'Tool') - const detail = description?.trim() || t('agent.toolPermission.executing') - - return ( - <div className="flex w-full max-w-xl items-center gap-3 rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm"> - <Spin size="small" /> - <div className="flex flex-col gap-1"> - <span className="font-semibold text-default-700 text-sm">{label}</span> - <span className="text-default-500 text-xs">{detail}</span> - </div> - </div> + <ToolContent + toolName={tool?.name} + input={args ?? parsedPartialArgs} + output={isLoading ? undefined : response} + isStreaming={isLoading} + status={effectiveStatus} + hasError={status === 'error'} + /> ) } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts index f4271b3a2e..7396f8ed00 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts @@ -1,3 +1,5 @@ +import type { CollapseProps } from 'antd' + export enum AgentToolsType { Skill = 'Skill', Read = 'Read', @@ -386,3 +388,52 @@ export type ToolOutput = export interface ToolRenderer { render: (props: { input: ToolInput; output?: ToolOutput }) => React.ReactElement } + +// 工具类型到输入类型的映射(用于文档和类型提示) +export interface ToolInputMap { + [AgentToolsType.Skill]: SkillToolInput + [AgentToolsType.Read]: ReadToolInput + [AgentToolsType.Task]: TaskToolInput + [AgentToolsType.Bash]: BashToolInput + [AgentToolsType.Search]: SearchToolInput + [AgentToolsType.Glob]: GlobToolInput + [AgentToolsType.TodoWrite]: TodoWriteToolInput + [AgentToolsType.WebSearch]: WebSearchToolInput + [AgentToolsType.Grep]: GrepToolInput + [AgentToolsType.Write]: WriteToolInput + [AgentToolsType.WebFetch]: WebFetchToolInput + [AgentToolsType.Edit]: EditToolInput + [AgentToolsType.MultiEdit]: MultiEditToolInput + [AgentToolsType.BashOutput]: BashOutputToolInput + [AgentToolsType.NotebookEdit]: NotebookEditToolInput + [AgentToolsType.ExitPlanMode]: ExitPlanModeToolInput +} + +// 工具类型到输出类型的映射 +export interface ToolOutputMap { + [AgentToolsType.Skill]: SkillToolOutput + [AgentToolsType.Read]: ReadToolOutput + [AgentToolsType.Task]: TaskToolOutput + [AgentToolsType.Bash]: BashToolOutput + [AgentToolsType.Search]: SearchToolOutput + [AgentToolsType.Glob]: GlobToolOutput + [AgentToolsType.TodoWrite]: TodoWriteToolOutput + [AgentToolsType.WebSearch]: WebSearchToolOutput + [AgentToolsType.Grep]: GrepToolOutput + [AgentToolsType.Write]: WriteToolOutput + [AgentToolsType.WebFetch]: WebFetchToolOutput + [AgentToolsType.Edit]: EditToolOutput + [AgentToolsType.MultiEdit]: MultiEditToolOutput + [AgentToolsType.BashOutput]: BashOutputToolOutput + [AgentToolsType.NotebookEdit]: NotebookEditToolOutput + [AgentToolsType.ExitPlanMode]: ExitPlanModeToolOutput +} + +// 通用工具渲染器函数类型 - 接受宽松的输入类型 +export type ToolRendererFn = (props: { + input?: ToolInput | Record<string, unknown> | string + output?: ToolOutput | unknown +}) => NonNullable<CollapseProps['items']>[number] + +// 工具渲染器映射类型 +export type ToolRenderersMap = Record<AgentToolsType, ToolRendererFn> diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx index 455e64d05d..328d1ab9a6 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx @@ -1,107 +1,66 @@ import { loggerService } from '@logger' -import { CopyIcon, LoadingIcon } from '@renderer/components/Icons' +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { CopyIcon } from '@renderer/components/Icons' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' -import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useSettings } from '@renderer/hooks/useSettings' import { useTimer } from '@renderer/hooks/useTimer' import type { MCPToolResponse } from '@renderer/types' import type { ToolMessageBlock } from '@renderer/types/newMessage' import { isToolAutoApproved } from '@renderer/utils/mcp-tools' -import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation' import type { MCPProgressEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' -import { - Button, - Collapse, - ConfigProvider, - Dropdown, - Flex, - message as antdMessage, - Modal, - Progress, - Tabs, - Tooltip -} from 'antd' +import { Collapse, ConfigProvider, Flex, message as antdMessage, Progress, Tooltip } from 'antd' import { message } from 'antd' -import { - Check, - ChevronDown, - ChevronRight, - CirclePlay, - CircleX, - Maximize, - PauseCircle, - ShieldCheck, - TriangleAlert, - X -} from 'lucide-react' +import { Check, ChevronRight, ShieldCheck } from 'lucide-react' +import { parse as parsePartialJson } from 'partial-json' import type { FC } from 'react' -import { memo, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import { useMcpToolApproval } from './hooks/useMcpToolApproval' +import { + getEffectiveStatus, + SkeletonSpan, + ToolStatusIndicator, + TruncatedIndicator +} from './MessageAgentTools/GenericTools' +import { + ArgKey, + ArgsSection, + ArgsSectionTitle, + ArgsTable, + ArgValue, + formatArgValue, + ResponseSection +} from './shared/ArgsTable' +import { truncateOutput } from './shared/truncateOutput' +import ToolApprovalActionsComponent from './ToolApprovalActions' + interface Props { block: ToolMessageBlock } const logger = loggerService.withContext('MessageTools') -const COUNTDOWN_TIME = 30 - const MessageMcpTool: FC<Props> = ({ block }) => { const [activeKeys, setActiveKeys] = useState<string[]>([]) const [copiedMap, setCopiedMap] = useState<Record<string, boolean>>({}) - const [countdown, setCountdown] = useState<number>(COUNTDOWN_TIME) const { t } = useTranslation() const { messageFont, fontSize } = useSettings() - const { mcpServers, updateMCPServer } = useMCPServers() - const [expandedResponse, setExpandedResponse] = useState<{ content: string; title: string } | null>(null) const [progress, setProgress] = useState<number>(0) const { setTimeoutTimer } = useTimer() + // Use the unified approval hook + const approval = useMcpToolApproval(block) + const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse - const { id, tool, status, response } = toolResponse as MCPToolResponse + const { id, tool, status, response, partialArguments } = toolResponse as MCPToolResponse const isPending = status === 'pending' const isDone = status === 'done' const isError = status === 'error' - - const isAutoApproved = useMemo( - () => - isToolAutoApproved( - tool, - mcpServers.find((s) => s.id === tool.serverId) - ), - [tool, mcpServers] - ) - - // 增加本地状态来跟踪用户确认 - const [isConfirmed, setIsConfirmed] = useState(isAutoApproved) - - // 判断不同的UI状态 - const isWaitingConfirmation = isPending && !isAutoApproved && !isConfirmed - const isExecuting = isPending && (isAutoApproved || isConfirmed) - - const timer = useRef<NodeJS.Timeout | null>(null) - useEffect(() => { - if (!isWaitingConfirmation) return - - if (countdown > 0) { - timer.current = setTimeout(() => { - logger.debug(`countdown: ${countdown}`) - setCountdown((prev) => prev - 1) - }, 1000) - } else if (countdown === 0) { - setIsConfirmed(true) - confirmToolAction(id) - } - - return () => { - if (timer.current) { - clearTimeout(timer.current) - } - } - }, [countdown, id, isWaitingConfirmation]) + const isStreaming = status === 'streaming' useEffect(() => { const removeListener = window.electron.ipcRenderer.on( @@ -119,33 +78,16 @@ const MessageMcpTool: FC<Props> = ({ block }) => { } }, [id]) - const cancelCountdown = () => { - if (timer.current) { - clearTimeout(timer.current) + // Auto-expand when streaming, auto-collapse when done + useEffect(() => { + if (isStreaming) { + // Expand when streaming starts + setActiveKeys((prev) => (prev.includes(id) ? prev : [...prev, id])) + } else if (isDone || isError) { + // Collapse when streaming ends + setActiveKeys((prev) => prev.filter((key) => key !== id)) } - } - - const argsString = useMemo(() => { - if (toolResponse?.arguments) { - return JSON.stringify(toolResponse.arguments, null, 2) - } - return 'No arguments' - }, [toolResponse]) - - const resultString = useMemo(() => { - try { - return JSON.stringify( - { - params: toolResponse?.arguments, - response: toolResponse?.response - }, - null, - 2 - ) - } catch (e) { - return 'Invalid Result' - } - }, [toolResponse]) + }, [isStreaming, isDone, isError, id]) if (!toolResponse) { return null @@ -162,17 +104,6 @@ const MessageMcpTool: FC<Props> = ({ block }) => { setActiveKeys(Array.isArray(keys) ? keys : [keys]) } - const handleConfirmTool = () => { - cancelCountdown() - setIsConfirmed(true) - confirmToolAction(id) - } - - const handleCancelTool = () => { - cancelCountdown() - cancelToolAction(id) - } - const handleAbortTool = async () => { if (toolResponse?.id) { try { @@ -189,75 +120,8 @@ const MessageMcpTool: FC<Props> = ({ block }) => { } } - const handleAutoApprove = async () => { - cancelCountdown() - - if (!tool || !tool.name) { - return - } - - const server = mcpServers.find((s) => s.id === tool.serverId) - if (!server) { - return - } - - let disabledAutoApproveTools = [...(server.disabledAutoApproveTools || [])] - - // Remove tool from disabledAutoApproveTools to enable auto-approve - disabledAutoApproveTools = disabledAutoApproveTools.filter((name) => name !== tool.name) - - const updatedServer = { - ...server, - disabledAutoApproveTools - } - - updateMCPServer(updatedServer) - - // Also confirm the current tool - setIsConfirmed(true) - confirmToolAction(id) - - window.toast.success(t('message.tools.autoApproveEnabled', 'Auto-approve enabled for this tool')) - } - - const renderStatusIndicator = (status: string, hasError: boolean) => { - let label = '' - let icon: React.ReactNode | null = null - - if (status === 'pending') { - if (isWaitingConfirmation) { - label = t('message.tools.pending', 'Awaiting Approval') - icon = <LoadingIcon style={{ marginLeft: 6, color: 'var(--status-color-warning)' }} /> - } else if (isExecuting) { - label = t('message.tools.invoking') - icon = <LoadingIcon style={{ marginLeft: 6 }} /> - } - } else if (status === 'cancelled') { - label = t('message.tools.cancelled') - icon = <X size={13} style={{ marginLeft: 6 }} className="lucide-custom" /> - } else if (status === 'done') { - if (hasError) { - label = t('message.tools.error') - icon = <TriangleAlert size={13} style={{ marginLeft: 6 }} className="lucide-custom" /> - } else { - label = t('message.tools.completed') - icon = <Check size={13} style={{ marginLeft: 6 }} className="lucide-custom" /> - } - } else if (status === 'error') { - label = t('message.tools.error') - icon = <TriangleAlert size={13} style={{ marginLeft: 6 }} className="lucide-custom" /> - } - - return ( - <StatusIndicator status={status} hasError={hasError}> - {label} - {icon} - </StatusIndicator> - ) - } - // Format tool responses for collapse items - const getCollapseItems = () => { + const getCollapseItems = (): { key: string; label: React.ReactNode; children: React.ReactNode }[] => { const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = [] const hasError = response?.isError === true const result = { @@ -282,22 +146,8 @@ const MessageMcpTool: FC<Props> = ({ block }) => { {progress > 0 ? ( <Progress type="circle" size={14} percent={Number((progress * 100)?.toFixed(0))} /> ) : ( - renderStatusIndicator(status, hasError) + <ToolStatusIndicator status={getEffectiveStatus(status, approval.isWaiting)} hasError={hasError} /> )} - <Tooltip title={t('common.expand')} mouseEnterDelay={0.5}> - <ActionButton - className="message-action-button" - onClick={(e) => { - e.stopPropagation() - setExpandedResponse({ - content: JSON.stringify(response, null, 2), - title: tool.name - }) - }} - aria-label={t('common.expand')}> - <Maximize size={14} /> - </ActionButton> - </Tooltip> {!isPending && ( <Tooltip title={t('common.copy')} mouseEnterDelay={0.5}> <ActionButton @@ -315,65 +165,25 @@ const MessageMcpTool: FC<Props> = ({ block }) => { </ActionButtonsContainer> </MessageTitleLabel> ), - children: - (isDone || isError) && result ? ( - <ToolResponseContainer - style={{ - fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)', - fontSize - }}> - <CollapsedContent isExpanded={activeKeys.includes(id)} resultString={resultString} /> - </ToolResponseContainer> - ) : argsString ? ( - <> - <ToolResponseContainer> - <CollapsedContent isExpanded={activeKeys.includes(id)} resultString={argsString} /> - </ToolResponseContainer> - </> - ) : null + children: ( + <ToolResponseContainer + style={{ + fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)', + fontSize + }}> + <ToolResponseContent + isExpanded={activeKeys.includes(id)} + args={isStreaming ? partialArguments : toolResponse.arguments} + isStreaming={!!isStreaming} + response={isDone || isError ? toolResponse.response : undefined} + /> + </ToolResponseContainer> + ) }) return items } - const renderPreview = (content: string) => { - if (!content) return null - - try { - logger.debug(`renderPreview: ${content}`) - const parsedResult = JSON.parse(content) - switch (parsedResult.content[0]?.type) { - case 'text': - try { - return ( - <CollapsedContent - isExpanded={true} - resultString={JSON.stringify(JSON.parse(parsedResult.content[0].text), null, 2)} - /> - ) - } catch (e) { - return ( - <CollapsedContent - isExpanded={true} - resultString={JSON.stringify(parsedResult.content[0].text, null, 2)} - /> - ) - } - - default: - return <CollapsedContent isExpanded={true} resultString={JSON.stringify(parsedResult, null, 2)} /> - } - } catch (e) { - logger.error('failed to render the preview of mcp results:', e as Error) - return ( - <CollapsedContent - isExpanded={true} - resultString={e instanceof Error ? e.message : JSON.stringify(e, null, 2)} - /> - ) - } - } - return ( <> <ConfigProvider @@ -401,155 +211,166 @@ const MessageMcpTool: FC<Props> = ({ block }) => { {isPending && ( <ActionsBar> <ActionLabel> - {isWaitingConfirmation + {approval.isWaiting ? t('settings.mcp.tools.autoApprove.tooltip.confirm') : t('message.tools.invoking')} </ActionLabel> - <ActionButtonsGroup> - {isWaitingConfirmation && ( - <Button - color="danger" - variant="filled" - size="small" - onClick={() => { - handleCancelTool() - }}> - <CircleX size={15} className="lucide-custom" /> - {t('common.cancel')} - </Button> - )} - {isExecuting && toolResponse?.id ? ( - <Button - size="small" - color="danger" - variant="solid" - className="abort-button" - onClick={(e) => { - e.stopPropagation() - handleAbortTool() - }}> - <PauseCircle size={14} className="lucide-custom" /> - {t('chat.input.pause')} - </Button> - ) : ( - isWaitingConfirmation && ( - <StyledDropdownButton - size="small" - type="primary" - icon={<ChevronDown size={14} />} - onClick={() => { - handleConfirmTool() - }} - menu={{ - items: [ - { - key: 'autoApprove', - label: t('settings.mcp.tools.autoApprove.label'), - onClick: () => { - handleAutoApprove() - } - } - ] - }}> - <CirclePlay size={15} className="lucide-custom" /> - <CountdownText> - {t('settings.mcp.tools.run', 'Run')} ({countdown}s) - </CountdownText> - </StyledDropdownButton> - ) - )} - </ActionButtonsGroup> + <ToolApprovalActionsComponent + {...approval} + showAbort={approval.isExecuting && !!toolResponse?.id} + onAbort={handleAbortTool} + /> </ActionsBar> )} </ToolContentWrapper> </ToolContainer> </ConfigProvider> - <Modal - title={expandedResponse?.title} - open={!!expandedResponse} - onCancel={() => setExpandedResponse(null)} - footer={null} - width="80%" - centered - transitionName="animation-move-down" - styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}> - {expandedResponse && ( - <ExpandedResponseContainer - style={{ - fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)', - fontSize - }}> - <Tabs - tabBarExtraContent={ - <ActionButton - className="copy-expanded-button" - onClick={() => { - navigator.clipboard.writeText( - typeof expandedResponse.content === 'string' - ? expandedResponse.content - : JSON.stringify(expandedResponse.content, null, 2) - ) - antdMessage.success({ content: t('message.copied'), key: 'copy-expanded' }) - }} - aria-label={t('common.copy')}> - <i className="iconfont icon-copy"></i> - </ActionButton> - } - items={[ - { - key: 'preview', - label: t('message.tools.preview'), - children: renderPreview(expandedResponse.content) - }, - { - key: 'raw', - label: t('message.tools.raw'), - children: ( - <CollapsedContent - isExpanded={true} - resultString={ - typeof expandedResponse.content === 'string' - ? expandedResponse.content - : JSON.stringify(expandedResponse.content, null, 2) - } - /> - ) - } - ]} - /> - </ExpandedResponseContainer> - )} - </Modal> </> ) } -// New component to handle collapsed content -const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ isExpanded, resultString }) => { - const { highlightCode } = useCodeStyle() - const [styledResult, setStyledResult] = useState<string>('') +/** + * Extract preview content from MCP tool response using SDK schema + */ +const extractPreviewContent = (response: unknown): string => { + if (!response) return '' - useEffect(() => { - if (!isExpanded) { - return + const result = CallToolResultSchema.safeParse(response) + if (result.success) { + const contents = result.data.content + if (contents.length === 0) return '' + + const textParts: string[] = [] + for (const content of contents) { + switch (content.type) { + case 'text': + if (content.text) { + try { + const parsed = JSON.parse(content.text) + textParts.push(JSON.stringify(parsed, null, 2)) + } catch { + textParts.push(content.text) + } + } + break + case 'image': + textParts.push(`[Image: ${content.mimeType ?? 'image/png'}]`) + break + case 'resource': + textParts.push(`[Resource: ${content.resource?.uri ?? 'unknown'}]`) + break + } } + return textParts.join('\n\n') + } + + // Fallback: return JSON string for unknown format + return JSON.stringify(response, null, 2) +} + +// Unified tool response content component +const ToolResponseContent: FC<{ + isExpanded: boolean + args: string | Record<string, unknown> | Record<string, unknown>[] | undefined + isStreaming: boolean + response?: unknown +}> = ({ isExpanded, args, isStreaming, response }) => { + const { highlightCode } = useCodeStyle() + const [highlightedResponse, setHighlightedResponse] = useState<string>('') + const [isTruncated, setIsTruncated] = useState(false) + const [originalLength, setOriginalLength] = useState(0) + + // Parse args if it's a string (streaming partial JSON) + const parsedArgs = useMemo(() => { + if (!args) return null + if (typeof args === 'string') { + try { + return parsePartialJson(args) + } catch { + return null + } + } + return args + }, [args]) + + // Extract and highlight response when available + useEffect(() => { + if (!isExpanded || !response) return const highlight = async () => { - const result = await highlightCode(resultString, 'json') - setStyledResult(result) + const previewContent = extractPreviewContent(response) + const { + data: truncatedContent, + isTruncated: wasTruncated, + originalLength: origLen + } = truncateOutput(previewContent) + setIsTruncated(wasTruncated) + setOriginalLength(origLen) + const result = await highlightCode(truncatedContent, 'json') + setHighlightedResponse(result) } const timer = setTimeout(highlight, 0) - return () => clearTimeout(timer) - }, [isExpanded, resultString, highlightCode]) + }, [isExpanded, response, highlightCode]) - if (!isExpanded) { - return null + if (!isExpanded) return null + + // Handle both object and array args - for arrays, show as single entry + const getEntries = (): Array<[string, unknown]> => { + if (!parsedArgs || typeof parsedArgs !== 'object') return [] + if (Array.isArray(parsedArgs)) { + return [['arguments', parsedArgs]] + } + return Object.entries(parsedArgs) + } + const entries = getEntries() + + const renderArgsTable = (): React.ReactNode => { + if (entries.length === 0) return null + return ( + <ArgsSection> + <ArgsSectionTitle>Arguments</ArgsSectionTitle> + <ArgsTable> + <tbody> + {entries.map(([key, value]) => ( + <tr key={key}> + <ArgKey>{key}</ArgKey> + <ArgValue>{formatArgValue(value)}</ArgValue> + </tr> + ))} + {isStreaming && ( + <tr> + <ArgKey> + <SkeletonSpan width="60px" /> + </ArgKey> + <ArgValue> + <SkeletonSpan width="120px" /> + </ArgValue> + </tr> + )} + </tbody> + </ArgsTable> + </ArgsSection> + ) } - return <MarkdownContainer className="markdown" dangerouslySetInnerHTML={{ __html: styledResult }} /> + return ( + <div> + {/* Arguments Table */} + {renderArgsTable()} + + {/* Response */} + {response !== undefined && response !== null && highlightedResponse && ( + <ResponseSection> + <ArgsSectionTitle>Response</ArgsSectionTitle> + <MarkdownContainer className="markdown" dangerouslySetInnerHTML={{ __html: highlightedResponse }} /> + {isTruncated && <TruncatedIndicator originalLength={originalLength} />} + </ResponseSection> + )} + </div> + ) } const ToolContentWrapper = styled.div` @@ -586,22 +407,6 @@ const ActionLabel = styled.div` white-space: nowrap; ` -const ActionButtonsGroup = styled.div` - display: flex; - gap: 10px; -` - -const CountdownText = styled.span` - width: 65px; - text-align: left; -` - -const StyledDropdownButton = styled(Dropdown.Button)` - .ant-btn-group { - border-radius: 6px; - } -` - const ExpandIcon = styled(ChevronRight)<{ $isActive?: boolean }>` transition: transform 0.2s; transform: ${({ $isActive }) => ($isActive ? 'rotate(90deg)' : 'rotate(0deg)')}; @@ -670,31 +475,6 @@ const ToolName = styled(Flex)` font-size: 13px; ` -const StatusIndicator = styled.span<{ status: string; hasError?: boolean }>` - color: ${(props) => { - switch (props.status) { - case 'pending': - return 'var(--status-color-warning)' - case 'invoking': - return 'var(--status-color-invoking)' - case 'cancelled': - return 'var(--status-color-error)' - case 'done': - return props.hasError ? 'var(--status-color-error)' : 'var(--status-color-success)' - case 'error': - return 'var(--status-color-error)' - default: - return 'var(--color-text)' - } - }}; - font-size: 11px; - font-weight: ${(props) => (props.status === 'pending' ? '600' : '400')}; - display: flex; - align-items: center; - opacity: ${(props) => (props.status === 'pending' ? '1' : '0.85')}; - padding-left: 12px; -` - const ActionButtonsContainer = styled.div` display: flex; gap: 6px; @@ -752,27 +532,4 @@ const ToolResponseContainer = styled.div` position: relative; ` -const ExpandedResponseContainer = styled.div` - background: var(--color-bg-1); - border-radius: 8px; - padding: 16px; - position: relative; - - .copy-expanded-button { - position: absolute; - top: 10px; - right: 10px; - background-color: var(--color-bg-2); - border-radius: 4px; - z-index: 1; - } - - pre { - margin: 0; - white-space: pre-wrap; - word-break: break-word; - color: var(--color-text); - } -` - export default memo(MessageMcpTool) diff --git a/src/renderer/src/pages/home/Messages/Tools/ToolApprovalActions.tsx b/src/renderer/src/pages/home/Messages/Tools/ToolApprovalActions.tsx new file mode 100644 index 0000000000..a012a0762a --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/ToolApprovalActions.tsx @@ -0,0 +1,165 @@ +import { LoadingIcon } from '@renderer/components/Icons' +import { Button, Dropdown } from 'antd' +import { ChevronDown, CirclePlay, CircleX, ShieldCheck } from 'lucide-react' +import type { FC, MouseEvent } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import type { ToolApprovalActions, ToolApprovalState } from './hooks/useToolApproval' + +export interface ToolApprovalActionsProps extends ToolApprovalState, ToolApprovalActions { + /** Compact mode for use in headers */ + compact?: boolean + /** Show abort button when executing */ + showAbort?: boolean + /** Abort handler */ + onAbort?: () => void +} + +/** + * Unified tool approval action buttons + * Used in both MessageMcpTool and ToolPermissionRequestCard + */ +export const ToolApprovalActionsComponent: FC<ToolApprovalActionsProps> = ({ + isWaiting, + isExecuting, + remainingSeconds, + isExpired, + isSubmitting, + confirm, + cancel, + autoApprove, + compact = false, + showAbort = false, + onAbort +}) => { + const { t } = useTranslation() + + // Stop event propagation to prevent collapse toggle + const handleClick = (e: MouseEvent, handler: () => void) => { + e.stopPropagation() + handler() + } + + // Nothing to show if not waiting and not executing + if (!isWaiting && !isExecuting) return null + + // Expired state for agent tools + if (isExpired && !isExecuting) { + return ( + <ExpiredBadge $compact={compact} onClick={(e) => e.stopPropagation()}> + {t('agent.toolPermission.expired')} + </ExpiredBadge> + ) + } + + // Executing state - show loading or abort button + if (isExecuting) { + if (showAbort && onAbort) { + return ( + <ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}> + <Button size="small" color="danger" variant="solid" onClick={(e) => handleClick(e, onAbort)}> + {t('chat.input.pause')} + </Button> + </ActionsContainer> + ) + } + return ( + <ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}> + <LoadingIndicator> + <LoadingIcon /> + {!compact && <span>{t('message.tools.invoking')}</span>} + </LoadingIndicator> + </ActionsContainer> + ) + } + + // Waiting state - show confirm/cancel buttons + return ( + <ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}> + <Button + size="small" + color="danger" + variant={compact ? 'text' : 'outlined'} + disabled={isSubmitting} + onClick={(e) => handleClick(e, cancel)}> + <CircleX size={compact ? 13 : 14} className="lucide-custom" /> + {!compact && t('common.cancel')} + </Button> + + {autoApprove ? ( + <StyledDropdownButton + size="small" + type="primary" + disabled={isSubmitting} + icon={<ChevronDown size={compact ? 12 : 14} />} + onClick={(e) => handleClick(e, confirm)} + menu={{ + items: [ + { + key: 'autoApprove', + label: t('settings.mcp.tools.autoApprove.label'), + icon: <ShieldCheck size={14} />, + onClick: () => autoApprove() + } + ] + }}> + <CirclePlay size={compact ? 13 : 15} className="lucide-custom" /> + <CountdownText $compact={compact}> + {compact ? `${remainingSeconds}s` : `${t('settings.mcp.tools.run', 'Run')} (${remainingSeconds}s)`} + </CountdownText> + </StyledDropdownButton> + ) : ( + <Button size="small" type="primary" disabled={isSubmitting} onClick={(e) => handleClick(e, confirm)}> + <CirclePlay size={compact ? 13 : 15} className="lucide-custom" /> + <CountdownText $compact={compact}> + {compact ? `${remainingSeconds}s` : `${t('settings.mcp.tools.run', 'Run')} (${remainingSeconds}s)`} + </CountdownText> + </Button> + )} + </ActionsContainer> + ) +} + +// Styled components + +const ActionsContainer = styled.div<{ $compact: boolean }>` + display: flex; + align-items: center; + gap: ${(props) => (props.$compact ? '4px' : '8px')}; + + .ant-btn-sm { + height: ${(props) => (props.$compact ? '24px' : '28px')}; + padding: ${(props) => (props.$compact ? '0 6px' : '0 8px')}; + font-size: ${(props) => (props.$compact ? '12px' : '13px')}; + } +` + +const ExpiredBadge = styled.span<{ $compact: boolean }>` + font-size: ${(props) => (props.$compact ? '11px' : '12px')}; + color: var(--color-status-error, #ff4d4f); + padding: ${(props) => (props.$compact ? '2px 6px' : '4px 8px')}; + background: var(--color-status-error-bg, rgba(255, 77, 79, 0.1)); + border-radius: 4px; +` + +const LoadingIndicator = styled.div` + display: flex; + align-items: center; + gap: 6px; + color: var(--color-primary); + font-size: 12px; +` + +const CountdownText = styled.span<{ $compact: boolean }>` + min-width: ${(props) => (props.$compact ? '24px' : '65px')}; + text-align: left; +` + +const StyledDropdownButton = styled(Dropdown.Button)` + .ant-btn-group { + border-radius: 6px; + } +` + +export default ToolApprovalActionsComponent diff --git a/src/renderer/src/pages/home/Messages/Tools/ToolHeader.tsx b/src/renderer/src/pages/home/Messages/Tools/ToolHeader.tsx new file mode 100644 index 0000000000..ab0a769d11 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/ToolHeader.tsx @@ -0,0 +1,272 @@ +import type { MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types' +import type { ToolMessageBlock } from '@renderer/types/newMessage' +import { isToolAutoApproved } from '@renderer/utils/mcp-tools' +import { Flex, Tooltip } from 'antd' +import { + Bot, + DoorOpen, + FileEdit, + FileSearch, + FileText, + FolderSearch, + Globe, + ListTodo, + NotebookPen, + PencilRuler, + Search, + ShieldCheck, + Terminal, + Wrench +} from 'lucide-react' +import type { FC, ReactNode } from 'react' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { type ToolStatus, ToolStatusIndicator } from './MessageAgentTools/GenericTools' +import { AgentToolsType } from './MessageAgentTools/types' + +export interface ToolHeaderProps { + block?: ToolMessageBlock + + toolName?: string + icon?: ReactNode + params?: ReactNode + stats?: ReactNode + + // Common config + status?: ToolStatus + hasError?: boolean + showStatus?: boolean // default true + + // Style variant + variant?: 'standalone' | 'collapse-label' +} + +const getAgentToolIcon = (toolName: string): ReactNode => { + switch (toolName) { + case AgentToolsType.Read: + return <FileText size={14} /> + case AgentToolsType.Task: + return <Bot size={14} /> + case AgentToolsType.Bash: + case AgentToolsType.BashOutput: + return <Terminal size={14} /> + case AgentToolsType.Search: + return <Search size={14} /> + case AgentToolsType.Glob: + return <FolderSearch size={14} /> + case AgentToolsType.Grep: + return <FileSearch size={14} /> + case AgentToolsType.Write: + return <FileText size={14} /> + case AgentToolsType.Edit: + return <FileEdit size={14} /> + case AgentToolsType.MultiEdit: + return <FileText size={14} /> + case AgentToolsType.WebSearch: + case AgentToolsType.WebFetch: + return <Globe size={14} /> + case AgentToolsType.NotebookEdit: + return <NotebookPen size={14} /> + case AgentToolsType.TodoWrite: + return <ListTodo size={14} /> + case AgentToolsType.ExitPlanMode: + return <DoorOpen size={14} /> + case AgentToolsType.Skill: + return <PencilRuler size={14} /> + default: + return <Wrench size={14} /> + } +} + +const getAgentToolLabel = (toolName: string, t: (key: string) => string): string => { + switch (toolName) { + case AgentToolsType.Read: + return t('message.tools.labels.readFile') + case AgentToolsType.Task: + return t('message.tools.labels.task') + case AgentToolsType.Bash: + return t('message.tools.labels.bash') + case AgentToolsType.BashOutput: + return t('message.tools.labels.bashOutput') + case AgentToolsType.Search: + return t('message.tools.labels.search') + case AgentToolsType.Glob: + return t('message.tools.labels.glob') + case AgentToolsType.Grep: + return t('message.tools.labels.grep') + case AgentToolsType.Write: + return t('message.tools.labels.write') + case AgentToolsType.Edit: + return t('message.tools.labels.edit') + case AgentToolsType.MultiEdit: + return t('message.tools.labels.multiEdit') + case AgentToolsType.WebSearch: + return t('message.tools.labels.webSearch') + case AgentToolsType.WebFetch: + return t('message.tools.labels.webFetch') + case AgentToolsType.NotebookEdit: + return t('message.tools.labels.notebookEdit') + case AgentToolsType.TodoWrite: + return t('message.tools.labels.todoWrite') + case AgentToolsType.ExitPlanMode: + return t('message.tools.labels.exitPlanMode') + case AgentToolsType.Skill: + return t('message.tools.labels.skill') + default: + return toolName + } +} + +const getToolDescription = (toolResponse?: MCPToolResponse | NormalToolResponse): string | undefined => { + if (!toolResponse) return undefined + const args = toolResponse.arguments + if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined + + // Common description fields + return ( + (args as Record<string, unknown>).description || + (args as Record<string, unknown>).file_path || + (args as Record<string, unknown>).pattern || + (args as Record<string, unknown>).query || + (args as Record<string, unknown>).command || + (args as Record<string, unknown>).url + )?.toString() +} + +// ============ Styled Components ============ + +const HeaderContainer = styled.div` + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + padding: 8px 12px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 0.75rem; + min-width: 0; +` + +// Label variant: no border/padding, for use inside Collapse header +const LabelContainer = styled.div` + display: flex; + align-items: center; + gap: 4px; + font-size: 14px; + min-width: 0; +` + +const ToolName = styled(Flex)` + font-weight: 500; + color: var(--color-text); + flex-shrink: 0; + + .tool-icon { + color: var(--color-primary); + } + + .name { + white-space: nowrap; + } +` + +const Description = styled.span` + color: var(--color-text-2); + font-weight: 400; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; + max-width: 300px; +` + +const Stats = styled.span` + color: var(--color-text-2); + font-weight: 400; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; +` + +const StatusWrapper = styled.div` + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: auto; +` + +// ============ Main Component ============ + +const ToolHeader: FC<ToolHeaderProps> = ({ + block, + toolName: propToolName, + icon: propIcon, + params, + stats, + status: propStatus, + hasError: propHasError, + showStatus = true, + variant = 'standalone' +}) => { + const { t } = useTranslation() + + const toolResponse = block?.metadata?.rawMcpToolResponse + const tool = toolResponse?.tool + + const toolName = propToolName || tool?.name || 'Tool' + + const status = propStatus || (toolResponse?.status as ToolStatus) + const hasError = propHasError ?? toolResponse?.response?.isError === true + + const description = params ?? getToolDescription(toolResponse) + + const Container = variant === 'standalone' ? HeaderContainer : LabelContainer + + if (block && tool?.type === 'mcp') { + const mcpTool = tool as MCPTool + return ( + <Container> + <ToolName align="center" gap={6}> + <Wrench size={14} className="tool-icon" /> + <span className="name"> + {mcpTool.serverName} : {mcpTool.name} + </span> + {isToolAutoApproved(mcpTool) && ( + <Tooltip title={t('message.tools.autoApproveEnabled')} mouseLeaveDelay={0}> + <ShieldCheck size={14} color="var(--color-primary)" /> + </Tooltip> + )} + </ToolName> + {description && <Description>{description}</Description>} + {stats && <Stats>{stats}</Stats>} + {showStatus && status && ( + <StatusWrapper> + <ToolStatusIndicator status={status} hasError={hasError} /> + </StatusWrapper> + )} + </Container> + ) + } + + return ( + <Container> + <ToolName align="center" gap={6}> + <span className="tool-icon">{propIcon || getAgentToolIcon(toolName)}</span> + <span className="name">{getAgentToolLabel(toolName, t)}</span> + </ToolName> + {description && <Description>{description}</Description>} + {stats && <Stats>{stats}</Stats>} + {showStatus && status && ( + <StatusWrapper> + <ToolStatusIndicator status={status} hasError={hasError} /> + </StatusWrapper> + )} + </Container> + ) +} + +export default memo(ToolHeader) diff --git a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx index 0e0ba211f6..497f235588 100644 --- a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx @@ -1,14 +1,16 @@ -import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' -import { loggerService } from '@logger' -import { useAppDispatch, useAppSelector } from '@renderer/store' -import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions' +import { LoadingIcon } from '@renderer/components/Icons' import type { NormalToolResponse } from '@renderer/types' -import { Button, Spin } from 'antd' -import { ChevronDown, CirclePlay, CircleX } from 'lucide-react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import type { CollapseProps } from 'antd' +import { Collapse } from 'antd' +import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import styled from 'styled-components' -const logger = loggerService.withContext('ToolPermissionRequestCard') +import { useAgentToolApproval } from './hooks/useAgentToolApproval' +import { type StatusColor, StatusIndicatorContainer, StreamingContext } from './MessageAgentTools/GenericTools' +import { isValidAgentToolsType, renderTool } from './MessageAgentTools/index' +import { UnknownToolRenderer } from './MessageAgentTools/UnknownToolRenderer' +import ToolApprovalActionsComponent from './ToolApprovalActions' interface Props { toolResponse: NormalToolResponse @@ -16,250 +18,115 @@ interface Props { export function ToolPermissionRequestCard({ toolResponse }: Props) { const { t } = useTranslation() - const dispatch = useAppDispatch() - const request = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolResponse.toolCallId)) - const [now, setNow] = useState(() => Date.now()) - const [showDetails, setShowDetails] = useState(false) - useEffect(() => { - if (!request) return + const approval = useAgentToolApproval(null, { toolCallId: toolResponse.toolCallId }) - logger.debug('Rendering inline tool permission card', { - requestId: request.requestId, - toolName: request.toolName, - expiresAt: request.expiresAt - }) - - setNow(Date.now()) - - const interval = window.setInterval(() => { - setNow(Date.now()) - }, 500) - - return () => { - window.clearInterval(interval) + const statusInfo = useMemo((): { color: StatusColor; text: string; showLoading: boolean } => { + if (approval.isExecuting) { + return { color: 'primary', text: t('message.tools.invoking'), showLoading: true } } - }, [request]) + if (approval.isExpired) { + return { color: 'error', text: t('agent.toolPermission.expired'), showLoading: false } + } + return { + color: 'warning', + text: t('agent.toolPermission.pending', { seconds: approval.remainingSeconds }), + showLoading: true + } + }, [approval.isExecuting, approval.isExpired, approval.remainingSeconds, t]) - const remainingMs = useMemo(() => { - if (!request) return 0 - return Math.max(0, request.expiresAt - now) - }, [request, now]) + const renderToolContent = useCallback((): React.ReactNode => { + const toolName = toolResponse.tool?.name ?? '' + const input = (approval.input ?? toolResponse.arguments) as Record<string, unknown> | undefined - const remainingSeconds = useMemo(() => Math.ceil(remainingMs / 1000), [remainingMs]) - const isExpired = remainingMs <= 0 + const renderedItem = isValidAgentToolsType(toolName) + ? renderTool(toolName, input) + : UnknownToolRenderer({ input, toolName }) - const isSubmittingAllow = request?.status === 'submitting-allow' - const isSubmittingDeny = request?.status === 'submitting-deny' - const isSubmitting = isSubmittingAllow || isSubmittingDeny - const isInvoking = request?.status === 'invoking' - - const handleDecision = useCallback( - async ( - behavior: 'allow' | 'deny', - extra?: { - updatedInput?: Record<string, unknown> - updatedPermissions?: PermissionUpdate[] - message?: string - } - ) => { - if (!request) return - - logger.debug('Submitting inline tool permission decision', { - requestId: request.requestId, - toolName: request.toolName, - behavior - }) - - dispatch(toolPermissionsActions.submissionSent({ requestId: request.requestId, behavior })) - - try { - const payload = { - requestId: request.requestId, - behavior, - ...(behavior === 'allow' - ? { - updatedInput: extra?.updatedInput ?? request.input, - updatedPermissions: extra?.updatedPermissions - } - : { - message: extra?.message ?? t('agent.toolPermission.defaultDenyMessage') - }) - } - - const response = await window.api.agentTools.respondToPermission(payload) - - if (!response?.success) { - throw new Error('Renderer response rejected by main process') - } - - logger.debug('Tool permission decision acknowledged by main process', { - requestId: request.requestId, - behavior - }) - } catch (error) { - logger.error('Failed to send tool permission response', error as Error) - window.toast?.error?.(t('agent.toolPermission.error.sendFailed')) - dispatch(toolPermissionsActions.submissionFailed({ requestId: request.requestId })) - } - }, - [dispatch, request, t] - ) - - if (!request) { - return ( - <div className="rounded-xl border border-default-200 bg-default-100 px-4 py-3 text-default-500 text-sm"> - {t('agent.toolPermission.waiting')} - </div> + const statusIndicator = ( + <StatusIndicatorContainer $color={statusInfo.color}> + {statusInfo.text} + {statusInfo.showLoading && <LoadingIcon />} + </StatusIndicatorContainer> ) - } - if (isInvoking) { - return ( - <div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm"> - <div className="flex flex-col gap-3"> - <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> - <div className="flex items-center gap-3"> - <Spin size="small" /> - <div className="flex flex-col gap-1"> - <div className="font-semibold text-default-700 text-sm">{request.toolName}</div> - <div className="text-default-500 text-xs">{t('agent.toolPermission.executing')}</div> - </div> - </div> - {request.inputPreview && ( - <div className="flex items-center justify-end"> - <Button - aria-label={ - showDetails - ? t('agent.toolPermission.aria.hideDetails') - : t('agent.toolPermission.aria.showDetails') - } - className="h-8 text-default-600 transition-colors hover:bg-default-200/50 hover:text-default-800" - onClick={() => setShowDetails((value) => !value)} - icon={<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />} - variant="text" - style={{ backgroundColor: 'transparent' }} - /> - </div> - )} - </div> - - {showDetails && request.inputPreview && ( - <div className="flex flex-col gap-3 border-default-200 border-t pt-3"> - <div className="rounded-md border border-default-200 bg-default-100 p-3"> - <p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide"> - {t('agent.toolPermission.inputPreview')} - </p> - <div className="max-h-[192px] overflow-auto font-mono text-xs"> - <pre className="whitespace-pre-wrap break-all p-2 text-left">{request.inputPreview}</pre> - </div> - </div> - </div> - )} + const toolContentItem: NonNullable<CollapseProps['items']>[number] = { + ...renderedItem, + label: ( + <div className="flex w-full items-start justify-between gap-2"> + <div className="min-w-0 flex-1">{renderedItem.label}</div> + <div className="shrink-0 pt-px">{statusIndicator}</div> </div> - </div> + ), + classNames: { + body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-60 overflow-auto' + } + } + + return ( + <StreamingContext value={false}> + <Collapse + className="w-full" + expandIconPosition="end" + size="small" + defaultActiveKey={[String(renderedItem.key ?? toolName)]} + items={[toolContentItem]} + /> + </StreamingContext> ) - } + }, [toolResponse.tool?.name, approval.input, toolResponse.arguments, statusInfo]) return ( - <div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm"> - <div className="flex flex-col gap-3"> - <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> - <div className="flex flex-col gap-1"> - <div className="font-semibold text-default-700 text-sm">{request.toolName}</div> - <div className="text-default-500 text-xs"> - {request.description?.trim() || t('agent.toolPermission.defaultDescription')} - </div> - </div> + <Container> + {/* Tool content area with status in header */} + {renderToolContent()} - <div className="flex flex-wrap items-center justify-end gap-2"> - <div - className={`rounded px-2 py-0.5 font-medium text-xs ${ - isExpired ? 'text-[var(--color-error)]' : 'text-[var(--color-status-warning)]' - }`}> - {isExpired - ? t('agent.toolPermission.expired') - : t('agent.toolPermission.pending', { seconds: remainingSeconds })} - </div> + {/* Bottom action bar - only show when not invoking */} + {!approval.isExecuting && ( + <ActionsBar> + <ToolApprovalActionsComponent {...approval} /> + </ActionsBar> + )} - <div className="flex items-center gap-1"> - <Button - aria-label={t('agent.toolPermission.aria.denyRequest')} - className="h-8" - color="danger" - disabled={isSubmitting || isExpired} - loading={isSubmittingDeny} - onClick={() => handleDecision('deny')} - icon={<CircleX size={16} />} - iconPosition={'start'} - variant="outlined"> - {t('agent.toolPermission.button.cancel')} - </Button> - - <Button - aria-label={t('agent.toolPermission.aria.allowRequest')} - className="h-8 px-3" - color="primary" - disabled={isSubmitting || isExpired} - loading={isSubmittingAllow} - onClick={() => handleDecision('allow')} - icon={<CirclePlay size={16} />} - iconPosition={'start'} - variant="solid"> - {t('agent.toolPermission.button.run')} - </Button> - - <Button - aria-label={ - showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails') - } - className="h-8 text-default-600 transition-colors hover:bg-default-200/50 hover:text-default-800" - onClick={() => setShowDetails((value) => !value)} - icon={<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />} - variant="text" - style={{ backgroundColor: 'transparent' }} - /> - </div> - </div> + {approval.isExpired && !approval.isSubmitting && !approval.isExecuting && ( + <div className="px-3 pb-2 text-center text-danger-500 text-xs"> + {t('agent.toolPermission.permissionExpired')} </div> - - {showDetails && ( - <div className="flex flex-col gap-3 border-default-200 border-t pt-3"> - <div className="rounded-lg bg-default-200/60 px-3 py-2 text-default-600 text-sm"> - {t('agent.toolPermission.confirmation')} - </div> - - <div className="rounded-md border border-default-200 bg-default-100 p-3"> - <p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide"> - {t('agent.toolPermission.inputPreview')} - </p> - <div className="max-h-[192px] overflow-auto font-mono text-xs"> - <pre className="whitespace-pre-wrap break-all p-2 text-left">{request.inputPreview}</pre> - </div> - </div> - - {request.requiresPermissions && ( - <div className="rounded-md border border-warning-300 bg-warning-50 p-3 text-warning-700 text-xs"> - {t('agent.toolPermission.requiresElevatedPermissions')} - </div> - )} - - {request.suggestions.length > 0 && ( - <div className="rounded-md border border-default-200 bg-default-50 p-3 text-default-500 text-xs"> - {request.suggestions.length === 1 - ? t('agent.toolPermission.suggestion.permissionUpdateSingle') - : t('agent.toolPermission.suggestion.permissionUpdateMultiple')} - </div> - )} - </div> - )} - - {isExpired && !isSubmitting && ( - <div className="text-center text-danger-500 text-xs">{t('agent.toolPermission.permissionExpired')}</div> - )} - </div> - </div> + )} + </Container> ) } +const Container = styled.div` + width: 100%; + max-width: 36rem; + border-radius: 0.75rem; + border: 1px solid var(--color-border); + background-color: var(--color-background-soft); + overflow: hidden; + + .ant-collapse { + border: none; + border-radius: 0; + background: transparent; + } + + .ant-collapse-item { + border: none; + } + + .ant-collapse-header { + padding: 8px 12px !important; + } +` + +const ActionsBar = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + padding: 8px 12px; + border-top: 1px solid var(--color-border); + background-color: var(--color-background); +` + export default ToolPermissionRequestCard diff --git a/src/renderer/src/pages/home/Messages/Tools/__tests__/MessageAgentTools.test.tsx b/src/renderer/src/pages/home/Messages/Tools/__tests__/MessageAgentTools.test.tsx new file mode 100644 index 0000000000..5f48306fb9 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/__tests__/MessageAgentTools.test.tsx @@ -0,0 +1,376 @@ +import type { NormalToolResponse } from '@renderer/types' +import { render, screen } from '@testing-library/react' +import { parse as parsePartialJson } from 'partial-json' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { isValidAgentToolsType, MessageAgentTools } from '../MessageAgentTools' + +vi.mock('@renderer/services/AssistantService', () => ({ + getDefaultAssistant: vi.fn(() => ({ + id: 'test-assistant', + name: 'Test Assistant', + settings: {} + })), + getDefaultTopic: vi.fn(() => ({ + id: 'test-topic', + assistantId: 'test-assistant', + createdAt: new Date().toISOString() + })) +})) + +// Mock dependencies +const mockUseAppSelector = vi.fn() +const mockUseTranslation = vi.fn() + +vi.mock('@renderer/store', () => ({ + useAppSelector: (selector: any) => mockUseAppSelector(selector) +})) + +vi.mock('@renderer/store/toolPermissions', () => ({ + selectPendingPermission: vi.fn() +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => mockUseTranslation(), + initReactI18next: { + type: '3rdParty', + init: vi.fn() + } +})) + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + }) + } +})) + +// Mock antd components +vi.mock('antd', async (importOriginal) => { + const actual = (await importOriginal()) as Record<string, unknown> + return { + ...actual, + Collapse: ({ items, defaultActiveKey, className }: any) => ( + <div data-testid="collapse" className={className} data-active-key={JSON.stringify(defaultActiveKey)}> + {items?.map((item: any) => ( + <div key={item.key} data-testid={`collapse-item-${item.key}`}> + <div data-testid={`collapse-header-${item.key}`}>{item.label}</div> + <div data-testid={`collapse-content-${item.key}`}>{item.children}</div> + </div> + ))} + </div> + ), + Spin: ({ size }: any) => <div data-testid="spin" data-size={size} />, + Skeleton: { + Input: ({ style }: any) => <span data-testid="skeleton-input" style={style} /> + }, + Tag: ({ children, className }: any) => ( + <span data-testid="tag" className={className}> + {children} + </span> + ), + Popover: ({ children }: any) => <>{children}</>, + Card: ({ children, className }: any) => ( + <div data-testid="card" className={className}> + {children} + </div> + ), + Button: ({ children, onClick, type, size, icon, disabled }: any) => ( + <button + type="button" + data-testid="button" + onClick={onClick} + data-type={type} + data-size={size} + disabled={disabled}> + {icon} + {children} + </button> + ) + } +}) + +// Mock lucide-react icons +vi.mock('lucide-react', async (importOriginal) => { + const actual = (await importOriginal()) as Record<string, unknown> + return { + ...actual, + Loader2: ({ className }: any) => <span data-testid="loader-icon" className={className} />, + FileText: () => <span data-testid="file-icon" />, + Terminal: () => <span data-testid="terminal-icon" />, + ListTodo: () => <span data-testid="list-icon" />, + Circle: () => <span data-testid="circle-icon" />, + CheckCircle: () => <span data-testid="check-circle-icon" />, + Clock: () => <span data-testid="clock-icon" />, + Check: () => <span data-testid="check-icon" />, + TriangleAlert: () => <span data-testid="triangle-alert-icon" />, + X: () => <span data-testid="x-icon" />, + Wrench: () => <span data-testid="wrench-icon" />, + ImageIcon: () => <span data-testid="image-icon" /> + } +}) + +// Mock LoadingIcon +vi.mock('@renderer/components/Icons', () => ({ + LoadingIcon: () => <span data-testid="loading-icon" /> +})) + +// Mock ToolPermissionRequestCard +vi.mock('../ToolPermissionRequestCard', () => ({ + default: () => <div data-testid="permission-card">Permission Required</div> +})) + +describe('MessageAgentTools', () => { + // Mock translations for tools + const mockTranslations: Record<string, string> = { + 'message.tools.labels.bash': 'Bash', + 'message.tools.labels.readFile': 'Read File', + 'message.tools.labels.todoWrite': 'Todo Write', + 'message.tools.labels.edit': 'Edit', + 'message.tools.labels.write': 'Write', + 'message.tools.labels.grep': 'Grep', + 'message.tools.labels.glob': 'Glob', + 'message.tools.labels.webSearch': 'Web Search', + 'message.tools.labels.webFetch': 'Web Fetch', + 'message.tools.labels.skill': 'Skill', + 'message.tools.labels.task': 'Task', + 'message.tools.labels.search': 'Search', + 'message.tools.labels.exitPlanMode': 'ExitPlanMode', + 'message.tools.labels.multiEdit': 'MultiEdit', + 'message.tools.labels.notebookEdit': 'NotebookEdit', + 'message.tools.labels.mcpServerTool': 'MCP Server Tool', + 'message.tools.labels.tool': 'Tool', + 'message.tools.sections.command': 'Command', + 'message.tools.sections.output': 'Output', + 'message.tools.sections.prompt': 'Prompt', + 'message.tools.sections.input': 'Input', + 'message.tools.status.done': 'Done', + 'message.tools.units.item': 'item', + 'message.tools.units.items': 'items', + 'message.tools.units.line': 'line', + 'message.tools.units.lines': 'lines', + 'message.tools.units.file': 'file', + 'message.tools.units.files': 'files', + 'message.tools.units.result': 'result', + 'message.tools.units.results': 'results' + } + + beforeEach(() => { + mockUseAppSelector.mockReturnValue(null) // No pending permission + mockUseTranslation.mockReturnValue({ + t: (key: string, fallback?: string) => mockTranslations[key] ?? fallback ?? key + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + // Helper to create tool response + const createToolResponse = (overrides: Partial<NormalToolResponse> = {}): NormalToolResponse => ({ + id: 'test-tool-1', + tool: { + id: 'Read', + name: 'Read', + description: 'Read a file', + type: 'provider' + }, + arguments: undefined, + status: 'pending', + toolCallId: 'call-123', + ...overrides + }) + + describe('isValidAgentToolsType', () => { + it('should return true for valid tool types', () => { + expect(isValidAgentToolsType('Read')).toBe(true) + expect(isValidAgentToolsType('Bash')).toBe(true) + expect(isValidAgentToolsType('TodoWrite')).toBe(true) + }) + + it('should return false for invalid tool types', () => { + expect(isValidAgentToolsType('InvalidTool')).toBe(false) + expect(isValidAgentToolsType('')).toBe(false) + expect(isValidAgentToolsType(null)).toBe(false) + expect(isValidAgentToolsType(undefined)).toBe(false) + }) + }) + + describe('partial-json parsing', () => { + it('should parse partial JSON correctly', () => { + // Test partial-json library behavior + const partialJson = '{"file_path": "/test.ts"' + const parsed = parsePartialJson(partialJson) + expect(parsed).toEqual({ file_path: '/test.ts' }) + }) + + it('should parse nested partial JSON', () => { + const partialJson = '{"todos": [{"content": "Task 1", "status": "pending"' + const parsed = parsePartialJson(partialJson) + expect(parsed).toEqual({ + todos: [{ content: 'Task 1', status: 'pending' }] + }) + }) + + it('should handle empty partial JSON', () => { + const partialJson = '{' + const parsed = parsePartialJson(partialJson) + expect(parsed).toEqual({}) + }) + }) + + describe('streaming tool rendering', () => { + it('should render dedicated tool renderer with partial arguments during streaming', () => { + const toolResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'streaming', + partialArguments: '{"file_path": "/test.ts"' + }) + + render(<MessageAgentTools toolResponse={toolResponse} />) + + // Should render the DEDICATED ReadTool component, not StreamingToolContent + // ReadTool uses 'Read File' as label, not just 'Read' + expect(screen.getByText('Read File')).toBeInTheDocument() + // Should show the filename from partial args + expect(screen.getByText('test.ts')).toBeInTheDocument() + }) + + it('should pass parsed partial arguments to dedicated tool renderer', () => { + const toolResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'streaming', + partialArguments: '{"file_path": "/path/to/myfile.ts", "offset": 10' + }) + + render(<MessageAgentTools toolResponse={toolResponse} />) + + // Should use dedicated ReadTool renderer + expect(screen.getByText('Read File')).toBeInTheDocument() + // Should show the filename extracted by ReadTool + expect(screen.getByText('myfile.ts')).toBeInTheDocument() + }) + + it('should update dedicated renderer as more arguments stream in', () => { + const initialResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'streaming', + partialArguments: '{"file_path": "/test/partial' + }) + + const { rerender } = render(<MessageAgentTools toolResponse={initialResponse} />) + + // Should use dedicated renderer even with partial path + expect(screen.getByText('Read File')).toBeInTheDocument() + + // Update with status changed to pending when arguments complete + const updatedResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'pending', + partialArguments: '{"file_path": "/test/complete.ts", "limit": 100}' + }) + + rerender(<MessageAgentTools toolResponse={updatedResponse} />) + + // When pending with no permission, shows ToolStatusIndicator with loading icon + expect(screen.getByTestId('loading-icon')).toBeInTheDocument() + }) + }) + + describe('completed tool rendering', () => { + it('should render tool with full arguments when done', () => { + const toolResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'done', + arguments: { file_path: '/test.ts', limit: 100 }, + response: 'file content here' + }) + + render(<MessageAgentTools toolResponse={toolResponse} />) + + // Should render the complete tool with output + expect(screen.getByText('Read File')).toBeInTheDocument() + }) + + it('should render error state correctly', () => { + const toolResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'error', + arguments: { file_path: '/nonexistent.ts' }, + response: 'File not found' + }) + + render(<MessageAgentTools toolResponse={toolResponse} />) + + // Should still render the tool component + expect(screen.getByText('Read File')).toBeInTheDocument() + }) + }) + + describe('pending without streaming', () => { + it('should show permission card when pending permission exists', () => { + mockUseAppSelector.mockReturnValue({ toolCallId: 'call-123' }) // Has pending permission + + const toolResponse = createToolResponse({ + status: 'pending', + partialArguments: undefined + }) + + render(<MessageAgentTools toolResponse={toolResponse} />) + + expect(screen.getByTestId('permission-card')).toBeInTheDocument() + }) + + it('should show pending indicator when no streaming and no permission', () => { + const toolResponse = createToolResponse({ + status: 'pending', + partialArguments: undefined + }) + + render(<MessageAgentTools toolResponse={toolResponse} />) + + // Should show the ToolStatusIndicator with loading icon + expect(screen.getByTestId('loading-icon')).toBeInTheDocument() + }) + }) + + describe('TodoWrite streaming', () => { + it('should render TodoWrite dedicated renderer with partial todos during streaming', () => { + const toolResponse = createToolResponse({ + tool: { id: 'TodoWrite', name: 'TodoWrite', description: 'Write todos', type: 'provider' }, + status: 'streaming', + partialArguments: + '{"todos": [{"content": "First task", "status": "pending", "activeForm": "Working on first task"}' + }) + + render(<MessageAgentTools toolResponse={toolResponse} />) + + // Should render the DEDICATED TodoWriteTool component, not StreamingToolContent + // TodoWriteTool uses 'Todo Write' (with space) as label + expect(screen.getByText('Todo Write')).toBeInTheDocument() + // The partial todo content should be visible in the dedicated renderer + expect(screen.getByText(/First task/)).toBeInTheDocument() + }) + }) + + describe('Bash streaming', () => { + it('should render Bash dedicated renderer with partial command during streaming', () => { + const toolResponse = createToolResponse({ + tool: { id: 'Bash', name: 'Bash', description: 'Execute command', type: 'provider' }, + status: 'streaming', + partialArguments: '{"command": "npm install",' + }) + + render(<MessageAgentTools toolResponse={toolResponse} />) + + // Should render the DEDICATED BashTool component + expect(screen.getByText('Bash')).toBeInTheDocument() + // Command should be visible in the dedicated renderer + expect(screen.getByText(/npm install/)).toBeInTheDocument() + }) + }) +}) diff --git a/src/renderer/src/pages/home/Messages/Tools/hooks/index.ts b/src/renderer/src/pages/home/Messages/Tools/hooks/index.ts new file mode 100644 index 0000000000..034926b4d2 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/hooks/index.ts @@ -0,0 +1,12 @@ +// Tool approval hooks - unified abstraction for MCP and Agent tool approval +export { + isBlockWaitingApproval, + type ToolApprovalActions, + type ToolApprovalState, + useAgentToolApproval, + type UseAgentToolApprovalOptions, + useMcpToolApproval, + type UseMcpToolApprovalOptions, + useToolApproval, + type UseToolApprovalOptions +} from './useToolApproval' diff --git a/src/renderer/src/pages/home/Messages/Tools/hooks/useAgentToolApproval.ts b/src/renderer/src/pages/home/Messages/Tools/hooks/useAgentToolApproval.ts new file mode 100644 index 0000000000..df244cdc2b --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/hooks/useAgentToolApproval.ts @@ -0,0 +1,163 @@ +import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' +import { loggerService } from '@logger' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions' +import type { NormalToolResponse } from '@renderer/types' +import type { ToolMessageBlock } from '@renderer/types/newMessage' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import type { ToolApprovalActions, ToolApprovalState } from './useToolApproval' + +const logger = loggerService.withContext('useAgentToolApproval') + +export interface UseAgentToolApprovalOptions { + /** Direct toolCallId (alternative to extracting from block) */ + toolCallId?: string +} + +/** + * Hook for Agent tool approval logic + * Can be used with: + * - A ToolMessageBlock (extracts toolCallId from metadata) + * - A direct toolCallId via options + */ +export function useAgentToolApproval( + block?: ToolMessageBlock | null, + options: UseAgentToolApprovalOptions = {} +): ToolApprovalState & ToolApprovalActions { + const { t } = useTranslation() + const dispatch = useAppDispatch() + + const toolResponse = block?.metadata?.rawMcpToolResponse as NormalToolResponse | undefined + const toolCallId = options.toolCallId ?? toolResponse?.toolCallId ?? '' + + const request = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolCallId)) + + const [now, setNow] = useState(() => Date.now()) + + // Update time every 500ms to track expiration + useEffect(() => { + if (!request) return + + logger.debug('Tracking agent tool permission', { + requestId: request.requestId, + toolName: request.toolName, + expiresAt: request.expiresAt + }) + + setNow(Date.now()) + + const interval = window.setInterval(() => { + setNow(Date.now()) + }, 500) + + return () => { + window.clearInterval(interval) + } + }, [request]) + + const remainingMs = useMemo(() => { + if (!request) return 0 + return Math.max(0, request.expiresAt - now) + }, [request, now]) + + const remainingSeconds = useMemo(() => Math.ceil(remainingMs / 1000), [remainingMs]) + const isExpired = remainingMs <= 0 + + const isSubmittingAllow = request?.status === 'submitting-allow' + const isSubmittingDeny = request?.status === 'submitting-deny' + const isSubmitting = isSubmittingAllow || isSubmittingDeny + const isInvoking = request?.status === 'invoking' + const isPending = request?.status === 'pending' + + const handleDecision = useCallback( + async ( + behavior: 'allow' | 'deny', + extra?: { + updatedInput?: Record<string, unknown> + updatedPermissions?: PermissionUpdate[] + message?: string + } + ) => { + if (!request) return + + logger.debug('Submitting agent tool permission decision', { + requestId: request.requestId, + toolName: request.toolName, + behavior + }) + + dispatch(toolPermissionsActions.submissionSent({ requestId: request.requestId, behavior })) + + try { + const payload = { + requestId: request.requestId, + behavior, + ...(behavior === 'allow' + ? { + updatedInput: extra?.updatedInput ?? request.input, + updatedPermissions: extra?.updatedPermissions + } + : { + message: extra?.message ?? t('agent.toolPermission.defaultDenyMessage') + }) + } + + const response = await window.api.agentTools.respondToPermission(payload) + + if (!response?.success) { + throw new Error('Renderer response rejected by main process') + } + + logger.debug('Tool permission decision acknowledged by main process', { + requestId: request.requestId, + behavior + }) + } catch (error) { + logger.error('Failed to send tool permission response', error as Error) + window.toast?.error?.(t('agent.toolPermission.error.sendFailed')) + dispatch(toolPermissionsActions.submissionFailed({ requestId: request.requestId })) + } + }, + [dispatch, request, t] + ) + + const confirm = useCallback(() => { + handleDecision('allow') + }, [handleDecision]) + + const cancel = useCallback(() => { + handleDecision('deny') + }, [handleDecision]) + + // Auto-approve with suggestions if available + const autoApprove = useCallback(() => { + if (request?.suggestions?.length) { + handleDecision('allow', { updatedPermissions: request.suggestions }) + } + }, [handleDecision, request?.suggestions]) + + // Determine isWaiting - only when pending and not expired + const isWaiting = !!request && isPending && !isExpired + // isExecuting - when invoking or submitting allow + const isExecuting = isInvoking || isSubmittingAllow + + return { + // State + isWaiting, + isExecuting, + countdown: undefined, + expiresAt: request?.expiresAt, + remainingSeconds, + isExpired: !!request && isExpired, + isSubmitting, + // Agent-specific: input from permission request + input: request?.input, + + // Actions + confirm, + cancel, + autoApprove: request?.suggestions?.length ? autoApprove : undefined + } +} diff --git a/src/renderer/src/pages/home/Messages/Tools/hooks/useMcpToolApproval.ts b/src/renderer/src/pages/home/Messages/Tools/hooks/useMcpToolApproval.ts new file mode 100644 index 0000000000..52cac75860 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/hooks/useMcpToolApproval.ts @@ -0,0 +1,139 @@ +import { loggerService } from '@logger' +import { useMCPServers } from '@renderer/hooks/useMCPServers' +import { useTimer } from '@renderer/hooks/useTimer' +import type { MCPToolResponse } from '@renderer/types' +import type { ToolMessageBlock } from '@renderer/types/newMessage' +import { isToolAutoApproved } from '@renderer/utils/mcp-tools' +import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import type { ToolApprovalActions, ToolApprovalState } from './useToolApproval' + +const logger = loggerService.withContext('useMcpToolApproval') + +const COUNTDOWN_TIME = 30 + +export interface UseMcpToolApprovalOptions { + /** Disable countdown auto-approve */ + disableCountdown?: boolean +} + +/** + * Hook for MCP tool approval logic + * Extracts approval state management from MessageMcpTool + */ +export function useMcpToolApproval( + block: ToolMessageBlock, + options: UseMcpToolApprovalOptions = {} +): ToolApprovalState & ToolApprovalActions { + const { disableCountdown = false } = options + const { t } = useTranslation() + const { mcpServers, updateMCPServer } = useMCPServers() + const { setTimeoutTimer, clearTimeoutTimer } = useTimer() + + const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse | undefined + const tool = toolResponse?.tool + const id = toolResponse?.id ?? '' + const status = toolResponse?.status + + const isPending = status === 'pending' + + const isAutoApproved = useMemo(() => { + if (!tool) return false + return isToolAutoApproved( + tool, + mcpServers.find((s) => s.id === tool.serverId) + ) + }, [tool, mcpServers]) + + const [countdown, setCountdown] = useState<number>(COUNTDOWN_TIME) + const [isConfirmed, setIsConfirmed] = useState(isAutoApproved) + + // Compute approval states + const isWaiting = isPending && !isAutoApproved && !isConfirmed + const isExecuting = isPending && (isAutoApproved || isConfirmed) + + // Countdown timer effect + useEffect(() => { + if (!isWaiting || disableCountdown) return + + if (countdown > 0) { + setTimeoutTimer( + `countdown-${id}`, + () => { + logger.debug(`countdown: ${countdown}`) + setCountdown((prev) => prev - 1) + }, + 1000 + ) + } else if (countdown === 0) { + setIsConfirmed(true) + confirmToolAction(id) + } + + return () => clearTimeoutTimer(`countdown-${id}`) + }, [countdown, id, isWaiting, disableCountdown, setTimeoutTimer, clearTimeoutTimer]) + + const cancelCountdown = useCallback(() => { + clearTimeoutTimer(`countdown-${id}`) + }, [clearTimeoutTimer, id]) + + const confirm = useCallback(() => { + cancelCountdown() + setIsConfirmed(true) + confirmToolAction(id) + }, [cancelCountdown, id]) + + const cancel = useCallback(() => { + cancelCountdown() + cancelToolAction(id) + }, [cancelCountdown, id]) + + const autoApprove = useCallback(async () => { + cancelCountdown() + + if (!tool || !tool.name) { + return + } + + const server = mcpServers.find((s) => s.id === tool.serverId) + if (!server) { + return + } + + let disabledAutoApproveTools = [...(server.disabledAutoApproveTools || [])] + + // Remove tool from disabledAutoApproveTools to enable auto-approve + disabledAutoApproveTools = disabledAutoApproveTools.filter((name) => name !== tool.name) + + const updatedServer = { + ...server, + disabledAutoApproveTools + } + + updateMCPServer(updatedServer) + + // Also confirm the current tool + setIsConfirmed(true) + confirmToolAction(id) + + window.toast.success(t('message.tools.autoApproveEnabled', 'Auto-approve enabled for this tool')) + }, [cancelCountdown, tool, mcpServers, updateMCPServer, id, t]) + + return { + // State + isWaiting, + isExecuting, + countdown, + remainingSeconds: countdown, + isExpired: false, // MCP tools don't expire, they auto-confirm + isSubmitting: false, + input: undefined, // MCP tools get input from toolResponse.arguments + + // Actions + confirm, + cancel, + autoApprove: isWaiting ? autoApprove : undefined + } +} diff --git a/src/renderer/src/pages/home/Messages/Tools/hooks/useToolApproval.ts b/src/renderer/src/pages/home/Messages/Tools/hooks/useToolApproval.ts new file mode 100644 index 0000000000..e7121dbaa8 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/hooks/useToolApproval.ts @@ -0,0 +1,77 @@ +import type { ToolMessageBlock } from '@renderer/types/newMessage' + +import { useAgentToolApproval } from './useAgentToolApproval' +import { useMcpToolApproval, type UseMcpToolApprovalOptions } from './useMcpToolApproval' + +/** + * Unified tool approval state + */ +export interface ToolApprovalState { + /** Whether the tool is waiting for user confirmation */ + isWaiting: boolean + /** Whether the tool is currently executing after approval */ + isExecuting: boolean + /** Countdown seconds (MCP only) */ + countdown?: number + /** Expiration timestamp (Agent only) */ + expiresAt?: number + /** Remaining seconds until auto-confirm (MCP) or expiration (Agent) */ + remainingSeconds: number + /** Whether the request has expired (Agent only) */ + isExpired: boolean + /** Whether a submission is in progress (Agent only) */ + isSubmitting: boolean + /** Tool input from permission request (Agent only) */ + input?: Record<string, unknown> +} + +/** + * Unified tool approval actions + */ +export interface ToolApprovalActions { + /** Confirm/approve the tool execution */ + confirm: () => void | Promise<void> + /** Cancel/deny the tool execution */ + cancel: () => void | Promise<void> + /** Auto-approve this tool for future calls (if available) */ + autoApprove?: () => void | Promise<void> +} + +export interface UseToolApprovalOptions extends UseMcpToolApprovalOptions { + /** Force a specific approval type */ + forceType?: 'mcp' | 'agent' +} + +/** + * Unified hook for tool approval - automatically selects between MCP and Agent approval + * based on the tool type in the block metadata. + * + * @param block - The tool message block + * @param options - Optional configuration + * @returns Unified approval state and actions + */ +export function useToolApproval( + block: ToolMessageBlock, + options: UseToolApprovalOptions = {} +): ToolApprovalState & ToolApprovalActions { + const { forceType, ...mcpOptions } = options + + const toolResponse = block.metadata?.rawMcpToolResponse + const tool = toolResponse?.tool + + const isMcpTool = forceType === 'mcp' || (forceType !== 'agent' && tool?.type === 'mcp') + const mcpApproval = useMcpToolApproval(block, mcpOptions) + const agentApproval = useAgentToolApproval(block) + + return isMcpTool ? mcpApproval : agentApproval +} + +/** + * Determine if a block needs approval (either MCP or Agent) + */ +export function isBlockWaitingApproval(block: ToolMessageBlock): boolean { + return block.metadata?.rawMcpToolResponse?.status === 'pending' +} + +export { useAgentToolApproval, type UseAgentToolApprovalOptions } from './useAgentToolApproval' +export { useMcpToolApproval, type UseMcpToolApprovalOptions } from './useMcpToolApproval' diff --git a/src/renderer/src/pages/home/Messages/Tools/shared/ArgsTable.tsx b/src/renderer/src/pages/home/Messages/Tools/shared/ArgsTable.tsx new file mode 100644 index 0000000000..8053d0986a --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/shared/ArgsTable.tsx @@ -0,0 +1,104 @@ +import styled from 'styled-components' + +import { SkeletonSpan } from '../MessageAgentTools/GenericTools' + +/** + * Format argument value for display in table + */ +export const formatArgValue = (value: unknown): string => { + if (value === null) return 'null' + if (value === undefined) return '' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return JSON.stringify(value) +} + +/** + * Shared argument table component for displaying tool parameters + * Used by both MCP tools and Agent tools + */ +export function ToolArgsTable({ + args, + title, + isStreaming = false +}: { + args: Record<string, unknown> | unknown[] | null | undefined + title?: string + isStreaming?: boolean +}) { + if (!args) return null + + // Handle both object and array args + const entries: Array<[string, unknown]> = Array.isArray(args) ? [['arguments', args]] : Object.entries(args) + + if (entries.length === 0 && !isStreaming) return null + + return ( + <ArgsSection> + {title && <ArgsSectionTitle>{title}</ArgsSectionTitle>} + <ArgsTable> + <tbody> + {entries.map(([key, value]) => ( + <tr key={key}> + <ArgKey>{key}</ArgKey> + <ArgValue>{formatArgValue(value)}</ArgValue> + </tr> + ))} + {isStreaming && ( + <tr> + <ArgKey> + <SkeletonSpan width="60px" /> + </ArgKey> + <ArgValue> + <SkeletonSpan width="120px" /> + </ArgValue> + </tr> + )} + </tbody> + </ArgsTable> + </ArgsSection> + ) +} + +// Styled components extracted from MessageMcpTool + +export const ArgsSection = styled.div` + padding: 8px 12px; + font-family: var(--font-family-mono, monospace); + font-size: 12px; + line-height: 1.5; +` + +export const ArgsSectionTitle = styled.div` + font-size: 11px; + font-weight: 600; + color: var(--color-text-3); + text-transform: uppercase; + margin-bottom: 8px; +` + +export const ArgsTable = styled.table` + width: 100%; + border-collapse: collapse; +` + +export const ArgKey = styled.td` + color: var(--color-primary); + padding: 4px 8px 4px 0; + white-space: nowrap; + vertical-align: top; + font-weight: 500; + width: 1%; +` + +export const ArgValue = styled.td` + color: var(--color-text); + padding: 4px 0; + word-break: break-all; + white-space: pre-wrap; +` + +export const ResponseSection = styled.div` + padding: 8px 12px; + border-top: 1px solid var(--color-border); +` diff --git a/src/renderer/src/pages/home/Messages/Tools/shared/truncateOutput.ts b/src/renderer/src/pages/home/Messages/Tools/shared/truncateOutput.ts new file mode 100644 index 0000000000..4d417e520b --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/shared/truncateOutput.ts @@ -0,0 +1,44 @@ +/** + * Truncate output string to prevent UI performance issues + * Tries to truncate at a newline boundary to avoid cutting in the middle of a line + */ + +const MAX_OUTPUT_LENGTH = 50000 + +/** + * Count non-empty lines in a string + */ +export function countLines(output: string | undefined | null): number { + if (!output) return 0 + return output.split('\n').filter((line) => line.trim()).length +} + +export interface TruncateResult { + data: string + isTruncated: boolean + originalLength: number +} + +export function truncateOutput( + output: string | undefined | null, + maxLength: number = MAX_OUTPUT_LENGTH +): TruncateResult { + if (!output) { + return { data: '', isTruncated: false, originalLength: 0 } + } + + const originalLength = output.length + + if (output.length <= maxLength) { + return { data: output, isTruncated: false, originalLength } + } + + // Truncate and try to find a newline boundary + const truncated = output.slice(0, maxLength) + const lastNewline = truncated.lastIndexOf('\n') + + // Only use newline boundary if it's reasonably close to maxLength (within 20%) + const data = lastNewline > maxLength * 0.8 ? truncated.slice(0, lastNewline) : truncated + + return { data, isTruncated: true, originalLength } +} diff --git a/src/renderer/src/pages/home/Navbar.tsx b/src/renderer/src/pages/home/Navbar.tsx index 0d93a4bd01..f63939eb01 100644 --- a/src/renderer/src/pages/home/Navbar.tsx +++ b/src/renderer/src/pages/home/Navbar.tsx @@ -1,7 +1,6 @@ import { Navbar, NavbarCenter, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' import { HStack } from '@renderer/components/Layout' import SearchPopup from '@renderer/components/Popups/SearchPopup' -import { isLinux, isWin } from '@renderer/config/constant' import { modelGenerating } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' @@ -123,7 +122,7 @@ const HeaderNavbar: FC<Props> = ({ justifyContent: 'flex-end', flex: activeTopicOrSession === 'topic' ? 1 : 'none', position: 'relative', - paddingRight: isWin || isLinux ? '144px' : '15px', + paddingRight: '15px', minWidth: activeTopicOrSession === 'topic' ? '' : 'auto' }} className="home-navbar-right"> diff --git a/src/renderer/src/pages/home/Tabs/components/AddButton.tsx b/src/renderer/src/pages/home/Tabs/components/AddButton.tsx index c96468db15..c935f1fb20 100644 --- a/src/renderer/src/pages/home/Tabs/components/AddButton.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AddButton.tsx @@ -6,6 +6,7 @@ import styled from 'styled-components' const StyledButton = styled(Button)` height: 36px; + min-height: 36px; width: calc(var(--assistants-width) - 20px); justify-content: flex-start; border-radius: var(--list-item-border-radius); diff --git a/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx b/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx index 08093cf102..f35d9fc267 100644 --- a/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx @@ -9,7 +9,7 @@ import type { MenuProps } from 'antd' import { Dropdown, Tooltip } from 'antd' import { Bot, MoreVertical } from 'lucide-react' import type { FC } from 'react' -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' // const logger = loggerService.withContext('AgentItem') @@ -24,6 +24,7 @@ interface AgentItemProps { const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) => { const { t } = useTranslation() const { clickAssistantToShowTopic, topicPosition, assistantIconType } = useSettings() + const [isHovered, setIsHovered] = useState(false) const handlePress = useCallback(() => { // Show session sidebar if setting is enabled (reusing the assistant setting for consistency) @@ -35,13 +36,9 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) = onPress() }, [clickAssistantToShowTopic, topicPosition, onPress]) - const handleMoreClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - agent.id && AgentSettingsPopup.show({ agentId: agent.id }) - }, - [agent.id] - ) + const handleMenuButtonClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + }, []) const menuItems: MenuProps['items'] = useMemo( () => [ @@ -75,17 +72,26 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) = menu={{ items: menuItems }} trigger={['contextMenu']} popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}> - <Container onClick={handlePress} isActive={isActive}> + <Container + onClick={handlePress} + isActive={isActive} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)}> <AssistantNameRow className="name" title={agent.name ?? agent.id}> <AgentNameWrapper> <AgentLabel agent={agent} hideIcon={assistantIconType === 'none'} /> </AgentNameWrapper> - {isActive && ( - <MenuButton onClick={handleMoreClick}> - <MoreVertical size={14} className="text-[var(--color-text-secondary)]" /> - </MenuButton> + {(isActive || isHovered) && ( + <Dropdown + menu={{ items: menuItems }} + trigger={['click']} + popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}> + <MenuButton onClick={handleMenuButtonClick}> + <MoreVertical size={14} className="text-[var(--color-text-secondary)]" /> + </MenuButton> + </Dropdown> )} - {!isActive && assistantIconType !== 'none' && <BotIcon />} + {!isActive && !isHovered && assistantIconType !== 'none' && <BotIcon />} </AssistantNameRow> </Container> </Dropdown> diff --git a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx index 92d5e88dae..6d952097f7 100644 --- a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx @@ -69,6 +69,7 @@ const AssistantItem: FC<AssistantItemProps> = ({ const { assistants, updateAssistants } = useAssistants() const [isPending, setIsPending] = useState(false) + const [isHovered, setIsHovered] = useState(false) const dispatch = useAppDispatch() useEffect(() => { @@ -148,20 +149,20 @@ const AssistantItem: FC<AssistantItemProps> = ({ [assistant.emoji, assistantName] ) - const handleMoreClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - AssistantSettingsPopup.show({ assistant }) - }, - [assistant] - ) + const handleMenuButtonClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + }, []) return ( <Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}> - <Container onClick={handleSwitch} isActive={isActive}> + <Container + onClick={handleSwitch} + isActive={isActive} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)}> <AssistantNameRow className="name" title={fullAssistantName}> <AssistantAvatar assistant={assistant} @@ -170,10 +171,15 @@ const AssistantItem: FC<AssistantItemProps> = ({ /> <AssistantName className="text-nowrap">{assistantName}</AssistantName> </AssistantNameRow> - {isActive && ( - <MenuButton onClick={handleMoreClick}> - <MoreVertical size={14} className="text-[var(--color-text-secondary)]" /> - </MenuButton> + {(isActive || isHovered) && ( + <Dropdown + menu={{ items: menuItems }} + trigger={['click']} + popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}> + <MenuButton onClick={handleMenuButtonClick}> + <MoreVertical size={14} className="text-[var(--color-text-secondary)]" /> + </MenuButton> + </Dropdown> )} </Container> </Dropdown> diff --git a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx index 2ec754d23b..ae33906cca 100644 --- a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx @@ -104,9 +104,11 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => { scrollerStyle={{ overflowX: 'hidden' }} autoHideScrollbar header={ - <AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]"> - {t('agent.session.add.title')} - </AddButton> + <div className="mt-[2px]"> + <AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]"> + {t('agent.session.add.title')} + </AddButton> + </div> }> {(session) => ( <SessionItem diff --git a/src/renderer/src/pages/home/Tabs/components/Topics.tsx b/src/renderer/src/pages/home/Tabs/components/Topics.tsx index 29232e65d9..a4a6f2b01e 100644 --- a/src/renderer/src/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Topics.tsx @@ -1,4 +1,5 @@ import AssistantAvatar from '@renderer/components/Avatar/AssistantAvatar' +import type { DraggableVirtualListRef } from '@renderer/components/DraggableList' import { DraggableVirtualList } from '@renderer/components/DraggableList' import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' @@ -85,6 +86,7 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se const [deletingTopicId, setDeletingTopicId] = useState<string | null>(null) const deleteTimerRef = useRef<NodeJS.Timeout>(null) const [editingTopicId, setEditingTopicId] = useState<string | null>(null) + const listRef = useRef<DraggableVirtualListRef>(null) // 管理模式状态 const manageState = useTopicManageMode() @@ -168,10 +170,46 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se const onPinTopic = useCallback( (topic: Topic) => { + let newIndex = 0 + + if (topic.pinned) { + // 取消固定:将话题移到未固定话题的顶部 + const pinnedTopics = assistant.topics.filter((t) => t.pinned) + const unpinnedTopics = assistant.topics.filter((t) => !t.pinned) + + // 构建新顺序:其他固定话题 + 取消固定的话题(移到顶部) + 其他未固定话题 + const reorderedTopics = [ + ...pinnedTopics.filter((t) => t.id !== topic.id), // 其他固定话题 + topic, // 取消固定的话题移到顶部 + ...unpinnedTopics // 其他未固定话题 + ] + + newIndex = pinnedTopics.length - 1 // 最后一个固定话题的索引 + 1 = 第一个未固定的索引 + updateTopics(reorderedTopics) + } else { + // 固定话题:移到固定区域顶部 + const pinnedTopics = assistant.topics.filter((t) => t.pinned) + const unpinnedTopics = assistant.topics.filter((t) => !t.pinned) + + const reorderedTopics = [ + topic, // 新固定的话题移到顶部 + ...pinnedTopics, // 其他固定话题 + ...unpinnedTopics.filter((t) => t.id !== topic.id) // 其他未固定话题(排除 topic) + ] + + newIndex = 0 + updateTopics(reorderedTopics) + } + const updatedTopic = { ...topic, pinned: !topic.pinned } updateTopic(updatedTopic) + + // 延迟滚动到话题位置(等待渲染完成) + setTimeout(() => { + listRef.current?.scrollToIndex(newIndex, { align: 'auto' }) + }, 50) }, - [updateTopic] + [assistant.topics, updateTopic, updateTopics] ) const onDeleteTopic = useCallback( @@ -529,6 +567,7 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se return ( <> <DraggableVirtualList + ref={listRef} className="topics-tab" list={filteredTopics} onUpdate={updateTopics} @@ -663,7 +702,7 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se </TopicPromptText> )} {showTopicTime && ( - <TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime> + <TopicTime className="time">{dayjs(topic.createdAt).format('YYYY/MM/DD HH:mm')}</TopicTime> )} </TopicListItem> </Dropdown> @@ -872,7 +911,8 @@ const HeaderRow = styled.div` align-items: center; gap: 6px; padding-right: 10px; - margin-bottom: 5px; + margin-bottom: 8px; + margin-top: 2px; ` const HeaderIconButton = styled.div` diff --git a/src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx b/src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx index 967d62f83b..fecdb25cf0 100644 --- a/src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx +++ b/src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx @@ -62,7 +62,7 @@ const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant, setAct } return ( - <div className="-mt-[4px] mb-[6px]"> + <div className="-mt-[2px] mb-[6px]"> <AddButton onClick={handleAddButtonClick}>{t('chat.add.assistant.title')}</AddButton> </div> ) diff --git a/src/renderer/src/pages/home/components/ChatNavbarContent.tsx b/src/renderer/src/pages/home/components/ChatNavbarContent.tsx index e58b290892..242714859e 100644 --- a/src/renderer/src/pages/home/components/ChatNavbarContent.tsx +++ b/src/renderer/src/pages/home/components/ChatNavbarContent.tsx @@ -130,14 +130,22 @@ const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity // ? t(permissionModeCard.titleKey, permissionModeCard.titleFallback) // : permissionMode + const getLastFolderName = (path: string): string => { + const trimmedPath = path.replace(/[/\\]+$/, '') + const parts = trimmedPath.split(/[/\\]/) + return parts[parts.length - 1] || path + } + const infoItems: ReactNode[] = [] const InfoTag = ({ text, + tooltip, className, onClick }: { text: string + tooltip?: string className?: string classNames?: {} onClick?: (e: React.MouseEvent) => void @@ -148,7 +156,7 @@ const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity onClick !== undefined ? 'cursor-pointer' : undefined, className )} - title={text} + title={tooltip ?? text} onClick={onClick}> <Folder className="h-3.5 w-3.5 shrink-0" /> <span className="block truncate">{text}</span> @@ -161,7 +169,8 @@ const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity infoItems.push( <InfoTag key="path" - text={firstAccessiblePath} + text={getLastFolderName(firstAccessiblePath)} + tooltip={firstAccessiblePath} className="max-w-60 transition-colors hover:border-primary hover:text-primary" onClick={() => { window.api.file diff --git a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx index e7f99a8037..0791c68539 100644 --- a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx +++ b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx @@ -10,8 +10,6 @@ import { useTranslation } from 'react-i18next' const logger = loggerService.withContext('QuotaTag') -const QUOTA_UNLIMITED = -9999 - const QuotaTag: FC<{ base: KnowledgeBase; providerId: PreprocessProviderId; quota?: number }> = ({ base, providerId, @@ -23,44 +21,40 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: PreprocessProviderId; quot useEffect(() => { const checkQuota = async () => { - if (provider.id !== 'mineru') return - // 使用用户的key时quota为无限 - if (provider.apiKey) { - setQuota(QUOTA_UNLIMITED) - updateProvider({ quota: QUOTA_UNLIMITED }) - return + const userId = getStoreSetting('userId') + const baseParams = getKnowledgeBaseParams(base) + try { + const response = await window.api.knowledgeBase.checkQuota({ + base: baseParams, + userId: userId as string + }) + setQuota(response) + updateProvider({ quota: response }) + } catch (error) { + logger.error('[KnowledgeContent] Error checking quota:', error as Error) } - if (quota === undefined) { - const userId = getStoreSetting('userId') - const baseParams = getKnowledgeBaseParams(base) - try { - const response = await window.api.knowledgeBase.checkQuota({ - base: baseParams, - userId: userId as string - }) - setQuota(response) - } catch (error) { - logger.error('[KnowledgeContent] Error checking quota:', error as Error) - } + } + + if (provider.id !== 'mineru') return + if (!provider.apiKey) { + if (quota !== undefined) { + setQuota(undefined) + updateProvider({ quota: undefined }) } + return } if (_quota !== undefined) { setQuota(_quota) updateProvider({ quota: _quota }) return } - checkQuota() - }, [_quota, base, provider.id, provider.apiKey, provider, quota, updateProvider]) + if (quota === undefined) { + checkQuota() + } + }, [_quota, base, provider.id, provider.apiKey, quota, updateProvider]) const getQuotaDisplay = () => { if (quota === undefined) return null - if (quota === QUOTA_UNLIMITED) { - return ( - <Tag color="orange" style={{ borderRadius: 20, margin: 0 }}> - {t('knowledge.quota_infinity', { name: provider.name })} - </Tag> - ) - } if (quota === 0) { return ( <Tag color="red" style={{ borderRadius: 20, margin: 0 }}> diff --git a/src/renderer/src/pages/minapps/MinAppPage.tsx b/src/renderer/src/pages/minapps/MinAppPage.tsx index e13a1059ce..4dc74a999f 100644 --- a/src/renderer/src/pages/minapps/MinAppPage.tsx +++ b/src/renderer/src/pages/minapps/MinAppPage.tsx @@ -1,5 +1,5 @@ import { loggerService } from '@logger' -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { allMinApps } from '@renderer/config/minapps' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinapps } from '@renderer/hooks/useMinapps' import { useNavbarPosition } from '@renderer/hooks/useSettings' @@ -51,7 +51,7 @@ const MinAppPage: FC = () => { if (!appId) return null // First try to find in default and custom mini-apps - let foundApp = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) + let foundApp = [...allMinApps, ...minapps].find((app) => app.id === appId) // If not found and we have cache, try to find in cache (for temporary apps) if (!foundApp && minAppsCache) { diff --git a/src/renderer/src/pages/minapps/MiniappSettings/MiniAppIconsManager.tsx b/src/renderer/src/pages/minapps/MiniappSettings/MiniAppIconsManager.tsx index 426d25cf17..0212477dd0 100644 --- a/src/renderer/src/pages/minapps/MiniappSettings/MiniAppIconsManager.tsx +++ b/src/renderer/src/pages/minapps/MiniappSettings/MiniAppIconsManager.tsx @@ -1,7 +1,7 @@ import { CloseOutlined } from '@ant-design/icons' import type { DraggableProvided, DroppableProvided, DropResult } from '@hello-pangea/dnd' import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd' -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { allMinApps } from '@renderer/config/minapps' import { useMinapps } from '@renderer/hooks/useMinapps' import { getMiniappsStatusLabel } from '@renderer/i18n/label' import type { MinAppType } from '@renderer/types' @@ -91,7 +91,9 @@ const MiniAppIconsManager: FC<MiniAppManagerProps> = ({ ) const renderProgramItem = (program: MinAppType, provided: DraggableProvided, listType: ListType) => { - const { name, logo } = DEFAULT_MIN_APPS.find((app) => app.id === program.id) || { name: program.name, logo: '' } + const appData = allMinApps.find((app) => app.id === program.id) + const name = appData?.nameKey ? t(appData.nameKey) : appData?.name || program.name + const logo = appData?.logo || '' return ( <ProgramItem ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}> diff --git a/src/renderer/src/pages/minapps/MiniappSettings/MiniAppSettings.tsx b/src/renderer/src/pages/minapps/MiniappSettings/MiniAppSettings.tsx index d8b61603a1..e54d80c369 100644 --- a/src/renderer/src/pages/minapps/MiniappSettings/MiniAppSettings.tsx +++ b/src/renderer/src/pages/minapps/MiniappSettings/MiniAppSettings.tsx @@ -1,5 +1,5 @@ import { UndoOutlined } from '@ant-design/icons' // 导入重置图标 -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { allMinApps } from '@renderer/config/minapps' import { useMinapps } from '@renderer/hooks/useMinapps' import { useSettings } from '@renderer/hooks/useSettings' import { SettingDescription, SettingDivider, SettingRowTitle, SettingTitle } from '@renderer/pages/settings' @@ -32,9 +32,9 @@ const MiniAppSettings: FC = () => { const debounceTimerRef = useRef<NodeJS.Timeout | null>(null) const handleResetMinApps = useCallback(() => { - setVisibleMiniApps(DEFAULT_MIN_APPS) + setVisibleMiniApps(allMinApps) setDisabledMiniApps([]) - updateMinapps(DEFAULT_MIN_APPS) + updateMinapps(allMinApps) updateDisabledMinapps([]) }, [updateDisabledMinapps, updateMinapps]) diff --git a/src/renderer/src/pages/minapps/NewAppButton.tsx b/src/renderer/src/pages/minapps/NewAppButton.tsx index 22d9f9345d..7845ae117c 100644 --- a/src/renderer/src/pages/minapps/NewAppButton.tsx +++ b/src/renderer/src/pages/minapps/NewAppButton.tsx @@ -1,6 +1,6 @@ import { PlusOutlined, UploadOutlined } from '@ant-design/icons' import { loggerService } from '@logger' -import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps } from '@renderer/config/minapps' +import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateAllMinApps } from '@renderer/config/minapps' import { useMinapps } from '@renderer/hooks/useMinapps' import type { MinAppType } from '@renderer/types' import { Button, Form, Input, Modal, Radio, Upload } from 'antd' @@ -60,7 +60,7 @@ const NewAppButton: FC<Props> = ({ size = 60 }) => { form.resetFields() setFileList([]) const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())] - updateDefaultMinApps(reloadedApps) + updateAllMinApps(reloadedApps) updateMinapps([...minapps, newApp]) } catch (error) { window.toast.error(t('settings.miniapps.custom.save_error')) diff --git a/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx index 7755432167..835f5dcf49 100644 --- a/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx +++ b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx @@ -10,7 +10,7 @@ import { } from '@ant-design/icons' import { loggerService } from '@logger' import { isDev } from '@renderer/config/constant' -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { allMinApps } from '@renderer/config/minapps' import { useMinapps } from '@renderer/hooks/useMinapps' import { useSettings } from '@renderer/hooks/useSettings' import { useAppDispatch } from '@renderer/store' @@ -50,7 +50,7 @@ const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOp const navigate = useNavigate() const [canGoBack, setCanGoBack] = useState(false) const [canGoForward, setCanGoForward] = useState(false) - const canPinned = DEFAULT_MIN_APPS.some((item) => item.id === app.id) + const canPinned = allMinApps.some((item) => item.id === app.id) const isPinned = pinned.some((item) => item.id === app.id) const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://') diff --git a/src/renderer/src/pages/minapps/components/WebviewSearch.tsx b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx index 1641e126ee..d484e3181a 100644 --- a/src/renderer/src/pages/minapps/components/WebviewSearch.tsx +++ b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx @@ -338,7 +338,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app type="text" onClick={goToPrevious} disabled={disableNavigation} - aria-label="Previous match" + aria-label={t('common.previous_match')} icon={<ChevronUp size={16} className="w-6" />} className="text-default-500 hover:text-default-900" /> @@ -347,7 +347,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app type="text" onClick={goToNext} disabled={disableNavigation} - aria-label="Next match" + aria-label={t('common.next_match')} icon={<ChevronDown size={16} className="w-6" />} className="text-default-500 hover:text-default-900" /> diff --git a/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx index 4deee62ad5..855180ecea 100644 --- a/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx +++ b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx @@ -10,7 +10,9 @@ const translations: Record<string, string> = { 'common.close': 'Close', 'common.error': 'Error', 'common.no_results': 'No results', - 'common.search': 'Search' + 'common.search': 'Search', + 'common.next_match': 'Next match', + 'common.previous_match': 'Previous match' } vi.mock('react-i18next', () => ({ diff --git a/src/renderer/src/pages/notes/HeaderNavbar.tsx b/src/renderer/src/pages/notes/HeaderNavbar.tsx index 92c66ba98d..d317343db6 100644 --- a/src/renderer/src/pages/notes/HeaderNavbar.tsx +++ b/src/renderer/src/pages/notes/HeaderNavbar.tsx @@ -53,6 +53,25 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand } }, [getCurrentNoteContent]) + const handleExportToWord = useCallback(async () => { + try { + const content = getCurrentNoteContent?.() + if (!content) { + window.toast.warning(t('notes.no_content_to_export')) + return + } + if (!activeNode) { + window.toast.warning(t('notes.no_note_selected')) + return + } + const fileName = activeNode.name.replace('.md', '') + await window.api.export.toWord(content, fileName) + } catch (error) { + logger.error('Failed to export to Word:', error as Error) + window.toast.error(t('notes.export_to_word_failed')) + } + }, [getCurrentNoteContent, activeNode]) + const handleShowSettings = useCallback(() => { GeneralPopup.show({ title: t('notes.settings.title'), @@ -142,6 +161,8 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand onClick: () => { if (item.copyAction) { handleCopyContent() + } else if (item.exportToWordAction) { + handleExportToWord() } else if (item.showSettingsPopup) { handleShowSettings() } else if (item.action) { diff --git a/src/renderer/src/pages/notes/MenuConfig.tsx b/src/renderer/src/pages/notes/MenuConfig.tsx index c157daa417..22c4c3c0fe 100644 --- a/src/renderer/src/pages/notes/MenuConfig.tsx +++ b/src/renderer/src/pages/notes/MenuConfig.tsx @@ -1,5 +1,5 @@ import type { NotesSettings } from '@renderer/store/note' -import { Copy, MonitorSpeaker, Settings, Type } from 'lucide-react' +import { Copy, FileText, MonitorSpeaker, Settings, Type } from 'lucide-react' import type { ReactNode } from 'react' export interface MenuItem { @@ -12,6 +12,7 @@ export interface MenuItem { isActive?: (settings: NotesSettings) => boolean component?: (settings: NotesSettings, updateSettings: (newSettings: Partial<NotesSettings>) => void) => ReactNode copyAction?: boolean + exportToWordAction?: boolean showSettingsPopup?: boolean } @@ -22,6 +23,12 @@ export const menuItems: MenuItem[] = [ icon: Copy, copyAction: true }, + { + key: 'export-to-word', + labelKey: 'notes.exportToWord', + icon: FileText, + exportToWordAction: true + }, { key: 'divider0', type: 'divider', diff --git a/src/renderer/src/pages/paintings/AihubmixPage.tsx b/src/renderer/src/pages/paintings/AihubmixPage.tsx index e9fea12691..5084d63492 100644 --- a/src/renderer/src/pages/paintings/AihubmixPage.tsx +++ b/src/renderer/src/pages/paintings/AihubmixPage.tsx @@ -239,7 +239,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { if (!response.ok) { const errorData = await response.json() logger.error('Gemini API Error:', errorData) - throw new Error(errorData.error?.message || '生成图像失败') + throw new Error(errorData.error?.message || t('paintings.generate_failed')) } const data = await response.json() @@ -340,7 +340,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { if (!response.ok) { const errorData = await response.json() logger.error('V3 API错误:', errorData) - throw new Error(errorData.error?.message || '生成图像失败') + throw new Error(errorData.error?.message || t('paintings.generate_failed')) } const data = await response.json() @@ -468,7 +468,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { if (!response.ok) { const errorData = await response.json() logger.error('V3 Remix API错误:', errorData) - throw new Error(errorData.error?.message || '图像混合失败') + throw new Error(errorData.error?.message || t('paintings.image_mix_failed')) } const data = await response.json() @@ -538,7 +538,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { if (!response.ok) { const errorData = await response.json() logger.error('通用API错误:', errorData) - throw new Error(errorData.error?.message || '生成图像失败') + throw new Error(errorData.error?.message || t('paintings.generate_failed')) } const data = await response.json() @@ -704,11 +704,11 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { typeof item.options === 'function' ? item.options(item, painting).map((option) => ({ ...option, - label: option.label.startsWith('paintings.') ? t(option.label) : option.label + label: option.labelKey ? t(option.labelKey) : option.label })) : item.options?.map((option) => ({ ...option, - label: option.label.startsWith('paintings.') ? t(option.label) : option.label + label: option.labelKey ? t(option.labelKey) : option.label })) return ( @@ -728,11 +728,11 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { typeof item.options === 'function' ? item.options(item, painting).map((option) => ({ ...option, - label: option.label.startsWith('paintings.') ? t(option.label) : option.label + label: option.labelKey ? t(option.labelKey) : option.label })) : item.options?.map((option) => ({ ...option, - label: option.label.startsWith('paintings.') ? t(option.label) : option.label + label: option.labelKey ? t(option.labelKey) : option.label })) return ( diff --git a/src/renderer/src/pages/paintings/DmxapiPage.tsx b/src/renderer/src/pages/paintings/DmxapiPage.tsx index e4f8323655..6c05a2737d 100644 --- a/src/renderer/src/pages/paintings/DmxapiPage.tsx +++ b/src/renderer/src/pages/paintings/DmxapiPage.tsx @@ -80,7 +80,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { const modeOptions = MODEOPTIONS.map((ele) => { return { - label: t(ele.label), + label: t(ele.labelKey), value: ele.value } }) @@ -387,7 +387,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { } if (painting.style_type) { - params.prompt = prompt + ',风格:' + painting.style_type + params.prompt = prompt + t('paintings.dmxapi.style') + painting.style_type } if (Array.isArray(fileMap.imageFiles) && fileMap.imageFiles.length > 0) { @@ -418,7 +418,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { } if (painting.style_type) { - params.prompt = prompt + ',风格:' + painting.style_type + params.prompt = prompt + t('paintings.dmxapi.style') + painting.style_type } const formData = new FormData() @@ -467,7 +467,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { throw new Error('paintings.req_error_no_balance') } - throw new Error('操作失败,请稍后重试') + throw new Error('paintings.operation_failed') } const data = await response.json() @@ -712,13 +712,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { ) { return ( <LoadTextWrap> - <div> - 正在用使用官方的模型生产, - <br /> - 预计等待2~5分钟效果最好, - <br /> - 本次消耗金额请到DMXAPI后台日志查看 - </div> + <div>{t('paintings.dmxapi.generating_tip')}</div> </LoadTextWrap> ) } @@ -822,7 +816,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { {painting.generationMode && [generationModeType.EDIT, generationModeType.MERGE].includes(painting.generationMode) && ( <> - <SettingTitle className="mt-4 mb-1">参考图</SettingTitle> + <SettingTitle className="mt-4 mb-1">{t('paintings.remix.image_file')}</SettingTitle> <ImageUploader fileMap={fileMap} maxImages={painting.generationMode === generationModeType.EDIT ? 1 : 3} @@ -942,10 +936,10 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { <RadioTextBox> {STYLE_TYPE_OPTIONS.map((ele) => ( <RadioTextItem - key={ele.label} - className={painting.style_type === ele.label ? 'selected' : ''} - onClick={() => onSelectStyleType(ele.label)}> - {ele.label} + key={ele.value} + className={painting.style_type === ele.value ? 'selected' : ''} + onClick={() => onSelectStyleType(ele.value)}> + {t(ele.labelKey)} </RadioTextItem> ))} </RadioTextBox> diff --git a/src/renderer/src/pages/paintings/NewApiPage.tsx b/src/renderer/src/pages/paintings/NewApiPage.tsx index c69584d63b..5ce5c42ca2 100644 --- a/src/renderer/src/pages/paintings/NewApiPage.tsx +++ b/src/renderer/src/pages/paintings/NewApiPage.tsx @@ -319,7 +319,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => { if (!response.ok) { const errorData = await response.json() - throw new Error(errorData.error?.message || '生成图像失败') + throw new Error(errorData.error?.message || t('paintings.generate_failed')) } const data = await response.json() diff --git a/src/renderer/src/pages/paintings/ZhipuPage.tsx b/src/renderer/src/pages/paintings/ZhipuPage.tsx index e8b25a3feb..15ff3d4ccd 100644 --- a/src/renderer/src/pages/paintings/ZhipuPage.tsx +++ b/src/renderer/src/pages/paintings/ZhipuPage.tsx @@ -119,7 +119,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { if (painting.imageSize === 'custom') { if (!customWidth || !customHeight) { window.modal.error({ - content: '请设置自定义尺寸的宽度和高度', + content: t('paintings.zhipu.custom_size_required'), centered: true }) return @@ -127,7 +127,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { // 验证自定义尺寸是否符合智谱AI的要求 if (customWidth < 512 || customWidth > 2048 || customHeight < 512 || customHeight > 2048) { window.modal.error({ - content: '自定义尺寸必须在512px-2048px之间', + content: t('paintings.zhipu.custom_size_range'), centered: true }) return @@ -135,7 +135,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { if (customWidth % 16 !== 0 || customHeight % 16 !== 0) { window.modal.error({ - content: '自定义尺寸必须能被16整除', + content: t('paintings.zhipu.custom_size_divisible'), centered: true }) return @@ -145,7 +145,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { if (totalPixels > 2097152) { // 2^21 = 2097152 window.modal.error({ - content: '自定义尺寸的总像素数不能超过2,097,152', + content: t('paintings.zhipu.custom_size_pixels'), centered: true }) return @@ -375,7 +375,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { <Radio.Group value={painting.quality} onChange={(e) => onSelectQuality(e.target.value)}> {QUALITY_OPTIONS.map((option) => ( <Radio key={option.value} value={option.value}> - {option.label} + {t(option.label)} </Radio> ))} </Radio.Group> @@ -389,7 +389,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { style={{ width: '100%' }}> {IMAGE_SIZES.map((size) => ( <Select.Option key={size.value} value={size.value}> - {size.label} + {t(size.label)} </Select.Option> ))} <Select.Option value="custom" key="custom"> @@ -423,7 +423,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { <span style={{ color: 'var(--color-text-2)', fontSize: '12px' }}>px</span> </HStack> <div style={{ marginTop: 5, fontSize: '12px', color: 'var(--color-text-3)' }}> - 长宽均需满足512px-2048px之间, 需被16整除, 并保证最大像素数不超过2^21px + {t('paintings.zhipu.custom_size_hint')} </div> </div> )} diff --git a/src/renderer/src/pages/paintings/components/DynamicFormRender.tsx b/src/renderer/src/pages/paintings/components/DynamicFormRender.tsx index 7495ad1a59..d86e751009 100644 --- a/src/renderer/src/pages/paintings/components/DynamicFormRender.tsx +++ b/src/renderer/src/pages/paintings/components/DynamicFormRender.tsx @@ -4,6 +4,7 @@ import { convertToBase64 } from '@renderer/utils' import { Button, Input, InputNumber, Select, Switch, Upload } from 'antd' import TextArea from 'antd/es/input/TextArea' import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' interface DynamicFormRenderProps { schemaProperty: any @@ -20,6 +21,7 @@ export const DynamicFormRender: React.FC<DynamicFormRenderProps> = ({ value, onChange }) => { + const { t } = useTranslation() const { type, enum: enumValues, description, default: defaultValue, format } = schemaProperty const handleImageUpload = useCallback( @@ -64,7 +66,7 @@ export const DynamicFormRender: React.FC<DynamicFormRenderProps> = ({ }} value={value || defaultValue || ''} onChange={(e) => onChange(propertyName, e.target.value)} - placeholder="Enter image URL or upload file" + placeholder={t('common.image_url_or_upload')} prefix={<LinkOutlined style={{ color: '#999' }} />} /> <Upload @@ -76,7 +78,7 @@ export const DynamicFormRender: React.FC<DynamicFormRenderProps> = ({ }}> <Button icon={<UploadOutlined />} - title="Upload image file" + title={t('common.upload_image')} style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, @@ -119,14 +121,14 @@ export const DynamicFormRender: React.FC<DynamicFormRenderProps> = ({ overflow: 'hidden', textOverflow: 'ellipsis' }}> - {value.startsWith('data:') ? 'Uploaded image' : 'Image URL'} + {value.startsWith('data:') ? t('common.uploaded_image') : t('common.image_url')} </div> <Button size="small" danger icon={<CloseOutlined />} onClick={() => onChange(propertyName, '')} - title="Remove image" + title={t('common.remove_image')} style={{ flexShrink: 0, minWidth: 'auto', padding: '0 8px' }} /> </div> @@ -182,7 +184,7 @@ export const DynamicFormRender: React.FC<DynamicFormRenderProps> = ({ size="small" icon={<RedoOutlined />} onClick={() => onChange(propertyName, generateRandomSeed())} - title="Generate random seed" + title={t('common.generate_random_seed')} /> </div> ) diff --git a/src/renderer/src/pages/paintings/components/ImageUploader.tsx b/src/renderer/src/pages/paintings/components/ImageUploader.tsx index b1559c4ff3..5cb93a7897 100644 --- a/src/renderer/src/pages/paintings/components/ImageUploader.tsx +++ b/src/renderer/src/pages/paintings/components/ImageUploader.tsx @@ -6,6 +6,7 @@ import { Popconfirm, Upload } from 'antd' import { Button } from 'antd' import type { RcFile, UploadProps } from 'antd/es/upload' import React from 'react' +import { useTranslation } from 'react-i18next' import styled from 'styled-components' interface ImageUploaderProps { @@ -28,6 +29,7 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({ onAddImage }) => { const { theme } = useTheme() + const { t } = useTranslation() const handleBeforeUpload = (file: RcFile, index?: number) => { onAddImage(file, index) @@ -46,7 +48,7 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({ <HeaderContainer> {fileMap.imageFiles && fileMap.imageFiles.length > 0 && ( <Button size="small" onClick={onClearImages}> - 清除全部 + {t('common.clear_all')} </Button> )} </HeaderContainer> @@ -68,13 +70,13 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({ handleBeforeUpload(file, index) }}> <ImagePreview> - <img src={src} alt={`预览图${index + 1}`} /> + <img src={src} alt={`${t('common.image_preview')} ${index + 1}`} /> </ImagePreview> </ImageUploadButton> <Popconfirm - title="确定要删除这张图片吗?" - okText="确定" - cancelText="取消" + title={t('paintings.button.delete.image.confirm')} + okText={t('common.confirm')} + cancelText={t('common.cancel')} onConfirm={() => onDeleteImage(index)}> <DeleteButton> <DeleteOutlined /> diff --git a/src/renderer/src/pages/paintings/config/DmxapiConfig.ts b/src/renderer/src/pages/paintings/config/DmxapiConfig.ts index 52af9490c8..ddccebc9a2 100644 --- a/src/renderer/src/pages/paintings/config/DmxapiConfig.ts +++ b/src/renderer/src/pages/paintings/config/DmxapiConfig.ts @@ -27,33 +27,33 @@ export type DMXApiModelGroups = { } export const STYLE_TYPE_OPTIONS = [ - { label: '吉卜力', value: '吉卜力' }, - { label: '皮克斯', value: '皮克斯' }, - { label: '绒线玩偶', value: '绒线玩偶' }, - { label: '水彩画', value: '水彩画' }, - { label: '卡通插画', value: '卡通插画' }, - { label: '3D卡通', value: '3D卡通' }, - { label: '日系动漫', value: '日系动漫' }, - { label: '木雕', value: '木雕' }, - { label: '唯美古风', value: '唯美古风' }, - { label: '2.5D动画', value: '2.5D动画' }, - { label: '清新日漫', value: '清新日漫' }, - { label: '黏土', value: '黏土' }, - { label: '小人书插画', value: '小人书插画' }, - { label: '浮世绘', value: '浮世绘' }, - { label: '毛毡', value: '毛毡' }, - { label: '美式复古', value: '美式复古' }, - { label: '赛博朋克', value: '赛博朋克' }, - { label: '素描', value: '素描' }, - { label: '莫奈花园', value: '莫奈花园' }, - { label: '厚涂手绘', value: '厚涂手绘' }, - { label: '扁平', value: '扁平' }, - { label: '肌理', value: '肌理' }, - { label: '像素艺术', value: '像素艺术' }, - { label: '街头艺术', value: '街头艺术' }, - { label: '迷幻', value: '迷幻' }, - { label: '国风工笔', value: '国风工笔' }, - { label: '巴洛克', value: '巴洛克' } + { labelKey: 'paintings.dmxapi.style_types.ghibli', value: '吉卜力' }, + { labelKey: 'paintings.dmxapi.style_types.pixar', value: '皮克斯' }, + { labelKey: 'paintings.dmxapi.style_types.yarn_doll', value: '绒线玩偶' }, + { labelKey: 'paintings.dmxapi.style_types.watercolor', value: '水彩画' }, + { labelKey: 'paintings.dmxapi.style_types.cartoon_illustration', value: '卡通插画' }, + { labelKey: 'paintings.dmxapi.style_types.3d_cartoon', value: '3D卡通' }, + { labelKey: 'paintings.dmxapi.style_types.japanese_anime', value: '日系动漫' }, + { labelKey: 'paintings.dmxapi.style_types.wood_carving', value: '木雕' }, + { labelKey: 'paintings.dmxapi.style_types.poetic_ancient', value: '唯美古风' }, + { labelKey: 'paintings.dmxapi.style_types.25d_animation', value: '2.5D动画' }, + { labelKey: 'paintings.dmxapi.style_types.fresh_anime', value: '清新日漫' }, + { labelKey: 'paintings.dmxapi.style_types.clay', value: '黏土' }, + { labelKey: 'paintings.dmxapi.style_types.little_people_book', value: '小人书插画' }, + { labelKey: 'paintings.dmxapi.style_types.ukiyo_e', value: '浮世绘' }, + { labelKey: 'paintings.dmxapi.style_types.felt', value: '毛毡' }, + { labelKey: 'paintings.dmxapi.style_types.american_retro', value: '美式复古' }, + { labelKey: 'paintings.dmxapi.style_types.cyberpunk', value: '赛博朋克' }, + { labelKey: 'paintings.dmxapi.style_types.sketch', value: '素描' }, + { labelKey: 'paintings.dmxapi.style_types.monet_garden', value: '莫奈花园' }, + { labelKey: 'paintings.dmxapi.style_types.oil_painting', value: '厚涂手绘' }, + { labelKey: 'paintings.dmxapi.style_types.flat', value: '扁平' }, + { labelKey: 'paintings.dmxapi.style_types.texture', value: '肌理' }, + { labelKey: 'paintings.dmxapi.style_types.pixel_art', value: '像素艺术' }, + { labelKey: 'paintings.dmxapi.style_types.street_art', value: '街头艺术' }, + { labelKey: 'paintings.dmxapi.style_types.psychedelic', value: '迷幻' }, + { labelKey: 'paintings.dmxapi.style_types.chinese_gongbi', value: '国风工笔' }, + { labelKey: 'paintings.dmxapi.style_types.baroque', value: '巴洛克' } ] export const COURSE_URL = 'http://seedream.dmxapi.cn/' @@ -76,9 +76,9 @@ export const DEFAULT_PAINTING: DmxapiPainting = { } export const MODEOPTIONS = [ - { label: 'paintings.mode.generate', value: generationModeType.GENERATION }, - { label: 'paintings.mode.edit', value: generationModeType.EDIT }, - { label: 'paintings.mode.merge', value: generationModeType.MERGE } + { labelKey: 'paintings.mode.generate', value: generationModeType.GENERATION }, + { labelKey: 'paintings.mode.edit', value: generationModeType.EDIT }, + { labelKey: 'paintings.mode.merge', value: generationModeType.MERGE } ] // 获取模型分组数据 diff --git a/src/renderer/src/pages/paintings/config/ZhipuConfig.ts b/src/renderer/src/pages/paintings/config/ZhipuConfig.ts index db119faa9f..0fc0a0f83b 100644 --- a/src/renderer/src/pages/paintings/config/ZhipuConfig.ts +++ b/src/renderer/src/pages/paintings/config/ZhipuConfig.ts @@ -33,16 +33,16 @@ export const DEFAULT_PAINTING = { } export const QUALITY_OPTIONS = [ - { label: '标准(默认)', value: 'standard' }, - { label: '高清', value: 'hd' } + { label: 'paintings.zhipu.quality_options.standard_default', value: 'standard' }, + { label: 'paintings.zhipu.quality_options.hd', value: 'hd' } ] export const IMAGE_SIZES = [ - { label: '1024x1024 (默认)', value: '1024x1024' }, - { label: '768x1344', value: '768x1344' }, - { label: '864x1152', value: '864x1152' }, - { label: '1344x768', value: '1344x768' }, - { label: '1152x864', value: '1152x864' }, - { label: '1440x720', value: '1440x720' }, - { label: '720x1440', value: '720x1440' } + { label: 'paintings.zhipu.image_sizes.1024x1024_default', value: '1024x1024' }, + { label: 'paintings.zhipu.image_sizes.768x1344', value: '768x1344' }, + { label: 'paintings.zhipu.image_sizes.864x1152', value: '864x1152' }, + { label: 'paintings.zhipu.image_sizes.1344x768', value: '1344x768' }, + { label: 'paintings.zhipu.image_sizes.1152x864', value: '1152x864' }, + { label: 'paintings.zhipu.image_sizes.1440x720', value: '1440x720' }, + { label: 'paintings.zhipu.image_sizes.720x1440', value: '720x1440' } ] diff --git a/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx b/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx index a203f93683..6f51f61fd9 100644 --- a/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx +++ b/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx @@ -29,17 +29,20 @@ export type ConfigItem = { tooltip?: string options?: | Array<{ - label: string + /** i18n key for label (use t() to translate), mutually exclusive with label */ + labelKey?: string + /** Direct display label (no translation needed), mutually exclusive with labelKey */ + label?: string title?: string value?: string | number icon?: string onlyV2?: boolean - options?: Array<{ label: string; value: string | number; icon?: string; onlyV2?: boolean }> + options?: Array<{ labelKey?: string; label?: string; value: string | number; icon?: string; onlyV2?: boolean }> }> | (( config: ConfigItem, painting: Partial<PaintingAction> - ) => Array<{ label: string; value: string | number; icon?: string; onlyV2?: boolean }>) + ) => Array<{ labelKey?: string; label?: string; value: string | number; icon?: string; onlyV2?: boolean }>) min?: number max?: number step?: number @@ -160,7 +163,7 @@ export const createModeConfigs = (): Record<AihubmixMode, ConfigItem[]> => { key: 'size', title: 'paintings.aspect_ratio', options: [ - { label: '自动', value: 'auto' }, + { labelKey: 'paintings.image_size_options.auto', value: 'auto' }, { label: '1:1', value: '1024x1024' }, { label: '3:2', value: '1536x1024' }, { label: '2:3', value: '1024x1536' } diff --git a/src/renderer/src/pages/paintings/config/constants.ts b/src/renderer/src/pages/paintings/config/constants.ts index 377f06533f..f9708e2ef6 100644 --- a/src/renderer/src/pages/paintings/config/constants.ts +++ b/src/renderer/src/pages/paintings/config/constants.ts @@ -1,6 +1,6 @@ export const ASPECT_RATIOS = [ { - label: 'paintings.aspect_ratios.square', + labelKey: 'paintings.aspect_ratios.square', options: [ { label: '1:1', @@ -9,7 +9,7 @@ export const ASPECT_RATIOS = [ ] }, { - label: 'paintings.aspect_ratios.landscape', + labelKey: 'paintings.aspect_ratios.landscape', options: [ { label: '1:2', @@ -42,7 +42,7 @@ export const ASPECT_RATIOS = [ ] }, { - label: 'paintings.aspect_ratios.landscape', + labelKey: 'paintings.aspect_ratios.landscape', options: [ { label: '2:1', @@ -78,28 +78,28 @@ export const ASPECT_RATIOS = [ export const STYLE_TYPES = [ { - label: 'paintings.style_types.auto', + labelKey: 'paintings.style_types.auto', value: 'AUTO' }, { - label: 'paintings.style_types.general', + labelKey: 'paintings.style_types.general', value: 'GENERAL' }, { - label: 'paintings.style_types.realistic', + labelKey: 'paintings.style_types.realistic', value: 'REALISTIC' }, { - label: 'paintings.style_types.design', + labelKey: 'paintings.style_types.design', value: 'DESIGN' }, { - label: 'paintings.style_types.3d', + labelKey: 'paintings.style_types.3d', value: 'RENDER_3D', onlyV2: true // 仅V2模型支持 }, { - label: 'paintings.style_types.anime', + labelKey: 'paintings.style_types.anime', value: 'ANIME', onlyV2: true // 仅V2模型支持 } @@ -111,39 +111,39 @@ export const V3_STYLE_TYPES = STYLE_TYPES.filter((style) => !style.onlyV2) // 新增V3渲染速度选项 export const RENDERING_SPEED_OPTIONS = [ { - label: 'paintings.rendering_speeds.default', + labelKey: 'paintings.rendering_speeds.default', value: 'DEFAULT' }, { - label: 'paintings.rendering_speeds.turbo', + labelKey: 'paintings.rendering_speeds.turbo', value: 'TURBO' }, { - label: 'paintings.rendering_speeds.quality', + labelKey: 'paintings.rendering_speeds.quality', value: 'QUALITY' } ] export const QUALITY_OPTIONS = [ - { label: 'paintings.quality_options.auto', value: 'auto' }, - { label: 'paintings.quality_options.low', value: 'low' }, - { label: 'paintings.quality_options.medium', value: 'medium' }, - { label: 'paintings.quality_options.high', value: 'high' } + { labelKey: 'paintings.quality_options.auto', value: 'auto' }, + { labelKey: 'paintings.quality_options.low', value: 'low' }, + { labelKey: 'paintings.quality_options.medium', value: 'medium' }, + { labelKey: 'paintings.quality_options.high', value: 'high' } ] export const MODERATION_OPTIONS = [ - { label: 'paintings.moderation_options.auto', value: 'auto' }, - { label: 'paintings.moderation_options.low', value: 'low' } + { labelKey: 'paintings.moderation_options.auto', value: 'auto' }, + { labelKey: 'paintings.moderation_options.low', value: 'low' } ] export const BACKGROUND_OPTIONS = [ - { label: 'paintings.background_options.auto', value: 'auto' }, - { label: 'paintings.background_options.transparent', value: 'transparent' }, - { label: 'paintings.background_options.opaque', value: 'opaque' } + { labelKey: 'paintings.background_options.auto', value: 'auto' }, + { labelKey: 'paintings.background_options.transparent', value: 'transparent' }, + { labelKey: 'paintings.background_options.opaque', value: 'opaque' } ] export const PERSON_GENERATION_OPTIONS = [ - { label: 'paintings.person_generation_options.allow_all', value: 'ALLOW_ALL' }, - { label: 'paintings.person_generation_options.allow_adult', value: 'ALLOW_ADULT' }, - { label: 'paintings.person_generation_options.allow_none', value: 'DONT_ALLOW' } + { labelKey: 'paintings.person_generation_options.allow_all', value: 'ALLOW_ALL' }, + { labelKey: 'paintings.person_generation_options.allow_adult', value: 'ALLOW_ADULT' }, + { labelKey: 'paintings.person_generation_options.allow_none', value: 'DONT_ALLOW' } ] diff --git a/src/renderer/src/pages/paintings/utils/TokenFluxService.ts b/src/renderer/src/pages/paintings/utils/TokenFluxService.ts index 4b1e224a8a..ddd362045b 100644 --- a/src/renderer/src/pages/paintings/utils/TokenFluxService.ts +++ b/src/renderer/src/pages/paintings/utils/TokenFluxService.ts @@ -6,6 +6,9 @@ import type { TokenFluxModel } from '../config/tokenFluxConfig' const logger = loggerService.withContext('TokenFluxService') +// 图片 API 使用固定的基础地址,独立于 provider.apiHost(后者是 OpenAI 兼容的聊天 API 地址) +const TOKENFLUX_IMAGE_API_HOST = 'https://api.tokenflux.ai' + export interface TokenFluxGenerationRequest { model: string input: { @@ -66,7 +69,7 @@ export class TokenFluxService { return cachedModels } - const response = await fetch(`${this.apiHost}/v1/images/models`, { + const response = await fetch(`${TOKENFLUX_IMAGE_API_HOST}/v1/images/models`, { headers: { Authorization: `Bearer ${this.apiKey}` } @@ -88,7 +91,7 @@ export class TokenFluxService { * Create a new image generation request */ async createGeneration(request: TokenFluxGenerationRequest, signal?: AbortSignal): Promise<string> { - const response = await fetch(`${this.apiHost}/v1/images/generations`, { + const response = await fetch(`${TOKENFLUX_IMAGE_API_HOST}/v1/images/generations`, { method: 'POST', headers: this.getHeaders(), body: JSON.stringify(request), @@ -108,7 +111,7 @@ export class TokenFluxService { * Get the status and result of a generation */ async getGenerationResult(generationId: string): Promise<TokenFluxGenerationResponse['data']> { - const response = await fetch(`${this.apiHost}/v1/images/generations/${generationId}`, { + const response = await fetch(`${TOKENFLUX_IMAGE_API_HOST}/v1/images/generations/${generationId}`, { headers: { Authorization: `Bearer ${this.apiKey}` } diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index 1765f1fbda..105ad5b0c8 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -15,7 +15,7 @@ import { runAsyncFunction } from '@renderer/utils' import { UpgradeChannel } from '@shared/config/constant' import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd' import { debounce } from 'lodash' -import { Bug, Building2, Github, Globe, Mail, Rss } from 'lucide-react' +import { Briefcase, Bug, Building2, Github, Globe, Mail, Rss } from 'lucide-react' import { BadgeQuestionMark } from 'lucide-react' import type { FC } from 'react' import { useEffect, useState } from 'react' @@ -327,6 +327,16 @@ const AboutSettings: FC = () => { <Button onClick={mailto}>{t('settings.about.contact.button')}</Button> </SettingRow> <SettingDivider /> + <SettingRow> + <SettingRowTitle> + <Briefcase size={18} /> + {t('settings.about.careers.title')} + </SettingRowTitle> + <Button onClick={() => onOpenWebsite('https://www.cherry-ai.com/careers')}> + {t('settings.about.careers.button')} + </Button> + </SettingRow> + <SettingDivider /> <SettingRow> <SettingRowTitle> <Bug size={18} /> diff --git a/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx b/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx index f1507f88f8..01499d8500 100644 --- a/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/AgentSettingsPopup.tsx @@ -44,32 +44,30 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag resolve() } - const items = ( - [ - { - key: 'essential', - label: t('agent.settings.essential') - }, - { - key: 'prompt', - label: t('agent.settings.prompt') - }, - { - key: 'tooling', - label: t('agent.settings.tooling.tab', 'Tooling & permissions') - }, - { - key: 'plugins', - label: t('agent.settings.plugins.tab', 'Plugins') - }, - { - key: 'advanced', - label: t('agent.settings.advance.title', 'Advanced Settings') - } - ] as const satisfies { key: AgentSettingPopupTab; label: string }[] - ).filter(Boolean) + const items = [ + { + key: 'essential', + label: t('agent.settings.essential') + }, + { + key: 'prompt', + label: t('agent.settings.prompt') + }, + { + key: 'tooling', + label: t('agent.settings.tooling.tab', 'Tooling & permissions') + }, + { + key: 'plugins', + label: t('agent.settings.plugins.tab', 'Plugins') + }, + { + key: 'advanced', + label: t('agent.settings.advance.title', 'Advanced Settings') + } + ] as const satisfies { key: AgentSettingPopupTab; label: string }[] - const ModalContent = () => { + const renderModalContent = () => { if (isLoading) { // TODO: use skeleton for better ux return ( @@ -146,7 +144,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag }} width="min(800px, 70vw)" centered> - <ModalContent /> + {renderModalContent()} </StyledModal> ) } diff --git a/src/renderer/src/pages/settings/AgentSettings/SessionSettingsPopup.tsx b/src/renderer/src/pages/settings/AgentSettings/SessionSettingsPopup.tsx index c6c1ac451d..6353ac8eff 100644 --- a/src/renderer/src/pages/settings/AgentSettings/SessionSettingsPopup.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/SessionSettingsPopup.tsx @@ -45,28 +45,26 @@ const SessionSettingPopupContainer: React.FC<SessionSettingPopupParams> = ({ tab resolve() } - const items = ( - [ - { - key: 'essential', - label: t('agent.settings.essential') - }, - { - key: 'prompt', - label: t('agent.settings.prompt') - }, - { - key: 'tooling', - label: t('agent.settings.tooling.tab', 'Tooling & permissions') - }, - { - key: 'advanced', - label: t('agent.settings.advance.title', 'Advanced Settings') - } - ] as const satisfies { key: AgentSettingPopupTab; label: string }[] - ).filter(Boolean) + const items = [ + { + key: 'essential', + label: t('agent.settings.essential') + }, + { + key: 'prompt', + label: t('agent.settings.prompt') + }, + { + key: 'tooling', + label: t('agent.settings.tooling.tab', 'Tooling & permissions') + }, + { + key: 'advanced', + label: t('agent.settings.advance.title', 'Advanced Settings') + } + ] as const satisfies { key: AgentSettingPopupTab; label: string }[] - const ModalContent = () => { + const renderModalContent = () => { if (isLoading) { // TODO: use skeleton for better ux return ( @@ -132,7 +130,7 @@ const SessionSettingPopupContainer: React.FC<SessionSettingPopupParams> = ({ tab }} width="min(800px, 70vw)" centered> - <ModalContent /> + {renderModalContent()} </StyledModal> ) } diff --git a/src/renderer/src/pages/settings/AgentSettings/components/PluginDetailModal.tsx b/src/renderer/src/pages/settings/AgentSettings/components/PluginDetailModal.tsx index d306a6740a..9a783f3148 100644 --- a/src/renderer/src/pages/settings/AgentSettings/components/PluginDetailModal.tsx +++ b/src/renderer/src/pages/settings/AgentSettings/components/PluginDetailModal.tsx @@ -97,7 +97,7 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({ if (result.success) { setContent(editedContent) setIsEditing(false) - window.toast?.success('Plugin content saved successfully') + window.toast?.success(t('plugins.content_saved')) } else { window.toast?.error(`Failed to save: ${result.error.type}`) } @@ -177,7 +177,7 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({ {/* Description */} {plugin.description && ( <div className="mb-4"> - <h3 className="mb-2 font-semibold text-small">Description</h3> + <h3 className="mb-2 font-semibold text-small">{t('plugins.detail.description')}</h3> <p className="text-default-600 text-small">{plugin.description}</p> </div> )} @@ -185,7 +185,7 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({ {/* Author */} {plugin.author && ( <div className="mb-4"> - <h3 className="mb-2 font-semibold text-small">Author</h3> + <h3 className="mb-2 font-semibold text-small">{t('plugins.detail.author')}</h3> <p className="text-default-600 text-small">{plugin.author}</p> </div> )} @@ -193,7 +193,7 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({ {/* Tools (for agents) */} {plugin.tools && plugin.tools.length > 0 && ( <div className="mb-4"> - <h3 className="mb-2 font-semibold text-small">Tools</h3> + <h3 className="mb-2 font-semibold text-small">{t('plugins.detail.tools')}</h3> <div className="flex flex-wrap gap-1"> {plugin.tools.map((tool) => ( <Tag key={tool}>{tool}</Tag> @@ -205,7 +205,7 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({ {/* Allowed Tools (for commands) */} {plugin.allowed_tools && plugin.allowed_tools.length > 0 && ( <div className="mb-4"> - <h3 className="mb-2 font-semibold text-small">Allowed Tools</h3> + <h3 className="mb-2 font-semibold text-small">{t('plugins.detail.allowed_tools')}</h3> <div className="flex flex-wrap gap-1"> {plugin.allowed_tools.map((tool) => ( <Tag key={tool}>{tool}</Tag> @@ -217,7 +217,7 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({ {/* Tags */} {plugin.tags && plugin.tags.length > 0 && ( <div className="mb-4"> - <h3 className="mb-2 font-semibold text-small">Tags</h3> + <h3 className="mb-2 font-semibold text-small">{t('plugins.detail.tags')}</h3> <div className="flex flex-wrap gap-1"> {plugin.tags.map((tag) => ( <Tag key={tag}>{tag}</Tag> @@ -228,23 +228,23 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({ {/* Metadata */} <div className="mb-4"> - <h3 className="mb-2 font-semibold text-small">Metadata</h3> + <h3 className="mb-2 font-semibold text-small">{t('plugins.detail.metadata')}</h3> <div className="space-y-1 text-small"> <div className="flex justify-between"> - <span className="text-default-500">File:</span> + <span className="text-default-500">{t('plugins.detail.file')}:</span> <span className="font-mono text-default-600 text-tiny">{plugin.filename}</span> </div> <div className="flex justify-between"> - <span className="text-default-500">Size:</span> + <span className="text-default-500">{t('plugins.detail.size')}:</span> <span className="text-default-600">{(plugin.size / 1024).toFixed(2)} KB</span> </div> <div className="flex justify-between"> - <span className="text-default-500">Source:</span> + <span className="text-default-500">{t('plugins.detail.source')}:</span> <span className="font-mono text-default-600 text-tiny">{plugin.sourcePath}</span> </div> {plugin.installedAt && ( <div className="flex justify-between"> - <span className="text-default-500">Installed:</span> + <span className="text-default-500">{t('plugins.detail.installed')}:</span> <span className="text-default-600">{new Date(plugin.installedAt).toLocaleString()}</span> </div> )} @@ -254,7 +254,7 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({ {/* Content */} <div className="mb-4"> <div className="mb-2 flex items-center justify-between"> - <h3 className="font-semibold text-small">Content</h3> + <h3 className="font-semibold text-small">{t('plugins.detail.content')}</h3> {installed && !contentLoading && !contentError && ( <div className="flex gap-2"> {isEditing ? ( diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx index ac89141092..1c243130dc 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx @@ -1,8 +1,9 @@ import { InfoCircleOutlined } from '@ant-design/icons' import { Box } from '@renderer/components/Layout' import { useMCPServers } from '@renderer/hooks/useMCPServers' -import type { Assistant, AssistantSettings } from '@renderer/types' -import { Empty, Switch, Tooltip } from 'antd' +import type { Assistant, AssistantSettings, McpMode } from '@renderer/types' +import { getEffectiveMcpMode } from '@renderer/types' +import { Empty, Radio, Switch, Tooltip } from 'antd' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -27,22 +28,26 @@ const AssistantMCPSettings: React.FC<Props> = ({ assistant, updateAssistant }) = const { t } = useTranslation() const { mcpServers: allMcpServers } = useMCPServers() + const currentMode = getEffectiveMcpMode(assistant) + + const handleModeChange = (mode: McpMode) => { + updateAssistant({ ...assistant, mcpMode: mode }) + } + const onUpdate = (ids: string[]) => { const mcpServers = ids .map((id) => allMcpServers.find((server) => server.id === id)) .filter((server): server is MCPServer => server !== undefined && server.isActive) - updateAssistant({ ...assistant, mcpServers }) + updateAssistant({ ...assistant, mcpServers, mcpMode: 'manual' }) } const handleServerToggle = (serverId: string) => { const currentServerIds = assistant.mcpServers?.map((server) => server.id) || [] if (currentServerIds.includes(serverId)) { - // Remove server if it's already enabled onUpdate(currentServerIds.filter((id) => id !== serverId)) } else { - // Add server if it's not enabled onUpdate([...currentServerIds, serverId]) } } @@ -58,49 +63,77 @@ const AssistantMCPSettings: React.FC<Props> = ({ assistant, updateAssistant }) = <InfoIcon /> </Tooltip> </Box> - {allMcpServers.length > 0 && ( - <EnabledCount> - {enabledCount} / {allMcpServers.length} {t('settings.mcp.active')} - </EnabledCount> - )} </HeaderContainer> - {allMcpServers.length > 0 ? ( - <ServerList> - {allMcpServers.map((server) => { - const isEnabled = assistant.mcpServers?.some((s) => s.id === server.id) || false + <ModeSelector> + <Radio.Group value={currentMode} onChange={(e) => handleModeChange(e.target.value)}> + <Radio.Button value="disabled"> + <ModeOption> + <ModeLabel>{t('assistants.settings.mcp.mode.disabled.label')}</ModeLabel> + <ModeDescription>{t('assistants.settings.mcp.mode.disabled.description')}</ModeDescription> + </ModeOption> + </Radio.Button> + <Radio.Button value="auto"> + <ModeOption> + <ModeLabel>{t('assistants.settings.mcp.mode.auto.label')}</ModeLabel> + <ModeDescription>{t('assistants.settings.mcp.mode.auto.description')}</ModeDescription> + </ModeOption> + </Radio.Button> + <Radio.Button value="manual"> + <ModeOption> + <ModeLabel>{t('assistants.settings.mcp.mode.manual.label')}</ModeLabel> + <ModeDescription>{t('assistants.settings.mcp.mode.manual.description')}</ModeDescription> + </ModeOption> + </Radio.Button> + </Radio.Group> + </ModeSelector> - return ( - <ServerItem key={server.id} isEnabled={isEnabled}> - <ServerInfo> - <ServerName>{server.name}</ServerName> - {server.description && <ServerDescription>{server.description}</ServerDescription>} - {server.baseUrl && <ServerUrl>{server.baseUrl}</ServerUrl>} - </ServerInfo> - <Tooltip - title={ - !server.isActive - ? t('assistants.settings.mcp.enableFirst', 'Enable this server in MCP settings first') - : undefined - }> - <Switch - checked={isEnabled} - disabled={!server.isActive} - onChange={() => handleServerToggle(server.id)} - size="small" - /> - </Tooltip> - </ServerItem> - ) - })} - </ServerList> - ) : ( - <EmptyContainer> - <Empty - description={t('assistants.settings.mcp.noServersAvailable', 'No MCP servers available')} - image={Empty.PRESENTED_IMAGE_SIMPLE} - /> - </EmptyContainer> + {currentMode === 'manual' && ( + <> + {allMcpServers.length > 0 && ( + <EnabledCount> + {enabledCount} / {allMcpServers.length} {t('settings.mcp.active')} + </EnabledCount> + )} + + {allMcpServers.length > 0 ? ( + <ServerList> + {allMcpServers.map((server) => { + const isEnabled = assistant.mcpServers?.some((s) => s.id === server.id) || false + + return ( + <ServerItem key={server.id} isEnabled={isEnabled}> + <ServerInfo> + <ServerName>{server.name}</ServerName> + {server.description && <ServerDescription>{server.description}</ServerDescription>} + {server.baseUrl && <ServerUrl>{server.baseUrl}</ServerUrl>} + </ServerInfo> + <Tooltip + title={ + !server.isActive + ? t('assistants.settings.mcp.enableFirst', 'Enable this server in MCP settings first') + : undefined + }> + <Switch + checked={isEnabled} + disabled={!server.isActive} + onChange={() => handleServerToggle(server.id)} + size="small" + /> + </Tooltip> + </ServerItem> + ) + })} + </ServerList> + ) : ( + <EmptyContainer> + <Empty + description={t('assistants.settings.mcp.noServersAvailable', 'No MCP servers available')} + image={Empty.PRESENTED_IMAGE_SIMPLE} + /> + </EmptyContainer> + )} + </> )} </Container> ) @@ -110,7 +143,7 @@ const Container = styled.div` display: flex; flex: 1; flex-direction: column; - overflow: hidden; + min-height: 0; ` const HeaderContainer = styled.div` @@ -127,9 +160,54 @@ const InfoIcon = styled(InfoCircleOutlined)` cursor: help; ` +const ModeSelector = styled.div` + margin-bottom: 16px; + + .ant-radio-group { + display: flex; + flex-direction: column; + gap: 8px; + } + + .ant-radio-button-wrapper { + height: auto; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid var(--color-border); + + &:not(:first-child)::before { + display: none; + } + + &:first-child { + border-radius: 8px; + } + + &:last-child { + border-radius: 8px; + } + } +` + +const ModeOption = styled.div` + display: flex; + flex-direction: column; + gap: 2px; +` + +const ModeLabel = styled.span` + font-weight: 600; +` + +const ModeDescription = styled.span` + font-size: 12px; + color: var(--color-text-2); +` + const EnabledCount = styled.span` font-size: 12px; color: var(--color-text-2); + margin-bottom: 8px; ` const EmptyContainer = styled.div` diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index b45cecc586..30629ce6f3 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -365,16 +365,24 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA </Row> <Row align="middle" gutter={24}> <Col span={24}> - <Slider - min={0} - max={MAX_CONTEXT_COUNT} - onChange={setContextCount} - onChangeComplete={onContextCountChange} - value={typeof contextCount === 'number' ? contextCount : 0} - marks={{ 0: '0', 25: '25', 50: '50', 75: '75', 100: t('chat.settings.max') }} - step={1} - tooltip={{ formatter: formatSliderTooltip, open: false }} - /> + <ContextSliderWrapper> + <Slider + min={0} + max={MAX_CONTEXT_COUNT} + onChange={setContextCount} + onChangeComplete={onContextCountChange} + value={typeof contextCount === 'number' ? contextCount : 0} + marks={{ + 0: '0', + 25: '25', + 50: '50', + 75: '75', + 100: <span style={{ position: 'absolute', right: -2 }}>{t('chat.settings.max')}</span> + }} + step={1} + tooltip={{ formatter: formatSliderTooltip, open: false }} + /> + </ContextSliderWrapper> </Col> </Row> <Divider style={{ margin: '10px 0' }} /> @@ -539,4 +547,8 @@ const ModelName = styled.span` display: inline-block; ` +const ContextSliderWrapper = styled.div` + padding-bottom: 5px; +` + export default AssistantModelSettings diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 444fba569f..65ccd93442 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -2,10 +2,11 @@ import CodeEditor from '@renderer/components/CodeEditor' import { ResetIcon } from '@renderer/components/Icons' import { HStack } from '@renderer/components/Layout' import TextBadge from '@renderer/components/TextBadge' -import { isMac, THEME_COLOR_PRESETS } from '@renderer/config/constant' +import { isLinux, isMac, THEME_COLOR_PRESETS } from '@renderer/config/constant' import { DEFAULT_SIDEBAR_ICONS } from '@renderer/config/sidebar' import { useTheme } from '@renderer/context/ThemeProvider' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import useUserTheme from '@renderer/hooks/useUserTheme' import { useAppDispatch } from '@renderer/store' import type { AssistantIconType } from '@renderer/store/settings' @@ -68,12 +69,15 @@ const DisplaySettings: FC = () => { sidebarIcons, setTheme, assistantIconType, - userTheme + userTheme, + useSystemTitleBar, + setUseSystemTitleBar } = useSettings() const { navbarPosition, setNavbarPosition } = useNavbarPosition() const { theme, settedTheme } = useTheme() const { t } = useTranslation() const dispatch = useAppDispatch() + const { setTimeoutTimer } = useTimer() const [currentZoom, setCurrentZoom] = useState(1.0) const { setUserTheme } = useUserTheme() @@ -88,6 +92,26 @@ const DisplaySettings: FC = () => { [setWindowStyle] ) + const handleUseSystemTitleBarChange = (checked: boolean) => { + window.modal.confirm({ + title: t('settings.use_system_title_bar.confirm.title'), + content: t('settings.use_system_title_bar.confirm.content'), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + centered: true, + onOk() { + setUseSystemTitleBar(checked) + setTimeoutTimer( + 'handleUseSystemTitleBarChange', + () => { + window.api.relaunchApp() + }, + 500 + ) + } + }) + } + const handleColorPrimaryChange = useCallback( (colorHex: string) => { setUserTheme({ @@ -260,6 +284,15 @@ const DisplaySettings: FC = () => { </SettingRow> </> )} + {isLinux && ( + <> + <SettingDivider /> + <SettingRow> + <SettingRowTitle>{t('settings.use_system_title_bar.title')}</SettingRowTitle> + <Switch checked={useSystemTitleBar} onChange={handleUseSystemTitleBarChange} /> + </SettingRow> + </> + )} </SettingGroup> <SettingGroup theme={theme}> <SettingTitle style={{ justifyContent: 'flex-start', gap: 5 }}> diff --git a/src/renderer/src/pages/settings/DocProcessSettings/OcrPpocrSettings.tsx b/src/renderer/src/pages/settings/DocProcessSettings/OcrPpocrSettings.tsx index 634e63b2d3..0dbf1f3d1e 100644 --- a/src/renderer/src/pages/settings/DocProcessSettings/OcrPpocrSettings.tsx +++ b/src/renderer/src/pages/settings/DocProcessSettings/OcrPpocrSettings.tsx @@ -5,13 +5,11 @@ import { Input } from 'antd' import { startTransition, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingRow, SettingRowTitle } from '..' +import { SettingHelpLink, SettingHelpTextRow, SettingRow, SettingRowTitle } from '..' export const OcrPpocrSettings = () => { // Hack: Hard-coded for now - const SERVING_DOC_URL = 'https://www.paddleocr.ai/latest/version3.x/deployment/serving.html' - const AISTUDIO_URL = 'https://aistudio.baidu.com/pipeline/mine' - + const API_URL = 'https://aistudio.baidu.com/paddleocr/task' const { t } = useTranslation() const { provider, updateConfig } = useOcrProvider(BuiltinOcrProviderIds.paddleocr) @@ -68,13 +66,9 @@ export const OcrPpocrSettings = () => { </SettingRow> <SettingHelpTextRow style={{ display: 'flex', flexDirection: 'column' }}> - <SettingHelpText style={{ marginBottom: 5 }}>{t('settings.tool.ocr.paddleocr.tip')}</SettingHelpText> <div style={{ display: 'flex', gap: 12 }}> - <SettingHelpLink target="_blank" href={SERVING_DOC_URL}> - {t('settings.tool.ocr.paddleocr.serving_doc_url_label')} - </SettingHelpLink> - <SettingHelpLink target="_blank" href={AISTUDIO_URL}> - {t('settings.tool.ocr.paddleocr.aistudio_url_label')} + <SettingHelpLink target="_blank" href={API_URL}> + {t('settings.tool.ocr.paddleocr.api_url_label')} </SettingHelpLink> </div> </SettingHelpTextRow> diff --git a/src/renderer/src/pages/settings/DocProcessSettings/PreprocessProviderSettings.tsx b/src/renderer/src/pages/settings/DocProcessSettings/PreprocessProviderSettings.tsx index 4d6df731f8..56e2308c89 100644 --- a/src/renderer/src/pages/settings/DocProcessSettings/PreprocessProviderSettings.tsx +++ b/src/renderer/src/pages/settings/DocProcessSettings/PreprocessProviderSettings.tsx @@ -75,32 +75,33 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => { <ProviderName> {preprocessProvider.name}</ProviderName> {officialWebsite && preprocessProviderConfig?.websites && ( <Link target="_blank" href={preprocessProviderConfig.websites.official}> - <ExportOutlined style={{ color: 'var(--color-text)', fontSize: '12px' }} /> + <ExportOutlined className="text-[--color-text] text-[12px]" /> </Link> )} </Flex> </SettingTitle> - <Divider style={{ width: '100%', margin: '10px 0' }} /> + <Divider className="my-[10px] w-full" /> {hasObjectKey(preprocessProvider, 'apiKey') && ( <> - <SettingSubtitle - style={{ - marginTop: 5, - marginBottom: 10, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between' - }}> - {t('settings.provider.api_key.label')} - <Tooltip title={t('settings.provider.api.key.list.open')} mouseEnterDelay={0.5}> - <Button type="text" size="small" onClick={openApiKeyList} icon={<List size={14} />} /> - </Tooltip> + <SettingSubtitle className="mt-[5px] mb-[10px] flex items-center justify-between"> + {preprocessProvider.id === 'paddleocr' + ? t('settings.tool.preprocess.paddleocr.aistudio_access_token') + : t('settings.provider.api_key.label')} + {preprocessProvider.id !== 'paddleocr' && ( + <Tooltip title={t('settings.provider.api.key.list.open')} mouseEnterDelay={0.5}> + <Button type="text" size="small" onClick={openApiKeyList} icon={<List size={14} />} /> + </Tooltip> + )} </SettingSubtitle> <Flex gap={8}> <Input.Password value={apiKey} placeholder={ - preprocessProvider.id === 'mineru' ? t('settings.mineru.api_key') : t('settings.provider.api_key.label') + preprocessProvider.id === 'mineru' + ? t('settings.mineru.api_key') + : preprocessProvider.id === 'paddleocr' + ? t('settings.tool.preprocess.paddleocr.aistudio_access_token') + : t('settings.provider.api_key.label') } onChange={(e) => setApiKey(formatApiKeys(e.target.value))} onBlur={onUpdateApiKey} @@ -109,28 +110,51 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => { autoFocus={apiKey === ''} /> </Flex> - <SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: 5 }}> - <SettingHelpLink target="_blank" href={apiKeyWebsite}> - {t('settings.provider.get_api_key')} - </SettingHelpLink> - <SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText> - </SettingHelpTextRow> + {preprocessProvider.id !== 'paddleocr' && ( + <SettingHelpTextRow className="mt-[5px] justify-between"> + <SettingHelpLink target="_blank" href={apiKeyWebsite}> + {t('settings.provider.get_api_key')} + </SettingHelpLink> + <SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText> + </SettingHelpTextRow> + )} </> )} {hasObjectKey(preprocessProvider, 'apiHost') && ( <> - <SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}> - {t('settings.provider.api_host')} + <SettingSubtitle className="mt-[5px] mb-[10px]"> + {preprocessProvider.id === 'paddleocr' + ? t('settings.tool.preprocess.paddleocr.api_url') + : t('settings.provider.api_host')} </SettingSubtitle> <Flex> <Input value={apiHost} - placeholder={t('settings.provider.api_host')} + placeholder={ + preprocessProvider.id === 'paddleocr' + ? t('settings.tool.preprocess.paddleocr.api_url') + : t('settings.provider.api_host') + } onChange={(e) => setApiHost(e.target.value)} onBlur={onUpdateApiHost} /> </Flex> + {preprocessProvider.id === 'paddleocr' && ( + <SettingHelpTextRow className="!flex-col"> + <div className="!flex !gap-3"> + <SettingHelpLink + className="!inline-block" + target="_blank" + href="https://aistudio.baidu.com/paddleocr/task"> + {t('settings.tool.preprocess.paddleocr.api_url_label')} + </SettingHelpLink> + <SettingHelpLink className="!inline-block" target="_blank" href="https://aistudio.baidu.com/paddleocr"> + {t('settings.tool.preprocess.paddleocr.paddleocr_url_label')} + </SettingHelpLink> + </div> + </SettingHelpTextRow> + )} </> )} diff --git a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx index fbd6e4067f..b26ce3e711 100644 --- a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx @@ -29,7 +29,7 @@ interface ParsedServerData extends MCPServer { } // 預設的 JSON 範例內容 -const initialJsonExample = `// 示例 JSON (stdio): +const initialJsonExample = `// Example JSON (stdio): // { // "mcpServers": { // "stdio-server-example": { @@ -39,7 +39,7 @@ const initialJsonExample = `// 示例 JSON (stdio): // } // } -// 示例 JSON (sse): +// Example JSON (sse): // { // "mcpServers": { // "sse-server-example": { @@ -49,7 +49,7 @@ const initialJsonExample = `// 示例 JSON (stdio): // } // } -// 示例 JSON (streamableHttp): +// Example JSON (streamableHttp): // { // "mcpServers": { // "streamable-http-example": { diff --git a/src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx b/src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx index 0d5e6e2865..e3da415b19 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx @@ -25,7 +25,7 @@ const MCPPromptsSection = ({ prompts }: MCPPromptsSectionProps) => { <Flex gap={4}> <Typography.Text strong>{arg.name}</Typography.Text> {arg.required && ( - <Tooltip title="Required field"> + <Tooltip title={t('common.required_field')}> <span style={{ color: '#f5222d' }}>*</span> </Tooltip> )} diff --git a/src/renderer/src/pages/settings/MCPSettings/McpProviderSettings.tsx b/src/renderer/src/pages/settings/MCPSettings/McpProviderSettings.tsx index ab0c1979cf..f6fc350e44 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpProviderSettings.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpProviderSettings.tsx @@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { SettingHelpLink, SettingHelpTextRow, SettingSubtitle } from '..' -import type { ProviderConfig } from './providers/config' +import { getProviderDisplayName, type ProviderConfig } from './providers/config' const logger = loggerService.withContext('McpProviderSettings') @@ -123,7 +123,7 @@ const McpProviderSettings: React.FC<Props> = ({ provider, existingServers }) => <DetailContainer> <ProviderHeader> <Flex className="items-center"> - <ProviderName>{provider.name}</ProviderName> + <ProviderName>{getProviderDisplayName(provider, t)}</ProviderName> {provider.discoverUrl && ( <Link target="_blank" href={provider.discoverUrl} style={{ display: 'flex' }}> <Button type="text" size="small"> diff --git a/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx index 8aa4faf6d8..3619a28356 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx @@ -62,7 +62,7 @@ const MCPToolsSection = ({ tools, server, onToggleTool, onToggleAutoApprove }: M <Flex gap={4}> <Typography.Text strong>{key}</Typography.Text> {tool.inputSchema.required?.includes(key) && ( - <Tooltip title="Required field"> + <Tooltip title={t('common.required_field')}> <span style={{ color: '#f5222d' }}>*</span> </Tooltip> )} diff --git a/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx index ea6d6dfe00..0c8f953f77 100644 --- a/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx @@ -15,8 +15,10 @@ import { getTokenFluxToken, saveTokenFluxToken, syncTokenFluxServers, TOKENFLUX_ // Provider configuration interface interface ProviderConfig { key: string - name: string - description: string + /** i18n key for provider name, or plain text if not starting with 'provider.' */ + nameKey: string + /** i18n key for provider description */ + descriptionKey: string discoverUrl: string apiKeyUrl: string tokenFieldName: string @@ -29,8 +31,8 @@ interface ProviderConfig { const providers: ProviderConfig[] = [ { key: 'modelscope', - name: 'ModelScope', - description: 'ModelScope 平台 MCP 服务', + nameKey: 'ModelScope', + descriptionKey: 'settings.mcp.sync.providerDescriptions.modelscope', discoverUrl: `${MODELSCOPE_HOST}/mcp?hosted=1&page=1`, apiKeyUrl: `${MODELSCOPE_HOST}/my/myaccesstoken`, tokenFieldName: 'modelScopeToken', @@ -40,8 +42,8 @@ const providers: ProviderConfig[] = [ }, { key: 'tokenflux', - name: 'TokenFlux', - description: 'TokenFlux 平台 MCP 服务', + nameKey: 'TokenFlux', + descriptionKey: 'settings.mcp.sync.providerDescriptions.tokenflux', discoverUrl: `${TOKENFLUX_HOST}/mcps`, apiKeyUrl: `${TOKENFLUX_HOST}/dashboard/api-keys`, tokenFieldName: 'tokenfluxToken', @@ -51,8 +53,8 @@ const providers: ProviderConfig[] = [ }, { key: 'lanyun', - name: '蓝耘科技', - description: '蓝耘科技云平台 MCP 服务', + nameKey: 'provider.lanyun', + descriptionKey: 'settings.mcp.sync.providerDescriptions.lanyun', discoverUrl: 'https://mcp.lanyun.net', apiKeyUrl: LANYUN_KEY_HOST, tokenFieldName: 'tokenLanyunToken', @@ -62,8 +64,8 @@ const providers: ProviderConfig[] = [ }, { key: '302ai', - name: '302.AI', - description: '302.AI 平台 MCP 服务', + nameKey: '302.AI', + descriptionKey: 'settings.mcp.sync.providerDescriptions.302ai', discoverUrl: 'https://302.ai', apiKeyUrl: 'https://dash.302.ai/apis/list', tokenFieldName: 'token302aiToken', @@ -73,8 +75,8 @@ const providers: ProviderConfig[] = [ }, { key: 'bailian', - name: '阿里云百炼', - description: '百炼平台服务', + nameKey: 'provider.dashscope', + descriptionKey: 'settings.mcp.sync.providerDescriptions.bailian', discoverUrl: `https://bailian.console.aliyun.com/?tab=mcp#/mcp-market`, apiKeyUrl: `https://bailian.console.aliyun.com/?tab=app#/api-key`, tokenFieldName: 'bailianToken', @@ -84,6 +86,14 @@ const providers: ProviderConfig[] = [ } ] +/** + * Helper function to get the display name for a provider. + * Translates if nameKey starts with 'provider.', otherwise returns as-is. + */ +const getProviderDisplayName = (provider: ProviderConfig, t: (key: string) => string): string => { + return provider.nameKey.startsWith('provider.') ? t(provider.nameKey) : provider.nameKey +} + interface Props { resolve: (data: any) => void existingServers: MCPServer[] @@ -206,7 +216,7 @@ const PopupContainer: React.FC<Props> = ({ resolve, existingServers }) => { style={{ width: 200 }} options={providers.map((provider) => ({ value: provider.key, - label: provider.name + label: getProviderDisplayName(provider, t) }))} /> </ProviderSelector> diff --git a/src/renderer/src/pages/settings/MCPSettings/index.tsx b/src/renderer/src/pages/settings/MCPSettings/index.tsx index 5c11b7b261..b27e058441 100644 --- a/src/renderer/src/pages/settings/MCPSettings/index.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/index.tsx @@ -27,7 +27,7 @@ import ProviderDetail from './McpProviderSettings' import McpServersList from './McpServersList' import McpSettings from './McpSettings' import NpxSearch from './NpxSearch' -import { providers } from './providers/config' +import { getProviderDisplayName, providers } from './providers/config' const MCPSettings: FC = () => { const { theme } = useTheme() @@ -108,7 +108,7 @@ const MCPSettings: FC = () => { {providers.map((provider) => ( <ListItem key={provider.key} - title={provider.name} + title={getProviderDisplayName(provider, t)} active={activeView === provider.key} onClick={() => navigate(`/settings/mcp/${provider.key}`)} icon={providerIcons[provider.key] || <FolderCog size={16} />} diff --git a/src/renderer/src/pages/settings/MCPSettings/providers/config.ts b/src/renderer/src/pages/settings/MCPSettings/providers/config.ts index 7c3f2974b9..2a1bc69d3b 100644 --- a/src/renderer/src/pages/settings/MCPSettings/providers/config.ts +++ b/src/renderer/src/pages/settings/MCPSettings/providers/config.ts @@ -1,4 +1,3 @@ -import { getProviderLabel } from '@renderer/i18n/label' import type { MCPServer } from '@renderer/types' import { getAI302Token, saveAI302Token, syncAi302Servers } from './302ai' @@ -10,8 +9,10 @@ import { getTokenFluxToken, saveTokenFluxToken, syncTokenFluxServers, TOKENFLUX_ export interface ProviderConfig { key: string - name: string - description: string + /** i18n key for provider name, or plain text if not starting with 'provider.' */ + nameKey: string + /** i18n key for provider description */ + descriptionKey: string discoverUrl: string apiKeyUrl: string tokenFieldName: string @@ -23,8 +24,8 @@ export interface ProviderConfig { export const providers: ProviderConfig[] = [ { key: 'bailian', - name: getProviderLabel('dashscope'), - description: '百炼平台服务', + nameKey: 'provider.dashscope', + descriptionKey: 'settings.mcp.sync.providerDescriptions.bailian', discoverUrl: `https://bailian.console.aliyun.com/?tab=mcp#/mcp-market`, apiKeyUrl: `https://bailian.console.aliyun.com/?tab=app#/api-key`, tokenFieldName: 'bailianToken', @@ -34,8 +35,8 @@ export const providers: ProviderConfig[] = [ }, { key: 'modelscope', - name: 'ModelScope', - description: 'ModelScope 平台 MCP 服务', + nameKey: 'ModelScope', + descriptionKey: 'settings.mcp.sync.providerDescriptions.modelscope', discoverUrl: `${MODELSCOPE_HOST}/mcp?hosted=1&page=1`, apiKeyUrl: `${MODELSCOPE_HOST}/my/myaccesstoken`, tokenFieldName: 'modelScopeToken', @@ -45,8 +46,8 @@ export const providers: ProviderConfig[] = [ }, { key: 'tokenflux', - name: 'TokenFlux', - description: 'TokenFlux 平台 MCP 服务', + nameKey: 'TokenFlux', + descriptionKey: 'settings.mcp.sync.providerDescriptions.tokenflux', discoverUrl: `${TOKENFLUX_HOST}/mcps`, apiKeyUrl: `${TOKENFLUX_HOST}/dashboard/api-keys`, tokenFieldName: 'tokenfluxToken', @@ -56,8 +57,8 @@ export const providers: ProviderConfig[] = [ }, { key: 'lanyun', - name: getProviderLabel('lanyun'), - description: '蓝耘科技云平台 MCP 服务', + nameKey: 'provider.lanyun', + descriptionKey: 'settings.mcp.sync.providerDescriptions.lanyun', discoverUrl: 'https://mcp.lanyun.net', apiKeyUrl: LANYUN_KEY_HOST, tokenFieldName: 'tokenLanyunToken', @@ -67,8 +68,8 @@ export const providers: ProviderConfig[] = [ }, { key: '302ai', - name: '302.AI', - description: '302.AI 平台 MCP 服务', + nameKey: '302.AI', + descriptionKey: 'settings.mcp.sync.providerDescriptions.302ai', discoverUrl: 'https://302.ai', apiKeyUrl: 'https://dash.302.ai/apis/list', tokenFieldName: 'token302aiToken', @@ -78,8 +79,8 @@ export const providers: ProviderConfig[] = [ }, { key: 'mcprouter', - name: 'MCP Router', - description: 'MCP Router 平台 MCP 服务', + nameKey: 'MCP Router', + descriptionKey: 'settings.mcp.sync.providerDescriptions.mcprouter', discoverUrl: 'https://mcprouter.co', apiKeyUrl: 'https://mcprouter.co/settings/keys', tokenFieldName: 'mcprouterToken', @@ -88,3 +89,11 @@ export const providers: ProviderConfig[] = [ syncServers: syncMCPRouterServers } ] + +/** + * Helper function to get the display name for a provider. + * Translates if nameKey starts with 'provider.', otherwise returns as-is. + */ +export const getProviderDisplayName = (provider: ProviderConfig, t: (key: string) => string): string => { + return provider.nameKey.startsWith('provider.') ? t(provider.nameKey) : provider.nameKey +} diff --git a/src/renderer/src/pages/settings/MCPSettings/providers/lanyun.ts b/src/renderer/src/pages/settings/MCPSettings/providers/lanyun.ts index 2cc1d1ee74..9f0d411b21 100644 --- a/src/renderer/src/pages/settings/MCPSettings/providers/lanyun.ts +++ b/src/renderer/src/pages/settings/MCPSettings/providers/lanyun.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { getProviderLabel } from '@renderer/i18n/label' import type { MCPServer } from '@renderer/types' import i18next from 'i18next' @@ -159,7 +160,7 @@ export const syncTokenLanYunServers = async ( args: [], env: {}, isActive: true, - provider: '蓝耘科技', + provider: getProviderLabel('lanyun'), providerUrl: server.operationalUrls[0].url, logoUrl: server.logoUrl || '', tags: server.tags ?? (server.chineseName ? [server.chineseName] : []) diff --git a/src/renderer/src/pages/settings/MemorySettings/MemorySettings.tsx b/src/renderer/src/pages/settings/MemorySettings/MemorySettings.tsx index 6a454c5dd4..94c226d426 100644 --- a/src/renderer/src/pages/settings/MemorySettings/MemorySettings.tsx +++ b/src/renderer/src/pages/settings/MemorySettings/MemorySettings.tsx @@ -2,6 +2,7 @@ import { ExclamationCircleOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { DeleteIcon, EditIcon, LoadingIcon, RefreshIcon } from '@renderer/components/Icons' import { HStack } from '@renderer/components/Layout' +import Scrollbar from '@renderer/components/Scrollbar' import TextBadge from '@renderer/components/TextBadge' import { useTheme } from '@renderer/context/ThemeProvider' import { useModel } from '@renderer/hooks/useModel' @@ -14,11 +15,11 @@ import { setGlobalMemoryEnabled } from '@renderer/store/memory' import type { MemoryItem } from '@types' -import { Badge, Button, Dropdown, Empty, Flex, Form, Input, Modal, Pagination, Space, Spin, Switch } from 'antd' +import { Button, Dropdown, Empty, Flex, Form, Input, Modal, Space, Spin, Switch } from 'antd' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { Brain, Calendar, MenuIcon, PlusIcon, Settings2, UserRound, UserRoundMinus, UserRoundPlus } from 'lucide-react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import styled from 'styled-components' @@ -285,8 +286,8 @@ const MemorySettings = () => { const [addUserModalVisible, setAddUserModalVisible] = useState(false) const [form] = Form.useForm() const [uniqueUsers, setUniqueUsers] = useState<string[]>([]) - const [currentPage, setCurrentPage] = useState(1) - const [pageSize, setPageSize] = useState(50) + const [displayCount, setDisplayCount] = useState(50) + const loadMoreRef = useRef<HTMLDivElement>(null) const memoryService = MemoryService.getInstance() // Utility functions @@ -335,8 +336,8 @@ const MemorySettings = () => { // Sync memoryService with Redux store on mount and when currentUser changes useEffect(() => { logger.verbose(`useEffect triggered for currentUser: ${currentUser}`) - // Reset to first page when user changes - setCurrentPage(1) + // Reset display count when user changes + setDisplayCount(50) loadMemories(currentUser) }, [currentUser, loadMemories]) @@ -357,36 +358,51 @@ const MemorySettings = () => { }) }, [allMemories, debouncedSearchText]) - // Calculate paginated memories - const startIndex = (currentPage - 1) * pageSize - const endIndex = startIndex + pageSize - const paginatedMemories = filteredMemories.slice(startIndex, endIndex) + // Calculate displayed memories based on displayCount + const displayedMemories = filteredMemories.slice(0, displayCount) + const hasMore = displayCount < filteredMemories.length const handleSearch = (value: string) => { setSearchText(value) - // Reset to first page when searching - setCurrentPage(1) + // Reset display count when searching + setDisplayCount(50) } - // Reset to first page when debounced search changes + // Reset display count when debounced search changes useEffect(() => { - setCurrentPage(1) + setDisplayCount(50) }, [debouncedSearchText]) - const handlePageChange = (page: number, size?: number) => { - setCurrentPage(page) - if (size && size !== pageSize) { - setPageSize(size) + // Infinite scroll using IntersectionObserver + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !loading) { + setDisplayCount((prev) => prev + 50) + } + }, + { threshold: 0.1 } + ) + + const currentRef = loadMoreRef.current + if (currentRef) { + observer.observe(currentRef) } - } + + return () => { + if (currentRef) { + observer.unobserve(currentRef) + } + } + }, [hasMore, loading]) const handleAddMemory = async (memory: string) => { try { // The memory service will automatically use the current user from its state await memoryService.add(memory, {}) window.toast.success(t('memory.add_success')) - // Go to first page to see the newly added memory - setCurrentPage(1) + // Reset display count to see the newly added memory at the top + setDisplayCount(50) await loadMemories(currentUser) } catch (error) { logger.error('Failed to add memory:', error as Error) @@ -432,8 +448,8 @@ const MemorySettings = () => { // Clear current memories to show loading state immediately setAllMemories([]) - // Reset pagination - setCurrentPage(1) + // Reset display count + setDisplayCount(50) try { // Explicitly load memories for the new user @@ -589,11 +605,9 @@ const MemorySettings = () => { {/* User Management */} <SettingGroup theme={theme}> - <SettingTitle>{t('memory.user_management')}</SettingTitle> - <SettingDivider /> <SettingRow> <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}> - <SettingRowTitle>{t('memory.user_id')}</SettingRowTitle> + <SettingRowTitle>{t('memory.user_management')}</SettingRowTitle> <SettingHelpText style={{ fontSize: '13px', lineHeight: '1.5', color: 'var(--color-text-secondary)' }}> {allMemories.length} {t('memory.total_memories')} </SettingHelpText> @@ -605,20 +619,10 @@ const MemorySettings = () => { onAddUser={() => setAddUserModalVisible(true)} /> </SettingRow> - <SettingDivider /> - <SettingRow> - <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}> - <SettingRowTitle>{t('memory.users')}</SettingRowTitle> - <SettingHelpText style={{ fontSize: '13px', lineHeight: '1.5', color: 'var(--color-text-secondary)' }}> - {t('memory.statistics')} - </SettingHelpText> - </div> - <Badge count={uniqueUsers.length} showZero style={{ backgroundColor: 'var(--color-primary)' }} /> - </SettingRow> </SettingGroup> {/* Memory List */} - <SettingGroup theme={theme}> + <SettingGroup theme={theme} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <SettingTitle>{t('memory.title')}</SettingTitle> <Space> @@ -679,7 +683,7 @@ const MemorySettings = () => { <SettingDivider style={{ marginBottom: 15 }} /> {/* Memory Content Area */} - <div style={{ minHeight: 400 }}> + <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> {allMemories.length === 0 && !loading ? ( <Empty image={<Brain size={48} style={{ opacity: 0.3 }} />} @@ -702,7 +706,7 @@ const MemorySettings = () => { ) : ( <> {loading && ( - <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 300 }}> + <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1 }}> <Spin indicator={<LoadingIcon color="var(--color-text-2)" />} /> </div> )} @@ -712,60 +716,48 @@ const MemorySettings = () => { )} {!loading && filteredMemories.length > 0 && ( - <> - <MemoryListContainer> - {paginatedMemories.map((memory) => ( - <MemoryItem key={memory.id}> - <div className="memory-header"> - <div className="memory-meta"> - <Calendar size={14} style={{ marginRight: 4 }} /> - <span>{memory.createdAt ? dayjs(memory.createdAt).fromNow() : '-'}</span> - </div> - <Space size="small"> - <Button - type="text" - size="small" - icon={<EditIcon size={14} />} - onClick={() => handleEditMemory(memory)} - /> - <Button - type="text" - size="small" - danger - icon={<DeleteIcon size={14} className="lucide-custom" />} - onClick={() => { - window.modal.confirm({ - centered: true, - title: t('memory.delete_confirm'), - content: t('memory.delete_confirm_single'), - onOk: () => handleDeleteMemory(memory.id), - okText: t('common.confirm'), - cancelText: t('common.cancel') - }) - }} - /> - </Space> + <MemoryListContainer> + {displayedMemories.map((memory) => ( + <MemoryItem key={memory.id}> + <div className="memory-header"> + <div className="memory-meta"> + <Calendar size={14} style={{ marginRight: 4 }} /> + <span>{memory.createdAt ? dayjs(memory.createdAt).fromNow() : '-'}</span> </div> - <div className="memory-content">{memory.memory}</div> - </MemoryItem> - ))} - </MemoryListContainer> - - <div style={{ marginTop: 16, textAlign: 'center' }}> - <Pagination - current={currentPage} - pageSize={pageSize} - total={filteredMemories.length} - onChange={handlePageChange} - showSizeChanger - showTotal={(total, range) => - t('memory.pagination_total', { start: range[0], end: range[1], total }) - } - pageSizeOptions={['20', '50', '100', '200']} - defaultPageSize={50} - /> - </div> - </> + <Space size="small" className="memory-actions"> + <Button + type="text" + size="small" + icon={<EditIcon size={14} />} + onClick={() => handleEditMemory(memory)} + /> + <Button + type="text" + size="small" + danger + icon={<DeleteIcon size={14} className="lucide-custom" />} + onClick={() => { + window.modal.confirm({ + centered: true, + title: t('memory.delete_confirm'), + content: t('memory.delete_confirm_single'), + onOk: () => handleDeleteMemory(memory.id), + okText: t('common.confirm'), + cancelText: t('common.cancel') + }) + }} + /> + </Space> + </div> + <div className="memory-content">{memory.memory}</div> + </MemoryItem> + ))} + {hasMore && ( + <LoadMoreTrigger ref={loadMoreRef}> + <Spin indicator={<LoadingIcon color="var(--color-text-2)" />} size="small" /> + </LoadMoreTrigger> + )} + </MemoryListContainer> )} </> )} @@ -804,24 +796,39 @@ const MemorySettings = () => { } // Styled Components -const MemoryListContainer = styled.div` +const MemoryListContainer = styled(Scrollbar)` display: flex; flex-direction: column; gap: 15px; - max-height: 500px; + flex: 1; overflow-y: auto; ` +const LoadMoreTrigger = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 16px; +` + const MemoryItem = styled.div` padding: 12px; - background: var(--color-background-soft); border: 1px solid var(--color-border); border-radius: 10px; transition: all 0.2s ease; + .memory-actions { + opacity: 0; + transition: opacity 0.2s ease; + } + &:hover { border-color: var(--color-primary); background: var(--color-background); + + .memory-actions { + opacity: 1; + } } .memory-header { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ApiOptionsSettings/ApiOptionsSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiOptionsSettings/ApiOptionsSettings.tsx index 3e4aa4c7c7..de883ccf9e 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ApiOptionsSettings/ApiOptionsSettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ApiOptionsSettings/ApiOptionsSettings.tsx @@ -1,8 +1,9 @@ import { HStack } from '@renderer/components/Layout' import { InfoTooltip } from '@renderer/components/TooltipIcons' import { useProvider } from '@renderer/hooks/useProvider' -import type { Provider } from '@renderer/types' -import { Flex, Switch } from 'antd' +import { type AnthropicCacheControlSettings, type Provider } from '@renderer/types' +import { isSupportAnthropicPromptCacheProvider } from '@renderer/utils/provider' +import { Divider, Flex, InputNumber, Switch } from 'antd' import { startTransition, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -114,6 +115,27 @@ const ApiOptionsSettings = ({ providerId }: Props) => { return items }, [openAIOptions, provider.apiOptions, provider.type, t, updateProviderTransition]) + const isSupportAnthropicPromptCache = isSupportAnthropicPromptCacheProvider(provider) + + const cacheSettings = useMemo( + () => + provider.anthropicCacheControl ?? { + tokenThreshold: 0, + cacheSystemMessage: true, + cacheLastNMessages: 0 + }, + [provider.anthropicCacheControl] + ) + + const updateCacheSettings = useCallback( + (updates: Partial<AnthropicCacheControlSettings>) => { + updateProviderTransition({ + anthropicCacheControl: { ...cacheSettings, ...updates } + }) + }, + [cacheSettings, updateProviderTransition] + ) + return ( <Flex vertical gap="middle"> {options.map((item) => ( @@ -127,6 +149,52 @@ const ApiOptionsSettings = ({ providerId }: Props) => { <Switch id={item.key} checked={item.checked} onChange={item.onChange} /> </HStack> ))} + + {isSupportAnthropicPromptCache && ( + <> + <Divider style={{ margin: '8px 0' }} /> + <HStack justifyContent="space-between"> + <HStack alignItems="center" gap={6}> + <span>{t('settings.provider.api.options.anthropic_cache.token_threshold')}</span> + <InfoTooltip title={t('settings.provider.api.options.anthropic_cache.token_threshold_help')} /> + </HStack> + <InputNumber + min={0} + max={100000} + value={cacheSettings.tokenThreshold} + onChange={(v) => updateCacheSettings({ tokenThreshold: v ?? 0 })} + style={{ width: 100 }} + /> + </HStack> + {cacheSettings.tokenThreshold > 0 && ( + <> + <HStack justifyContent="space-between"> + <HStack alignItems="center" gap={6}> + <span>{t('settings.provider.api.options.anthropic_cache.cache_system')}</span> + <InfoTooltip title={t('settings.provider.api.options.anthropic_cache.cache_system_help')} /> + </HStack> + <Switch + checked={cacheSettings.cacheSystemMessage} + onChange={(v) => updateCacheSettings({ cacheSystemMessage: v })} + /> + </HStack> + <HStack justifyContent="space-between"> + <HStack alignItems="center" gap={6}> + <span>{t('settings.provider.api.options.anthropic_cache.cache_last_n')}</span> + <InfoTooltip title={t('settings.provider.api.options.anthropic_cache.cache_last_n_help')} /> + </HStack> + <InputNumber + min={0} + max={10} + value={cacheSettings.cacheLastNMessages} + onChange={(v) => updateCacheSettings({ cacheLastNMessages: v ?? 0 })} + style={{ width: 100 }} + /> + </HStack> + </> + )} + </> + )} </Flex> ) } diff --git a/src/renderer/src/pages/settings/ProviderSettings/AwsBedrockSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/AwsBedrockSettings.tsx index 75278c917f..9b171b073a 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AwsBedrockSettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AwsBedrockSettings.tsx @@ -54,7 +54,7 @@ const AwsBedrockSettings: FC = () => { </SettingSubtitle> <Input value={localAccessKeyId} - placeholder="Access Key ID" + placeholder={t('settings.provider.aws-bedrock.access_key_id')} onChange={(e) => setLocalAccessKeyId(e.target.value)} onBlur={() => setAccessKeyId(localAccessKeyId)} style={{ marginTop: 5 }} @@ -68,7 +68,7 @@ const AwsBedrockSettings: FC = () => { </SettingSubtitle> <Input.Password value={localSecretAccessKey} - placeholder="Secret Access Key" + placeholder={t('settings.provider.aws-bedrock.secret_access_key')} onChange={(e) => setLocalSecretAccessKey(e.target.value)} onBlur={() => setSecretAccessKey(localSecretAccessKey)} style={{ marginTop: 5 }} @@ -92,7 +92,7 @@ const AwsBedrockSettings: FC = () => { <SettingSubtitle style={{ marginTop: 15 }}>{t('settings.provider.aws-bedrock.api_key')}</SettingSubtitle> <Input.Password value={localApiKey} - placeholder="Bedrock API Key" + placeholder={t('settings.provider.aws-bedrock.api_key')} onChange={(e) => setLocalApiKey(e.target.value)} onBlur={() => setApiKey(localApiKey)} style={{ marginTop: 5 }} diff --git a/src/renderer/src/pages/settings/ProviderSettings/DMXAPISettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/DMXAPISettings.tsx index ffdaff8955..63245a3396 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/DMXAPISettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/DMXAPISettings.tsx @@ -22,31 +22,30 @@ enum PlatformType { OVERSEA = 'https://ssvip.DMXAPI.com' } -// FIXME: always Chinese. take consider of i18n -const PlatformOptions = [ - { - label: 'www.DMXAPI.cn 人民币站', - value: PlatformType.OFFICIAL, - apiKeyWebsite: 'https://www.dmxapi.cn/register?aff=bwwY' - }, - { - label: 'www.DMXAPI.com 国际站', - value: PlatformType.INTERNATIONAL, - apiKeyWebsite: 'https://www.dmxapi.com/register' - }, - { - label: 'ssvip.DMXAPI.com 生产级商用站', - value: PlatformType.OVERSEA, - apiKeyWebsite: 'https://ssvip.dmxapi.com/register' - } -] - const DMXAPISettings: FC<DMXAPISettingsProps> = ({ providerId }) => { const { provider, updateProvider } = useProvider(providerId) const { theme } = useTheme() const { t } = useTranslation() + const PlatformOptions = [ + { + label: t('settings.provider.dmxapi.platform_official'), + value: PlatformType.OFFICIAL, + apiKeyWebsite: 'https://www.dmxapi.cn/register?aff=bwwY' + }, + { + label: t('settings.provider.dmxapi.platform_international'), + value: PlatformType.INTERNATIONAL, + apiKeyWebsite: 'https://www.dmxapi.com/register' + }, + { + label: t('settings.provider.dmxapi.platform_enterprise'), + value: PlatformType.OVERSEA, + apiKeyWebsite: 'https://ssvip.dmxapi.com/register' + } + ] + // 获取当前选中的平台,如果没有设置则默认为官方平台 const getCurrentPlatform = (): PlatformType => { if (!provider.apiHost) return PlatformType.OFFICIAL @@ -93,7 +92,7 @@ const DMXAPISettings: FC<DMXAPISettingsProps> = ({ providerId }) => { <span> {option.label}{' '} <a href={option.apiKeyWebsite} target="_blank" rel="noopener noreferrer"> - (获得 API密钥) + ({t('settings.provider.get_api_key')}) </a> </span> ) diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/HealthCheckPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/HealthCheckPopup.tsx index 9099561b08..5bbf5c8d27 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/HealthCheckPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/HealthCheckPopup.tsx @@ -2,7 +2,7 @@ import { Box } from '@renderer/components/Layout' import { TopView } from '@renderer/components/TopView' import type { Provider } from '@renderer/types' import { maskApiKey } from '@renderer/utils/api' -import { Flex, InputNumber, Modal, Radio, Segmented, Typography } from 'antd' +import { Divider, Flex, InputNumber, Modal, Radio, Segmented, Typography } from 'antd' import { Alert } from 'antd' import { useCallback, useMemo, useReducer } from 'react' import { useTranslation } from 'react-i18next' @@ -141,7 +141,6 @@ const PopupContainer: React.FC<Props> = ({ title, apiKeys, resolve }) => { <Segmented value={keyCheckMode} onChange={(value) => dispatch({ type: 'SET_KEY_CHECK_MODE', payload: value as 'single' | 'all' })} - size="small" options={[ { value: 'single', label: t('settings.models.check.single') }, { value: 'all', label: t('settings.models.check.all') } @@ -153,7 +152,6 @@ const PopupContainer: React.FC<Props> = ({ title, apiKeys, resolve }) => { <Segmented value={isConcurrent ? 'enabled' : 'disabled'} onChange={(value) => dispatch({ type: 'SET_CONCURRENT', payload: value === 'enabled' })} - size="small" options={[ { value: 'disabled', label: t('settings.models.check.disabled') }, { value: 'enabled', label: t('settings.models.check.enabled') } @@ -167,8 +165,7 @@ const PopupContainer: React.FC<Props> = ({ title, apiKeys, resolve }) => { onChange={(value) => dispatch({ type: 'SET_TIMEOUT_SECONDS', payload: value || 15 })} min={5} max={60} - size="small" - style={{ width: 90 }} + style={{ width: 110 }} addonAfter="s" /> </Flex> @@ -192,6 +189,7 @@ const PopupContainer: React.FC<Props> = ({ title, apiKeys, resolve }) => { footer={(_, { OkBtn, CancelBtn }) => ( <> {renderFooter} + <Divider /> <Flex justify="space-between" style={{ marginTop: 16 }}> <div /> {/* Empty div for spacing */} <Flex gap={8}> @@ -202,11 +200,10 @@ const PopupContainer: React.FC<Props> = ({ title, apiKeys, resolve }) => { </> )}> <Alert - message={t('common.warning')} description={t('settings.models.check.disclaimer')} type="warning" showIcon - style={{ fontSize: 12 }} + style={{ fontSize: 12, padding: 10, marginTop: 10 }} /> {/* API key selection section - only shown for 'single' mode and multiple keys */} diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelListItem.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelListItem.tsx index e8fe90e7b8..29735d8eba 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelListItem.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelListItem.tsx @@ -1,14 +1,17 @@ +import { ErrorDetailModal } from '@renderer/components/ErrorDetailModal' import { FreeTrialModelTag } from '@renderer/components/FreeTrialModelTag' import { type HealthResult, HealthStatusIndicator } from '@renderer/components/HealthStatusIndicator' import { HStack } from '@renderer/components/Layout' import ModelIdWithTags from '@renderer/components/ModelIdWithTags' import { getModelLogo } from '@renderer/config/models' import type { Model } from '@renderer/types' +import type { SerializedError } from '@renderer/types/error' import type { ModelWithStatus } from '@renderer/types/healthCheck' +import { HealthStatus } from '@renderer/types/healthCheck' import { maskApiKey } from '@renderer/utils/api' import { Avatar, Button, Tooltip } from 'antd' import { Bolt, Minus } from 'lucide-react' -import React, { memo } from 'react' +import React, { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -24,43 +27,83 @@ interface ModelListItemProps { const ModelListItem: React.FC<ModelListItemProps> = ({ ref, model, modelStatus, disabled, onEdit, onRemove }) => { const { t } = useTranslation() const isChecking = modelStatus?.checking === true + const [showErrorModal, setShowErrorModal] = useState(false) + const [selectedError, setSelectedError] = useState<SerializedError | undefined>() - const healthResults: HealthResult[] = - modelStatus?.keyResults?.map((kr) => ({ - status: kr.status, - latency: kr.latency, - error: kr.error, - label: maskApiKey(kr.key) - })) || [] + const healthResults = useMemo( + () => + modelStatus?.keyResults?.map((kr) => ({ + status: kr.status, + latency: kr.latency, + error: kr.error, + label: maskApiKey(kr.key) + })) || [], + [modelStatus?.keyResults] + ) + + const hasFailedResult = useMemo(() => healthResults.some((r) => r.status === HealthStatus.FAILED), [healthResults]) + + const handleErrorClick = useMemo(() => { + if (!hasFailedResult) return undefined + return (result: HealthResult) => { + if (result.error) { + setSelectedError(result.error) + setShowErrorModal(true) + } + } + }, [hasFailedResult]) + + const handleCloseErrorModal = useCallback(() => { + setShowErrorModal(false) + setSelectedError(undefined) + }, []) + + const handleEdit = useCallback(() => { + onEdit(model) + }, [model, onEdit]) + + const handleRemove = useCallback(() => { + onRemove(model) + }, [model, onRemove]) return ( - <ListItem ref={ref}> - <HStack alignItems="center" gap={10} style={{ flex: 1 }}> - <Avatar src={getModelLogo(model)} size={24}> - {model?.name?.[0]?.toUpperCase()} - </Avatar> - <ModelIdWithTags - model={model} - style={{ - flex: 1, - width: 0, - overflow: 'hidden' - }} - /> - <FreeTrialModelTag model={model} /> - </HStack> - <HStack alignItems="center" gap={6}> - <HealthStatusIndicator results={healthResults} loading={isChecking} showLatency /> - <HStack alignItems="center" gap={0}> - <Tooltip title={t('models.edit')} mouseLeaveDelay={0}> - <Button type="text" onClick={() => onEdit(model)} disabled={disabled} icon={<Bolt size={14} />} /> - </Tooltip> - <Tooltip title={t('settings.models.manage.remove_model')} mouseLeaveDelay={0}> - <Button type="text" onClick={() => onRemove(model)} disabled={disabled} icon={<Minus size={14} />} /> - </Tooltip> + <> + <ListItem ref={ref}> + <HStack alignItems="center" gap={10} style={{ flex: 1 }}> + <Avatar src={getModelLogo(model)} size={24}> + {model?.name?.[0]?.toUpperCase()} + </Avatar> + <ModelIdWithTags + model={model} + style={{ + flex: 1, + width: 0, + overflow: 'hidden' + }} + /> + <FreeTrialModelTag model={model} /> </HStack> - </HStack> - </ListItem> + <HStack alignItems="center" gap={6}> + <HealthStatusIndicator + results={healthResults} + loading={isChecking} + showLatency + onErrorClick={handleErrorClick} + /> + <HStack alignItems="center" gap={0}> + <Tooltip title={t('models.edit')} mouseLeaveDelay={0}> + <Button type="text" onClick={handleEdit} disabled={disabled} icon={<Bolt size={14} />} /> + </Tooltip> + <Tooltip title={t('settings.models.manage.remove_model')} mouseLeaveDelay={0}> + <Button type="text" onClick={handleRemove} disabled={disabled} icon={<Minus size={14} />} /> + </Tooltip> + </HStack> + </HStack> + </ListItem> + {hasFailedResult && ( + <ErrorDetailModal open={showErrorModal} onClose={handleCloseErrorModal} error={selectedError} /> + )} + </> ) } diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx index cc19eea6a6..f56abbaabe 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx @@ -183,7 +183,7 @@ const ProviderList: FC = () => { setProviderLogos(updatedLogos) } catch (error) { logger.error('Failed to save logo', error as Error) - window.toast.error('保存Provider Logo失败') + window.toast.error(t('message.error.save_provider_logo')) } } @@ -218,7 +218,7 @@ const ProviderList: FC = () => { })) } catch (error) { logger.error('Failed to save logo', error as Error) - window.toast.error('更新Provider Logo失败') + window.toast.error(t('message.error.update_provider_logo')) } } else if (logo === undefined && logoFile === undefined) { try { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 777bc61984..15c4b6446b 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,11 +1,12 @@ import { adaptProvider } from '@renderer/aiCore/provider/providerConfig' import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert' +import { ErrorDetailModal } from '@renderer/components/ErrorDetailModal' import { LoadingIcon } from '@renderer/components/Icons' import { HStack } from '@renderer/components/Layout' import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup' import Selector from '@renderer/components/Selector' import { HelpTooltip } from '@renderer/components/TooltipIcons' -import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import { isRerankModel } from '@renderer/config/models' import { PROVIDER_URLS } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' @@ -21,7 +22,7 @@ import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@render import type { ApiKeyConnectivity } from '@renderer/types/healthCheck' import { HealthStatus } from '@renderer/types/healthCheck' import { formatApiHost, formatApiKeys, getFancyProviderName, validateApiHost } from '@renderer/utils' -import { formatErrorMessage } from '@renderer/utils/error' +import { serializeHealthCheckError } from '@renderer/utils/error' import { isAIGatewayProvider, isAnthropicProvider, @@ -31,6 +32,7 @@ import { isOllamaProvider, isOpenAICompatibleProvider, isOpenAIProvider, + isSupportAnthropicPromptCacheProvider, isVertexProvider } from '@renderer/utils/provider' import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd' @@ -128,18 +130,22 @@ const ProviderSetting: FC<Props> = ({ providerId }) => { status: HealthStatus.NOT_CHECKED, checking: false }) + const [showErrorModal, setShowErrorModal] = useState(false) - const updateWebSearchProviderKey = ({ apiKey }: { apiKey: string }) => { - provider.id === 'zhipu' && dispatch(updateWebSearchProvider({ id: 'zhipu', apiKey: apiKey.split(',')[0] })) - } + const updateWebSearchProviderKey = useCallback( + ({ apiKey }: { apiKey: string }) => { + provider.id === 'zhipu' && dispatch(updateWebSearchProvider({ id: 'zhipu', apiKey: apiKey.split(',')[0] })) + }, + [dispatch, provider.id] + ) - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedUpdateApiKey = useCallback( - debounce((value) => { - updateProvider({ apiKey: formatApiKeys(value) }) - updateWebSearchProviderKey({ apiKey: formatApiKeys(value) }) - }, 150), - [] + const debouncedUpdateApiKey = useMemo( + () => + debounce((value: string) => { + updateProvider({ apiKey: formatApiKeys(value) }) + updateWebSearchProviderKey({ apiKey: formatApiKeys(value) }) + }, 150), + [updateProvider, updateWebSearchProviderKey] ) // 同步 provider.apiKey 到 localApiKey @@ -225,7 +231,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => { return } - const modelsToCheck = models.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model)) + const modelsToCheck = models.filter((model) => !isRerankModel(model)) if (isEmpty(modelsToCheck)) { window.toast.error({ @@ -259,13 +265,15 @@ const ProviderSetting: FC<Props> = ({ providerId }) => { }, 3000 ) - } catch (error: any) { + } catch (error: unknown) { window.toast.error({ timeout: 8000, title: i18n.t('message.api.connection.failed') }) - setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.FAILED, error: formatErrorMessage(error) })) + const serializedError = serializeHealthCheckError(error) + + setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.FAILED, error: serializedError })) } finally { setApiKeyConnectivity((prev) => ({ ...prev, checking: false })) } @@ -294,7 +302,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => { if (isAzureOpenAIProvider(provider)) { const apiVersion = provider.apiVersion || '' const path = !['preview', 'v1'].includes(apiVersion) - ? `/v1/chat/completion?apiVersion=v1` + ? `/v1/chat/completions?apiVersion=v1` : `/v1/responses?apiVersion=v1` return formattedApiHost + path } @@ -325,9 +333,21 @@ const ProviderSetting: FC<Props> = ({ providerId }) => { } return ( - <Tooltip title={<ErrorOverlay>{apiKeyConnectivity.error}</ErrorOverlay>}> - <TriangleAlert size={16} color="var(--color-status-warning)" /> - </Tooltip> + <> + <Tooltip title={apiKeyConnectivity.error?.message || t('settings.models.check.failed')}> + <TriangleAlert + size={16} + color="var(--color-status-warning)" + style={{ cursor: 'pointer' }} + onClick={() => setShowErrorModal(true)} + /> + </Tooltip> + <ErrorDetailModal + open={showErrorModal} + onClose={() => setShowErrorModal(false)} + error={apiKeyConnectivity.error} + /> + </> ) } @@ -397,7 +417,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => { <Button type="text" size="small" icon={<SquareArrowOutUpRight size={14} />} /> </Link> )} - {!isSystemProvider(provider) && ( + {(!isSystemProvider(provider) || isSupportAnthropicPromptCacheProvider(provider)) && ( <Tooltip title={t('settings.provider.api.options.label')}> <Button type="text" @@ -615,12 +635,4 @@ const ProviderName = styled.span` margin-right: -2px; ` -const ErrorOverlay = styled.div` - max-height: 200px; - overflow-y: auto; - max-width: 300px; - word-wrap: break-word; - user-select: text; -` - export default ProviderSetting diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index f5908becd4..afffcd2c68 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -8,8 +8,9 @@ import { isDedicatedImageGenerationModel, isEmbeddingModel, isFunctionCallingMod import { getStoreSetting } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' import store from '@renderer/store' +import { hubMCPServer } from '@renderer/store/mcp' import type { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types' -import { type FetchChatCompletionParams, isSystemProvider } from '@renderer/types' +import { type FetchChatCompletionParams, getEffectiveMcpMode, isSystemProvider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import { type Chunk, ChunkType } from '@renderer/types/chunk' import type { Message, ResponseError } from '@renderer/types/newMessage' @@ -51,14 +52,60 @@ import type { StreamProcessorCallbacks } from './StreamProcessingService' const logger = loggerService.withContext('ApiService') -export async function fetchMcpTools(assistant: Assistant) { - // Get MCP tools (Fix duplicate declaration) - let mcpTools: MCPTool[] = [] // Initialize as empty array +/** + * Get the MCP servers to use based on the assistant's MCP mode. + */ +export function getMcpServersForAssistant(assistant: Assistant): MCPServer[] { + const mode = getEffectiveMcpMode(assistant) const allMcpServers = store.getState().mcp.servers || [] const activedMcpServers = allMcpServers.filter((s) => s.isActive) - const assistantMcpServers = assistant.mcpServers || [] - const enabledMCPs = activedMcpServers.filter((server) => assistantMcpServers.some((s) => s.id === server.id)) + switch (mode) { + case 'disabled': + return [] + case 'auto': + return [hubMCPServer] + case 'manual': { + const assistantMcpServers = assistant.mcpServers || [] + return activedMcpServers.filter((server) => assistantMcpServers.some((s) => s.id === server.id)) + } + default: + return [] + } +} + +export async function fetchAllActiveServerTools(): Promise<MCPTool[]> { + const allMcpServers = store.getState().mcp.servers || [] + const activedMcpServers = allMcpServers.filter((s) => s.isActive) + + if (activedMcpServers.length === 0) { + return [] + } + + try { + const toolPromises = activedMcpServers.map(async (mcpServer: MCPServer) => { + try { + const tools = await window.api.mcp.listTools(mcpServer) + return tools.filter((tool: any) => !mcpServer.disabledTools?.includes(tool.name)) + } catch (error) { + logger.error(`Error fetching tools from MCP server ${mcpServer.name}:`, error as Error) + return [] + } + }) + const results = await Promise.allSettled(toolPromises) + return results + .filter((result): result is PromiseFulfilledResult<MCPTool[]> => result.status === 'fulfilled') + .map((result) => result.value) + .flat() + } catch (toolError) { + logger.error('Error fetching all active server tools:', toolError as Error) + return [] + } +} + +export async function fetchMcpTools(assistant: Assistant) { + let mcpTools: MCPTool[] = [] + const enabledMCPs = getMcpServersForAssistant(assistant) if (enabledMCPs && enabledMCPs.length > 0) { try { @@ -198,6 +245,7 @@ export async function fetchChatCompletion({ const usePromptToolUse = isPromptToolUse(assistant) || (isToolUseModeFunction(assistant) && !isFunctionCallingModel(assistant.model)) + const mcpMode = getEffectiveMcpMode(assistant) const middlewareConfig: AiSdkMiddlewareConfig = { streamOutput: assistant.settings?.streamOutput ?? true, onChunk: onChunkReceived, @@ -210,6 +258,7 @@ export async function fetchChatCompletion({ enableWebSearch: capabilities.enableWebSearch, enableGenerateImage: capabilities.enableGenerateImage, enableUrlContext: capabilities.enableUrlContext, + mcpMode, mcpTools, uiMessages, knowledgeRecognition: assistant.knowledgeRecognition @@ -601,6 +650,13 @@ export function checkApiProvider(provider: Provider): void { } } +/** + * Validates that a provider/model pair is working by sending a minimal request. + * @param provider - The provider configuration to test. + * @param model - The model to use for the validation request (chat or embeddings). + * @param timeout - Maximum time (ms) to wait for the request to complete. Defaults to 15000 ms. + * @throws {Error} If the request fails or times out, indicating the API is not usable. + */ export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise<void> { checkApiProvider(provider) @@ -611,7 +667,6 @@ export async function checkApi(provider: Provider, model: Model, timeout = 15000 assistant.prompt = 'test' // 避免部分 provider 空系统提示词会报错 if (isEmbeddingModel(model)) { - // race 超时 15s logger.silly("it's a embedding model") const timerPromise = new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout)) await Promise.race([ai.getEmbeddingDimensions(model), timerPromise]) diff --git a/src/renderer/src/services/HealthCheckService.ts b/src/renderer/src/services/HealthCheckService.ts index df814653f8..c3e707d3b1 100644 --- a/src/renderer/src/services/HealthCheckService.ts +++ b/src/renderer/src/services/HealthCheckService.ts @@ -2,7 +2,7 @@ import { loggerService } from '@logger' import type { Model, Provider } from '@renderer/types' import type { ApiKeyWithStatus, ModelCheckOptions, ModelWithStatus } from '@renderer/types/healthCheck' import { HealthStatus } from '@renderer/types/healthCheck' -import { formatErrorMessage } from '@renderer/utils/error' +import { serializeHealthCheckError } from '@renderer/utils/error' import { aggregateApiKeyResults } from '@renderer/utils/healthCheck' import { checkModel } from './ApiService' @@ -37,10 +37,12 @@ export async function checkModelWithMultipleKeys( if (result.status === 'fulfilled') { return result.value } else { + const serializedError = serializeHealthCheckError(result.reason) + return { key: apiKeys[index], // 对应失败的 promise 的 key status: HealthStatus.FAILED, - error: formatErrorMessage(result.reason) + error: serializedError } } }) diff --git a/src/renderer/src/services/KnowledgeService.ts b/src/renderer/src/services/KnowledgeService.ts index ce9577c68d..debda4bac7 100644 --- a/src/renderer/src/services/KnowledgeService.ts +++ b/src/renderer/src/services/KnowledgeService.ts @@ -30,6 +30,7 @@ import { isEmpty } from 'lodash' import { getProviderByModel } from './AssistantService' import FileManager from './FileManager' import type { BlockManager } from './messageStreaming' +import { estimateTextTokens } from './TokenService' const logger = loggerService.withContext('RendererKnowledgeService') @@ -146,6 +147,16 @@ export const searchKnowledgeBase = async ( parentSpanId?: string, modelName?: string ): Promise<Array<KnowledgeSearchResult & { file: FileMetadata | null }>> => { + // Truncate query based on embedding model's max_context to prevent embedding errors + const maxContext = getEmbeddingMaxContext(base.model.id) + if (maxContext) { + const estimatedTokens = estimateTextTokens(query) + if (estimatedTokens > maxContext) { + const ratio = maxContext / estimatedTokens + query = query.slice(0, Math.floor(query.length * ratio)) + } + } + let currentSpan: Span | undefined = undefined try { const baseParams = getKnowledgeBaseParams(base) diff --git a/src/renderer/src/services/LoggerService.ts b/src/renderer/src/services/LoggerService.ts index b0aa7f4b62..30eb1398dd 100644 --- a/src/renderer/src/services/LoggerService.ts +++ b/src/renderer/src/services/LoggerService.ts @@ -1,6 +1,7 @@ /* eslint-disable no-restricted-syntax */ import type { LogContextData, LogLevel, LogSourceWithContext } from '@shared/config/logger' import { LEVEL, LEVEL_MAP } from '@shared/config/logger' +import { IpcChannel } from '@shared/IpcChannel' // check if the current process is a worker const IS_WORKER = typeof window === 'undefined' @@ -113,9 +114,10 @@ class LoggerService { * @param data - Additional data to log */ private processLog(level: LogLevel, message: string, data: any[]): void { + let windowSource = this.window if (!this.window) { console.error('[LoggerService] window source not initialized, please initialize window source first') - return + windowSource = 'UNKNOWN' } const currentLevel = LEVEL_MAP[level] @@ -164,7 +166,7 @@ class LoggerService { if (currentLevel >= LEVEL_MAP[this.logToMainLevel] || forceLogToMain) { const source: LogSourceWithContext = { process: 'renderer', - window: this.window, + window: windowSource, module: this.module } @@ -179,7 +181,7 @@ class LoggerService { // In renderer process, use window.api.logToMain to send log to main process if (!IS_WORKER) { - window.api.logToMain(source, level, message, data) + window.electron.ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data) } else { //TODO support worker to send log to main process } diff --git a/src/renderer/src/services/StreamProcessingService.ts b/src/renderer/src/services/StreamProcessingService.ts index 7e80672d5d..bf54172ae9 100644 --- a/src/renderer/src/services/StreamProcessingService.ts +++ b/src/renderer/src/services/StreamProcessingService.ts @@ -1,5 +1,11 @@ import { loggerService } from '@logger' -import type { ExternalToolResult, GenerateImageResponse, MCPToolResponse, WebSearchResponse } from '@renderer/types' +import type { + ExternalToolResult, + GenerateImageResponse, + MCPToolResponse, + NormalToolResponse, + WebSearchResponse +} from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' import { ChunkType } from '@renderer/types/chunk' import type { Response } from '@renderer/types/newMessage' @@ -23,9 +29,11 @@ export interface StreamProcessorCallbacks { onThinkingChunk?: (text: string, thinking_millsec?: number) => void onThinkingComplete?: (text: string, thinking_millsec?: number) => void // A tool call response chunk (from MCP) - onToolCallPending?: (toolResponse: MCPToolResponse) => void - onToolCallInProgress?: (toolResponse: MCPToolResponse) => void - onToolCallComplete?: (toolResponse: MCPToolResponse) => void + onToolCallPending?: (toolResponse: MCPToolResponse | NormalToolResponse) => void + onToolCallInProgress?: (toolResponse: MCPToolResponse | NormalToolResponse) => void + onToolCallComplete?: (toolResponse: MCPToolResponse | NormalToolResponse) => void + // Tool argument streaming (partial arguments during streaming) + onToolArgumentStreaming?: (toolResponse: MCPToolResponse | NormalToolResponse) => void // External tool call in progress onExternalToolInProgress?: () => void // Citation data received (e.g., from Internet and Knowledge Base) @@ -109,6 +117,12 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {}) } break } + case ChunkType.MCP_TOOL_STREAMING: { + if (callbacks.onToolArgumentStreaming) { + data.responses.forEach((toolResp) => callbacks.onToolArgumentStreaming!(toolResp)) + } + break + } case ChunkType.EXTERNEL_TOOL_IN_PROGRESS: { if (callbacks.onExternalToolInProgress) callbacks.onExternalToolInProgress() break diff --git a/src/renderer/src/services/__tests__/mcpMode.test.ts b/src/renderer/src/services/__tests__/mcpMode.test.ts new file mode 100644 index 0000000000..9117caa666 --- /dev/null +++ b/src/renderer/src/services/__tests__/mcpMode.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import type { Assistant, MCPServer } from '../../types' +import { getEffectiveMcpMode } from '../../types' + +describe('getEffectiveMcpMode', () => { + it('should return mcpMode when explicitly set to auto', () => { + const assistant: Partial<Assistant> = { mcpMode: 'auto' } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('auto') + }) + + it('should return disabled when mcpMode is explicitly disabled', () => { + const assistant: Partial<Assistant> = { mcpMode: 'disabled' } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled') + }) + + it('should return manual when mcpMode is explicitly manual', () => { + const assistant: Partial<Assistant> = { mcpMode: 'manual' } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('manual') + }) + + it('should return manual when no mcpMode but mcpServers has items (backward compatibility)', () => { + const assistant: Partial<Assistant> = { + mcpServers: [{ id: 'test', name: 'Test Server', isActive: true }] as MCPServer[] + } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('manual') + }) + + it('should return disabled when no mcpMode and no mcpServers (backward compatibility)', () => { + const assistant: Partial<Assistant> = {} + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled') + }) + + it('should return disabled when no mcpMode and empty mcpServers (backward compatibility)', () => { + const assistant: Partial<Assistant> = { mcpServers: [] } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled') + }) + + it('should prioritize explicit mcpMode over mcpServers presence', () => { + const assistant: Partial<Assistant> = { + mcpMode: 'disabled', + mcpServers: [{ id: 'test', name: 'Test Server', isActive: true }] as MCPServer[] + } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled') + }) + + it('should return auto when mcpMode is auto regardless of mcpServers', () => { + const assistant: Partial<Assistant> = { + mcpMode: 'auto', + mcpServers: [{ id: 'test', name: 'Test Server', isActive: true }] as MCPServer[] + } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('auto') + }) +}) diff --git a/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts index 74d854d665..6261cdfd1d 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import type { AppDispatch } from '@renderer/store' import { toolPermissionsActions } from '@renderer/store/toolPermissions' -import type { MCPToolResponse } from '@renderer/types' +import type { MCPToolResponse, NormalToolResponse } from '@renderer/types' import { WebSearchSource } from '@renderer/types' import type { ToolMessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' @@ -11,6 +11,8 @@ import type { BlockManager } from '../BlockManager' const logger = loggerService.withContext('ToolCallbacks') +type ToolResponse = MCPToolResponse | NormalToolResponse + interface ToolCallbacksDependencies { blockManager: BlockManager assistantMsgId: string @@ -26,7 +28,7 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => { let citationBlockId: string | null = null return { - onToolCallPending: (toolResponse: MCPToolResponse) => { + onToolCallPending: (toolResponse: ToolResponse) => { logger.debug('onToolCallPending', toolResponse) if (blockManager.hasInitialPlaceholder) { @@ -55,7 +57,46 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => { } }, - onToolCallComplete: (toolResponse: MCPToolResponse) => { + onToolArgumentStreaming: (toolResponse: ToolResponse) => { + // Find or create the tool block for streaming updates + let existingBlockId = toolCallIdToBlockIdMap.get(toolResponse.id) + + if (!existingBlockId) { + // Create a new tool block if one doesn't exist yet + if (blockManager.hasInitialPlaceholder) { + const changes = { + type: MessageBlockType.TOOL, + status: MessageBlockStatus.PENDING, + toolName: toolResponse.tool.name, + metadata: { rawMcpToolResponse: toolResponse } + } + toolBlockId = blockManager.initialPlaceholderBlockId! + blockManager.smartBlockUpdate(toolBlockId, changes, MessageBlockType.TOOL) + toolCallIdToBlockIdMap.set(toolResponse.id, toolBlockId) + existingBlockId = toolBlockId + } else { + const toolBlock = createToolBlock(assistantMsgId, toolResponse.id, { + toolName: toolResponse.tool.name, + status: MessageBlockStatus.PENDING, + metadata: { rawMcpToolResponse: toolResponse } + }) + toolBlockId = toolBlock.id + blockManager.handleBlockTransition(toolBlock, MessageBlockType.TOOL) + toolCallIdToBlockIdMap.set(toolResponse.id, toolBlock.id) + existingBlockId = toolBlock.id + } + } + + // Update the tool block with streaming arguments + const changes: Partial<ToolMessageBlock> = { + status: MessageBlockStatus.PENDING, + metadata: { rawMcpToolResponse: toolResponse } + } + + blockManager.smartBlockUpdate(existingBlockId, changes, MessageBlockType.TOOL) + }, + + onToolCallComplete: (toolResponse: ToolResponse) => { if (toolResponse?.id) { dispatch(toolPermissionsActions.removeByToolCallId({ toolCallId: toolResponse.id })) } diff --git a/src/renderer/src/store/assistants.ts b/src/renderer/src/store/assistants.ts index aaac1810ab..02a39bfa93 100644 --- a/src/renderer/src/store/assistants.ts +++ b/src/renderer/src/store/assistants.ts @@ -43,6 +43,8 @@ const initialState: AssistantsState = { unifiedListOrder: [] } +const normalizeTopics = (topics: unknown): Topic[] => (Array.isArray(topics) ? topics : []) + const assistantsSlice = createSlice({ name: 'assistants', initialState, @@ -127,7 +129,7 @@ const assistantsSlice = createSlice({ assistant.id === action.payload.assistantId ? { ...assistant, - topics: uniqBy([topic, ...assistant.topics], 'id') + topics: uniqBy([topic, ...normalizeTopics(assistant.topics)], 'id') } : assistant ) @@ -137,7 +139,7 @@ const assistantsSlice = createSlice({ assistant.id === action.payload.assistantId ? { ...assistant, - topics: assistant.topics.filter(({ id }) => id !== action.payload.topic.id) + topics: normalizeTopics(assistant.topics).filter(({ id }) => id !== action.payload.topic.id) } : assistant ) @@ -149,7 +151,7 @@ const assistantsSlice = createSlice({ assistant.id === action.payload.assistantId ? { ...assistant, - topics: assistant.topics.map((topic) => { + topics: normalizeTopics(assistant.topics).map((topic) => { const _topic = topic.id === newTopic.id ? newTopic : topic _topic.messages = [] return _topic @@ -173,7 +175,7 @@ const assistantsSlice = createSlice({ removeAllTopics: (state, action: PayloadAction<{ assistantId: string }>) => { state.assistants = state.assistants.map((assistant) => { if (assistant.id === action.payload.assistantId) { - assistant.topics.forEach((topic) => TopicManager.removeTopic(topic.id)) + normalizeTopics(assistant.topics).forEach((topic) => TopicManager.removeTopic(topic.id)) return { ...assistant, topics: [getDefaultTopic(assistant.id)] @@ -184,7 +186,7 @@ const assistantsSlice = createSlice({ }, updateTopicUpdatedAt: (state, action: PayloadAction<{ topicId: string }>) => { outer: for (const assistant of state.assistants) { - for (const topic of assistant.topics) { + for (const topic of normalizeTopics(assistant.topics)) { if (topic.id === action.payload.topicId) { topic.updatedAt = new Date().toISOString() break outer @@ -268,7 +270,7 @@ export const { } = assistantsSlice.actions export const selectAllTopics = createSelector([(state: RootState) => state.assistants.assistants], (assistants) => - assistants.flatMap((assistant: Assistant) => assistant.topics) + assistants.flatMap((assistant: Assistant) => normalizeTopics(assistant.topics)) ) export const selectTopicsMap = createSelector([selectAllTopics], (topics) => { diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 4727bdc1e7..b507c332d8 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -83,7 +83,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 192, + version: 193, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index 3b94248401..0e2028a2b8 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -86,6 +86,28 @@ export { mcpSlice } // Export the reducer as default export export default mcpSlice.reducer +/** + * Hub MCP server for auto mode - aggregates all MCP servers for LLM code mode. + * This server is injected automatically when mcpMode === 'auto'. + */ +export const hubMCPServer: BuiltinMCPServer = { + id: 'hub', + name: BuiltinMCPServerNames.hub, + type: 'inMemory', + isActive: true, + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true +} + +/** + * User-installable built-in MCP servers shown in the UI. + * + * Note: The `hub` server (@cherry/hub) is intentionally excluded because: + * - It's a meta-server that aggregates all other MCP servers + * - It's designed for LLM code mode, not direct user interaction + * - It should be auto-enabled internally when needed, not manually installed + */ export const builtinMCPServers: BuiltinMCPServer[] = [ { id: nanoid(), diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 8719cdb7c1..bc0b157934 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -22,7 +22,7 @@ import { DEFAULT_TEMPERATURE, isMac } from '@renderer/config/constant' -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { allMinApps } from '@renderer/config/minapps' import { glm45FlashModel, isFunctionCallingModel, @@ -99,7 +99,7 @@ function removeMiniAppFromState(state: RootState, id: string) { function addMiniApp(state: RootState, id: string) { if (state.minapps) { - const app = DEFAULT_MIN_APPS.find((app) => app.id === id) + const app = allMinApps.find((app) => app.id === id) if (app) { if (!state.minapps.enabled.find((app) => app.id === id)) { state.minapps.enabled.push(app) @@ -1076,7 +1076,7 @@ const migrateConfig = { if (state.minapps) { appIds.forEach((id) => { - const app = DEFAULT_MIN_APPS.find((app) => app.id === id) + const app = allMinApps.find((app) => app.id === id) if (app) { state.minapps.enabled.push(app) } @@ -3159,6 +3159,16 @@ const migrateConfig = { logger.error('migrate 192 error', error as Error) return state } + }, + '193': (state: RootState) => { + try { + addPreprocessProviders(state, 'paddleocr') + logger.info('migrate 193 success') + return state + } catch (error) { + logger.error('migrate 193 error', error as Error) + return state + } } } diff --git a/src/renderer/src/store/minapps.ts b/src/renderer/src/store/minapps.ts index ac2a83440b..749be9c1d3 100644 --- a/src/renderer/src/store/minapps.ts +++ b/src/renderer/src/store/minapps.ts @@ -16,7 +16,7 @@ */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { allMinApps } from '@renderer/config/minapps' import type { MinAppType } from '@renderer/types' export interface MinAppsState { @@ -26,7 +26,7 @@ export interface MinAppsState { } const initialState: MinAppsState = { - enabled: DEFAULT_MIN_APPS, + enabled: allMinApps, disabled: [], pinned: [] } diff --git a/src/renderer/src/store/preprocess.ts b/src/renderer/src/store/preprocess.ts index 8fee31b0ef..59dd7413dc 100644 --- a/src/renderer/src/store/preprocess.ts +++ b/src/renderer/src/store/preprocess.ts @@ -49,6 +49,12 @@ const initialState: PreprocessState = { name: 'Open MinerU', apiKey: '', apiHost: '' + }, + { + id: 'paddleocr', + name: 'PaddleOCR', + apiKey: '', + apiHost: '' } ], defaultProvider: 'mineru' diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 3ba3cc4da8..01460ba035 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -198,6 +198,8 @@ export interface SettingsState { enableQuickPanelTriggers: boolean // 硬件加速设置 disableHardwareAcceleration: boolean + // 使用系统标题栏 (仅Linux) + useSystemTitleBar: boolean exportMenuOptions: { image: boolean markdown: boolean @@ -383,6 +385,8 @@ export const initialState: SettingsState = { confirmRegenerateMessage: true, // 硬件加速设置 disableHardwareAcceleration: false, + // 使用系统标题栏 (仅Linux) + useSystemTitleBar: false, exportMenuOptions: { image: true, markdown: true, @@ -818,6 +822,9 @@ const settingsSlice = createSlice({ setDisableHardwareAcceleration: (state, action: PayloadAction<boolean>) => { state.disableHardwareAcceleration = action.payload }, + setUseSystemTitleBar: (state, action: PayloadAction<boolean>) => { + state.useSystemTitleBar = action.payload + }, setOpenAISummaryText: (state, action: PayloadAction<OpenAIReasoningSummary>) => { state.openAI.summaryText = action.payload }, @@ -998,6 +1005,7 @@ export const { setConfirmDeleteMessage, setConfirmRegenerateMessage, setDisableHardwareAcceleration, + setUseSystemTitleBar, setOpenAISummaryText, setOpenAIVerbosity, setOpenAIStreamOptionsIncludeUsage, diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index aaa2ffc2c4..39e44c2efb 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -823,6 +823,7 @@ const fetchAndProcessAssistantResponseImpl = async ( const streamProcessorCallbacks = createStreamProcessor(callbacks) const abortController = new AbortController() + logger.silly('Add Abort Controller', { id: userMessageId }) addAbortController(userMessageId!, () => abortController.abort()) await transformMessagesAndFetch( diff --git a/src/renderer/src/trace/pages/index.tsx b/src/renderer/src/trace/pages/index.tsx index f83d160df9..26b5567e5d 100644 --- a/src/renderer/src/trace/pages/index.tsx +++ b/src/renderer/src/trace/pages/index.tsx @@ -158,7 +158,7 @@ export const TracePage: React.FC<TracePageProp> = ({ topicId, traceId, modelName {showList ? ( <VStack gap={1} align="start"> {spans.length === 0 ? ( - <Text>没有找到Trace信息</Text> + <Text>{t('trace.noTraceList')}</Text> ) : ( <> <SimpleGrid columns={20} style={{ width: '100%' }} className="floating"> diff --git a/src/renderer/src/types/chunk.ts b/src/renderer/src/types/chunk.ts index 345d8a385c..c6e79fe337 100644 --- a/src/renderer/src/types/chunk.ts +++ b/src/renderer/src/types/chunk.ts @@ -24,6 +24,7 @@ export enum ChunkType { MCP_TOOL_PENDING = 'mcp_tool_pending', MCP_TOOL_IN_PROGRESS = 'mcp_tool_in_progress', MCP_TOOL_COMPLETE = 'mcp_tool_complete', + MCP_TOOL_STREAMING = 'mcp_tool_streaming', // NEW: Streaming tool arguments EXTERNEL_TOOL_COMPLETE = 'externel_tool_complete', LLM_RESPONSE_CREATED = 'llm_response_created', LLM_RESPONSE_IN_PROGRESS = 'llm_response_in_progress', @@ -329,6 +330,20 @@ export interface MCPToolCompleteChunk { type: ChunkType.MCP_TOOL_COMPLETE } +/** + * Streaming tool arguments chunk - emitted during tool-input-delta events + */ +export interface MCPToolStreamingChunk { + /** + * The type of the chunk + */ + type: ChunkType.MCP_TOOL_STREAMING + /** + * The tool responses with streaming arguments + */ + responses: (MCPToolResponse | NormalToolResponse)[] +} + export interface LLMResponseCompleteChunk { /** * The response @@ -438,6 +453,7 @@ export type Chunk = | MCPToolPendingChunk // MCP工具调用等待中 | MCPToolInProgressChunk // MCP工具调用中 | MCPToolCompleteChunk // MCP工具调用完成 + | MCPToolStreamingChunk // MCP工具参数流式传输中 | ExternalToolCompleteChunk // 外部工具调用完成,外部工具包含搜索互联网,知识库,MCP服务器 | LLMResponseCreatedChunk // 大模型响应创建,返回即将创建的块类型 | LLMResponseInProgressChunk // 大模型响应进行中 diff --git a/src/renderer/src/types/healthCheck.ts b/src/renderer/src/types/healthCheck.ts index 6cf9a29303..fcd2d2c337 100644 --- a/src/renderer/src/types/healthCheck.ts +++ b/src/renderer/src/types/healthCheck.ts @@ -1,9 +1,11 @@ import type { Model, Provider } from '@types' +import type { SerializedError } from './error' + /** * 健康检查的通用状态枚举 - * - SUCCESS: 用于表达“所有都成功” - * - FAILED: 用于表达“至少一个失败” + * - SUCCESS: 用于表达"所有都成功" + * - FAILED: 用于表达"至少一个失败" */ export enum HealthStatus { SUCCESS = 'success', @@ -17,7 +19,7 @@ export enum HealthStatus { export interface ApiKeyConnectivity { status: HealthStatus checking?: boolean - error?: string + error?: SerializedError model?: Model latency?: number } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index f266dcb535..26f6ec035a 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -27,6 +27,8 @@ export * from './ocr' export * from './plugin' export * from './provider' +export type McpMode = 'disabled' | 'auto' | 'manual' + export type Assistant = { id: string name: string @@ -47,6 +49,8 @@ export type Assistant = { // enableUrlContext 是 Gemini/Anthropic 的特有功能 enableUrlContext?: boolean enableGenerateImage?: boolean + /** MCP mode: 'disabled' (no MCP), 'auto' (hub server only), 'manual' (user selects servers) */ + mcpMode?: McpMode mcpServers?: MCPServer[] knowledgeRecognition?: 'off' | 'on' regularPhrases?: QuickPhrase[] // Added for regular phrase @@ -57,6 +61,15 @@ export type Assistant = { targetLanguage?: TranslateLanguage } +/** + * Get the effective MCP mode for an assistant with backward compatibility. + * Legacy assistants without mcpMode default based on mcpServers presence. + */ +export function getEffectiveMcpMode(assistant: Assistant): McpMode { + if (assistant.mcpMode) return assistant.mcpMode + return (assistant.mcpServers?.length ?? 0) > 0 ? 'manual' : 'disabled' +} + export type TranslateAssistant = Assistant & { model: Model content: string @@ -108,9 +121,26 @@ const ThinkModelTypes = [ 'hunyuan', 'zhipu', 'perplexity', - 'deepseek_hybrid' + 'deepseek_hybrid', + 'kimi_k2_5' ] as const +/** If the model's reasoning effort could be controlled, or its reasoning behavior could be turned on/off. + * It's basically based on OpenAI's reasoning effort, but we have adapted it for other models. + * + * Possible options: + * - 'none': Disable reasoning for the model. (inherit from OpenAI) + * It's also used as "off" when the reasoning behavior of the model only could be set to "on" and "off". + * - 'minimal': Enable minimal reasoning effort for the model. (inherit from OpenAI, only for few models, such as GPT-5.) + * - 'low': Enable low reasoning effort for the model. (inherit from OpenAI) + * - 'medium': Enable medium reasoning effort for the model. (inherit from OpenAI) + * - 'high': Enable high reasoning effort for the model. (inherit from OpenAI) + * - 'xhigh': Enable extra high reasoning effort for the model. (inherit from OpenAI) + * - 'auto': Automatically determine the reasoning effort based on the model's capabilities. + * For some providers, it's same with 'default'. + * It's also used as "on" when the reasoning behavior of the model only could be set to "on" and "off". + * - 'default': Depend on default behavior. It means we would not set any reasoning related settings when calling API. + */ export type ReasoningEffortOption = NonNullable<OpenAI.ReasoningEffort> | 'auto' | 'default' export type ThinkingOption = ReasoningEffortOption export type ThinkingModelType = (typeof ThinkModelTypes)[number] @@ -446,6 +476,10 @@ export interface PaintingsState { export type MinAppType = { id: string name: string + /** i18n key for translatable names */ + nameKey?: string + /** Locale codes where this app should be visible (e.g., ['zh-CN', 'zh-TW']) */ + locales?: LanguageVarious[] logo?: string url: string // FIXME: It should be `bordered` @@ -757,7 +791,8 @@ export const BuiltinMCPServerNames = { python: '@cherry/python', didiMCP: '@cherry/didi-mcp', browser: '@cherry/browser', - nowledgeMem: '@cherry/nowledge-mem' + nowledgeMem: '@cherry/nowledge-mem', + hub: '@cherry/hub' } as const export type BuiltinMCPServerName = (typeof BuiltinMCPServerNames)[keyof typeof BuiltinMCPServerNames] @@ -802,7 +837,7 @@ export interface MCPConfig { isBunInstalled: boolean } -export type MCPToolResponseStatus = 'pending' | 'cancelled' | 'invoking' | 'done' | 'error' +export type MCPToolResponseStatus = 'pending' | 'streaming' | 'cancelled' | 'invoking' | 'done' | 'error' interface BaseToolResponse { id: string // unique id @@ -810,6 +845,8 @@ interface BaseToolResponse { arguments: Record<string, unknown> | Record<string, unknown>[] | string | undefined status: MCPToolResponseStatus response?: any + // Streaming arguments support + partialArguments?: string // Accumulated partial JSON string during streaming } export interface ToolUseResponse extends BaseToolResponse { @@ -826,11 +863,13 @@ export interface MCPToolResponse extends Omit<ToolUseResponse | ToolCallResponse tool: MCPTool toolCallId?: string toolUseId?: string + parentToolUseId?: string } export interface NormalToolResponse extends Omit<ToolCallResponse, 'tool'> { tool: BaseTool toolCallId: string + parentToolUseId?: string } export interface MCPToolResultContent { diff --git a/src/renderer/src/types/knowledge.ts b/src/renderer/src/types/knowledge.ts index b584c31b8f..1685b09529 100644 --- a/src/renderer/src/types/knowledge.ts +++ b/src/renderer/src/types/knowledge.ts @@ -108,7 +108,8 @@ export const PreprocessProviderIds = { doc2x: 'doc2x', mistral: 'mistral', mineru: 'mineru', - 'open-mineru': 'open-mineru' + 'open-mineru': 'open-mineru', + paddleocr: 'paddleocr' } as const export type PreprocessProviderId = keyof typeof PreprocessProviderIds @@ -156,3 +157,7 @@ export interface KnowledgeSearchResult { score: number metadata: Record<string, any> } + +export interface PreprocessReadPdfResult { + numPages: number +} diff --git a/src/renderer/src/types/mcp.ts b/src/renderer/src/types/mcp.ts index 48f48a6167..e16ebb2e04 100644 --- a/src/renderer/src/types/mcp.ts +++ b/src/renderer/src/types/mcp.ts @@ -123,7 +123,15 @@ export const McpServerConfigSchema = z * 请求超时时间 * 可选。单位为秒,默认为60秒。 */ - timeout: z.number().optional().describe('Timeout in seconds for requests to this server'), + timeout: z + .preprocess((val) => { + if (typeof val === 'string' && val.trim() !== '') { + const parsed = Number(val) + return isNaN(parsed) ? val : parsed + } + return val + }, z.number().optional()) + .describe('Timeout in seconds for requests to this server'), /** * DXT包版本号 * 可选。用于标识DXT包的版本。 diff --git a/src/renderer/src/types/provider.ts b/src/renderer/src/types/provider.ts index edab3a7305..182c25424b 100644 --- a/src/renderer/src/types/provider.ts +++ b/src/renderer/src/types/provider.ts @@ -79,6 +79,12 @@ export function isGroqServiceTier(tier: string | undefined | null): tier is Groq export type ServiceTier = OpenAIServiceTier | GroqServiceTier +export type AnthropicCacheControlSettings = { + tokenThreshold: number + cacheSystemMessage: boolean + cacheLastNMessages: number +} + export function isServiceTier(tier: string | null | undefined): tier is ServiceTier { return isGroqServiceTier(tier) || isOpenAIServiceTier(tier) } @@ -127,6 +133,9 @@ export type Provider = { isVertex?: boolean notes?: string extra_headers?: Record<string, string> + + // Anthropic prompt caching settings + anthropicCacheControl?: AnthropicCacheControlSettings } export const SystemProviderIdSchema = z.enum([ diff --git a/src/renderer/src/utils/__tests__/export.test.ts b/src/renderer/src/utils/__tests__/export.test.ts index c7a89207e9..1c53d07c4b 100644 --- a/src/renderer/src/utils/__tests__/export.test.ts +++ b/src/renderer/src/utils/__tests__/export.test.ts @@ -9,9 +9,9 @@ import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' vi.mock('@renderer/config/minapps', () => { return { ORIGIN_DEFAULT_MIN_APPS: [], - DEFAULT_MIN_APPS: [], + allMinApps: [], loadCustomMiniApp: async () => [], - updateDefaultMinApps: vi.fn() + updateAllMinApps: vi.fn() } }) diff --git a/src/renderer/src/utils/__tests__/naming.test.ts b/src/renderer/src/utils/__tests__/naming.test.ts index 8fb6bc2b74..1a196ed189 100644 --- a/src/renderer/src/utils/__tests__/naming.test.ts +++ b/src/renderer/src/utils/__tests__/naming.test.ts @@ -225,6 +225,9 @@ describe('naming', () => { it('should remove trailing (free)', () => { expect(getLowerBaseModelName('agent/gpt-4(free)')).toBe('gpt-4') }) + it('should remove trailing :cloud', () => { + expect(getLowerBaseModelName('local/kimi-k2.5:cloud')).toBe('kimi-k2.5') + }) }) describe('getFirstCharacter', () => { diff --git a/src/renderer/src/utils/error.ts b/src/renderer/src/utils/error.ts index ec2e15f6d8..28f94c3843 100644 --- a/src/renderer/src/utils/error.ts +++ b/src/renderer/src/utils/error.ts @@ -11,6 +11,7 @@ import type { } from '@renderer/types/error' import { isSerializedAiSdkAPICallError } from '@renderer/types/error' import type { NoSuchToolError } from 'ai' +import { AISDKError } from 'ai' import { InvalidToolInputError } from 'ai' import type { AxiosError } from 'axios' import { isAxiosError } from 'axios' @@ -333,3 +334,18 @@ export const formatAxiosError = (error: AxiosError) => { return `${t('common.error')}: ${status} ${statusText}` } + +/** + * Safely serialize an unknown error to SerializedError format. + * Used specifically for health check error handling. + */ +export function serializeHealthCheckError(error: unknown): SerializedError { + if (AISDKError.isInstance(error)) { + return serializeError(error) + } + return { + name: null, + message: safeToString(error), + stack: null + } +} diff --git a/src/renderer/src/utils/mcp-tools.ts b/src/renderer/src/utils/mcp-tools.ts index 691689dcc4..3bc4a273cb 100644 --- a/src/renderer/src/utils/mcp-tools.ts +++ b/src/renderer/src/utils/mcp-tools.ts @@ -13,7 +13,7 @@ import { isFunctionCallingModel, isVisionModel } from '@renderer/config/models' import i18n from '@renderer/i18n' import { currentSpan } from '@renderer/services/SpanManagerService' import store from '@renderer/store' -import { addMCPServer } from '@renderer/store/mcp' +import { addMCPServer, hubMCPServer } from '@renderer/store/mcp' import type { Assistant, MCPCallToolResponse, @@ -325,7 +325,16 @@ export function filterMCPTools( export function getMcpServerByTool(tool: MCPTool) { const servers = store.getState().mcp.servers - return servers.find((s) => s.id === tool.serverId) + const server = servers.find((s) => s.id === tool.serverId) + if (server) { + return server + } + // For hub server (auto mode), the server isn't in the store + // Return the hub server constant if the tool's serverId matches + if (tool.serverId === 'hub') { + return hubMCPServer + } + return undefined } export function isToolAutoApproved(tool: MCPTool, server?: MCPServer): boolean { diff --git a/src/renderer/src/utils/naming.ts b/src/renderer/src/utils/naming.ts index 2ebd9c1fc3..dee41a137e 100644 --- a/src/renderer/src/utils/naming.ts +++ b/src/renderer/src/utils/naming.ts @@ -74,14 +74,19 @@ export const getBaseModelName = (id: string, delimiter: string = '/'): string => * @returns {string} 小写的基础名称 */ export const getLowerBaseModelName = (id: string, delimiter: string = '/'): string => { - const baseModelName = getBaseModelName(id, delimiter).toLowerCase() + let baseModelName = getBaseModelName(id, delimiter).toLowerCase() + // Remove suffix // for openrouter if (baseModelName.endsWith(':free')) { - return baseModelName.replace(':free', '') + baseModelName = baseModelName.replace(':free', '') } // for cherryin if (baseModelName.endsWith('(free)')) { - return baseModelName.replace('(free)', '') + baseModelName = baseModelName.replace('(free)', '') + } + // for ollama + if (baseModelName.endsWith(':cloud')) { + baseModelName = baseModelName.replace(':cloud', '') } return baseModelName } diff --git a/src/renderer/src/utils/prompt.ts b/src/renderer/src/utils/prompt.ts index 4e799800a7..326392947a 100644 --- a/src/renderer/src/utils/prompt.ts +++ b/src/renderer/src/utils/prompt.ts @@ -14,7 +14,7 @@ Here are a few examples using notional tools: --- User: Generate an image of the oldest person in this document. -Assistant: I can use the document_qa tool to find out who the oldest person is in the document. +A: I can use the document_qa tool to find out who the oldest person is in the document. <tool_use> <name>document_qa</name> <arguments>{"document": "document.pdf", "question": "Who is the oldest person mentioned?"}</arguments> @@ -25,7 +25,7 @@ User: <tool_use_result> <result>John Doe, a 55 year old lumberjack living in Newfoundland.</result> </tool_use_result> -Assistant: I can use the image_generator tool to create a portrait of John Doe. +A: I can use the image_generator tool to create a portrait of John Doe. <tool_use> <name>image_generator</name> <arguments>{"prompt": "A portrait of John Doe, a 55-year-old man living in Canada."}</arguments> @@ -36,12 +36,12 @@ User: <tool_use_result> <result>image.png</result> </tool_use_result> -Assistant: the image is generated as image.png +A: the image is generated as image.png --- User: "What is the result of the following operation: 5 + 3 + 1294.678?" -Assistant: I can use the python_interpreter tool to calculate the result of the operation. +A: I can use the python_interpreter tool to calculate the result of the operation. <tool_use> <name>python_interpreter</name> <arguments>{"code": "5 + 3 + 1294.678"}</arguments> @@ -52,12 +52,12 @@ User: <tool_use_result> <result>1302.678</result> </tool_use_result> -Assistant: The result of the operation is 1302.678. +A: The result of the operation is 1302.678. --- User: "Which city has the highest population , Guangzhou or Shanghai?" -Assistant: I can use the search tool to find the population of Guangzhou. +A: I can use the search tool to find the population of Guangzhou. <tool_use> <name>search</name> <arguments>{"query": "Population Guangzhou"}</arguments> @@ -68,7 +68,7 @@ User: <tool_use_result> <result>Guangzhou has a population of 15 million inhabitants as of 2021.</result> </tool_use_result> -Assistant: I can use the search tool to find the population of Shanghai. +A: I can use the search tool to find the population of Shanghai. <tool_use> <name>search</name> <arguments>{"query": "Population Shanghai"}</arguments> @@ -78,7 +78,8 @@ User: <tool_use_result> <name>search</name> <result>26 million (2019)</result> </tool_use_result> -Assistant: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population. + +A: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population. ` export const AvailableTools = (tools: MCPTool[]) => { diff --git a/src/renderer/src/utils/provider.ts b/src/renderer/src/utils/provider.ts index 86544de990..0f862b054f 100644 --- a/src/renderer/src/utils/provider.ts +++ b/src/renderer/src/utils/provider.ts @@ -198,3 +198,13 @@ export const NOT_SUPPORT_API_KEY_PROVIDERS: readonly SystemProviderId[] = [ ] export const NOT_SUPPORT_API_KEY_PROVIDER_TYPES: readonly ProviderType[] = ['vertexai', 'aws-bedrock'] + +// https://platform.claude.com/docs/en/build-with-claude/prompt-caching#1-hour-cache-duration +export const isSupportAnthropicPromptCacheProvider = (provider: Provider) => { + return ( + provider.type === 'anthropic' || + isNewApiProvider(provider) || + provider.id === SystemProviderIds.aihubmix || + isAzureOpenAIProvider(provider) + ) +} diff --git a/src/renderer/src/windows/selection/action/components/ActionGeneral.tsx b/src/renderer/src/windows/selection/action/components/ActionGeneral.tsx index 9dd2fbc4f5..44cdaaa8f5 100644 --- a/src/renderer/src/windows/selection/action/components/ActionGeneral.tsx +++ b/src/renderer/src/windows/selection/action/components/ActionGeneral.tsx @@ -12,6 +12,7 @@ import { } from '@renderer/services/AssistantService' import { pauseTrace } from '@renderer/services/SpanManagerService' import type { Assistant, Topic } from '@renderer/types' +import { AssistantMessageStatus } from '@renderer/types/newMessage' import type { ActionItem } from '@renderer/types/selectionTypes' import { abortCompletion } from '@renderer/utils/abortController' import { ChevronDown } from 'lucide-react' @@ -34,8 +35,7 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => { const { language } = useSettings() const [error, setError] = useState<string | null>(null) const [showOriginal, setShowOriginal] = useState(false) - const [isContented, setIsContented] = useState(false) - const [isLoading, setIsLoading] = useState(true) + const [status, setStatus] = useState<'preparing' | 'streaming' | 'finished'>('preparing') const [contentToCopy, setContentToCopy] = useState('') const initialized = useRef(false) @@ -67,17 +67,13 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => { let userContent = '' switch (action.id) { case 'summary': - userContent = - `请总结下面的内容。要求:使用 ${language} 语言进行回复;请不要包含对本提示词的任何解释,直接给出回复: \n\n` + - action.selectedText + userContent = t('selection.action.prompt.summary', { language }) + action.selectedText break case 'explain': - userContent = - `请解释下面的内容。要求:使用 ${language} 语言进行回复;请不要包含对本提示词的任何解释,直接给出回复: \n\n` + - action.selectedText + userContent = t('selection.action.prompt.explain', { language }) + action.selectedText break case 'refine': - userContent = `请对用XML标签<INPUT>包裹的用户输入内容进行优化或润色,并保持原内容的含义和完整性。要求:你的输出应当与用户输入内容的语言相同。;请不要包含对本提示词的任何解释,直接给出回复;请不要输出XML标签,直接输出优化后的内容: \n\n<INPUT>${action.selectedText ?? ''}</INPUT>` + userContent = t('selection.action.prompt.refine', { text: action.selectedText ?? '' }) break default: if (!action.prompt) { @@ -93,22 +89,27 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => { userContent = action.prompt + '\n\n' + action.selectedText } promptContentRef.current = userContent - }, [action, language]) + }, [action, language, t]) const fetchResult = useCallback(() => { + if (!initialized.current) { + return + } + setStatus('preparing') + const setAskId = (id: string) => { askId.current = id } const onStream = () => { - setIsContented(true) + setStatus('streaming') scrollToBottom?.() } const onFinish = (content: string) => { + setStatus('finished') setContentToCopy(content) - setIsLoading(false) } const onError = (error: Error) => { - setIsLoading(false) + setStatus('finished') setError(error.message) } @@ -131,17 +132,40 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => { const allMessages = useTopicMessages(topicRef.current?.id || '') - // Memoize the messages to prevent unnecessary re-renders - const messageContent = useMemo(() => { + const currentAssistantMessage = useMemo(() => { const assistantMessages = allMessages.filter((message) => message.role === 'assistant') - const lastAssistantMessage = assistantMessages[assistantMessages.length - 1] - return lastAssistantMessage ? <MessageContent key={lastAssistantMessage.id} message={lastAssistantMessage} /> : null + if (assistantMessages.length === 0) { + return null + } + return assistantMessages[assistantMessages.length - 1] }, [allMessages]) + useEffect(() => { + // Sync message status + switch (currentAssistantMessage?.status) { + case AssistantMessageStatus.PROCESSING: + case AssistantMessageStatus.PENDING: + case AssistantMessageStatus.SEARCHING: + setStatus('streaming') + break + case AssistantMessageStatus.PAUSED: + case AssistantMessageStatus.ERROR: + case AssistantMessageStatus.SUCCESS: + setStatus('finished') + break + case undefined: + break + default: + logger.warn('Unexpected assistant message status:', { status: currentAssistantMessage?.status }) + } + }, [currentAssistantMessage?.status]) + + const isPreparing = status === 'preparing' + const isStreaming = status === 'streaming' + const handlePause = () => { if (askId.current) { abortCompletion(askId.current) - setIsLoading(false) } if (topicRef.current?.id) { pauseTrace(topicRef.current.id) @@ -150,7 +174,6 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => { const handleRegenerate = () => { setContentToCopy('') - setIsLoading(true) fetchResult() } @@ -178,13 +201,20 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => { </OriginalContent> )} <Result> - {!isContented && isLoading && <LoadingOutlined style={{ fontSize: 16 }} spin />} - {messageContent} + {isPreparing && <LoadingOutlined style={{ fontSize: 16 }} spin />} + {!isPreparing && currentAssistantMessage && ( + <MessageContent key={currentAssistantMessage.id} message={currentAssistantMessage} /> + )} </Result> {error && <ErrorMsg>{error}</ErrorMsg>} </Container> <FooterPadding /> - <WindowFooter loading={isLoading} onPause={handlePause} onRegenerate={handleRegenerate} content={contentToCopy} /> + <WindowFooter + loading={isStreaming} + onPause={handlePause} + onRegenerate={handleRegenerate} + content={contentToCopy} + /> </> ) }) diff --git a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx index b5e0fea689..9bd1eb0803 100644 --- a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx +++ b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx @@ -9,16 +9,18 @@ import { useSettings } from '@renderer/hooks/useSettings' import useTranslate from '@renderer/hooks/useTranslate' import MessageContent from '@renderer/pages/home/Messages/MessageContent' import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService' +import { pauseTrace } from '@renderer/services/SpanManagerService' import type { Assistant, Topic, TranslateLanguage, TranslateLanguageCode } from '@renderer/types' +import { AssistantMessageStatus } from '@renderer/types/newMessage' import type { ActionItem } from '@renderer/types/selectionTypes' import { abortCompletion } from '@renderer/utils/abortController' import { detectLanguage } from '@renderer/utils/translate' -import { Tooltip } from 'antd' -import { ArrowRightFromLine, ArrowRightToLine, ChevronDown, CircleHelp, Globe } from 'lucide-react' +import { Dropdown, Tooltip } from 'antd' +import { ArrowRight, ChevronDown, CircleHelp, Settings2 } from 'lucide-react' import type { FC } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' +import styled, { createGlobalStyle } from 'styled-components' import { processMessages } from './ActionUtils' import WindowFooter from './WindowFooter' @@ -45,15 +47,17 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => { }) const [alterLanguage, setAlterLanguage] = useState<TranslateLanguage>(LanguagesEnum.enUS) + const [detectedLanguage, setDetectedLanguage] = useState<TranslateLanguage | null>(null) + const [actualTargetLanguage, setActualTargetLanguage] = useState<TranslateLanguage>(targetLanguage) const [error, setError] = useState('') const [showOriginal, setShowOriginal] = useState(false) - const [isContented, setIsContented] = useState(false) - const [isLoading, setIsLoading] = useState(true) + const [status, setStatus] = useState<'preparing' | 'streaming' | 'finished'>('preparing') const [contentToCopy, setContentToCopy] = useState('') + const [initialized, setInitialized] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) // Use useRef for values that shouldn't trigger re-renders - const initialized = useRef(false) const assistantRef = useRef<Assistant | null>(null) const topicRef = useRef<Topic | null>(null) const askId = useRef('') @@ -85,7 +89,7 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => { // Initialize values only once const initialize = useCallback(async () => { - if (initialized.current) { + if (initialized) { logger.silly('[initialize] Already initialized.') return } @@ -106,6 +110,7 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => { // Initialize language pair. // It will update targetLangRef, so we could get latest target language in the following code await updateLanguagePair() + logger.silly('[initialize] UpdateLanguagePair completed.') // Initialize assistant const currentAssistant = getDefaultTranslateAssistant(targetLangRef.current, action.selectedText) @@ -114,8 +119,8 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => { // Initialize topic topicRef.current = getDefaultTopic(currentAssistant.id) - initialized.current = true - }, [action.selectedText, isLanguagesLoaded, updateLanguagePair]) + setInitialized(true) + }, [action.selectedText, initialized, isLanguagesLoaded, updateLanguagePair]) // Try to initialize when: // 1. action.selectedText change (generally will not) @@ -126,26 +131,24 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => { }, [initialize]) const fetchResult = useCallback(async () => { - if (!assistantRef.current || !topicRef.current || !action.selectedText || !initialized.current) return + if (!assistantRef.current || !topicRef.current || !action.selectedText || !initialized) return const setAskId = (id: string) => { askId.current = id } const onStream = () => { - setIsContented(true) + setStatus('streaming') scrollToBottom?.() } const onFinish = (content: string) => { + setStatus('finished') setContentToCopy(content) - setIsLoading(false) } const onError = (error: Error) => { - setIsLoading(false) + setStatus('finished') setError(error.message) } - setIsLoading(true) - let sourceLanguageCode: TranslateLanguageCode try { @@ -156,6 +159,10 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => { return } + // Set detected language for UI display + const detectedLang = getLanguageByLangcode(sourceLanguageCode) + setDetectedLanguage(detectedLang) + let translateLang: TranslateLanguage if (sourceLanguageCode === UNKNOWN.langCode) { @@ -170,11 +177,14 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => { } } + // Set actual target language for UI display + setActualTargetLanguage(translateLang) + const assistant = getDefaultTranslateAssistant(translateLang, action.selectedText) assistantRef.current = assistant logger.debug('process once') processMessages(assistant, topicRef.current, assistant.content, setAskId, onStream, onFinish, onError) - }, [action, targetLanguage, alterLanguage, scrollToBottom]) + }, [action, targetLanguage, alterLanguage, scrollToBottom, initialized, getLanguageByLangcode]) useEffect(() => { fetchResult() @@ -182,71 +192,190 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => { const allMessages = useTopicMessages(topicRef.current?.id || '') - const messageContent = useMemo(() => { + const currentAssistantMessage = useMemo(() => { const assistantMessages = allMessages.filter((message) => message.role === 'assistant') - const lastAssistantMessage = assistantMessages[assistantMessages.length - 1] - return lastAssistantMessage ? <MessageContent key={lastAssistantMessage.id} message={lastAssistantMessage} /> : null + if (assistantMessages.length === 0) { + return null + } + return assistantMessages[assistantMessages.length - 1] }, [allMessages]) - const handleChangeLanguage = (targetLanguage: TranslateLanguage, alterLanguage: TranslateLanguage) => { - if (!initialized.current) { - return + useEffect(() => { + // Sync message status + switch (currentAssistantMessage?.status) { + case AssistantMessageStatus.PROCESSING: + case AssistantMessageStatus.PENDING: + case AssistantMessageStatus.SEARCHING: + setStatus('streaming') + break + case AssistantMessageStatus.PAUSED: + case AssistantMessageStatus.ERROR: + case AssistantMessageStatus.SUCCESS: + setStatus('finished') + break + case undefined: + break + default: + logger.warn('Unexpected assistant message status:', { status: currentAssistantMessage?.status }) } - setTargetLanguage(targetLanguage) - targetLangRef.current = targetLanguage - setAlterLanguage(alterLanguage) + }, [currentAssistantMessage?.status]) - db.settings.put({ id: 'translate:bidirectional:pair', value: [targetLanguage.langCode, alterLanguage.langCode] }) - } + const isPreparing = status === 'preparing' + const isStreaming = status === 'streaming' + + const handleChangeLanguage = useCallback( + (newTargetLanguage: TranslateLanguage, newAlterLanguage: TranslateLanguage) => { + if (!initialized) { + return + } + setTargetLanguage(newTargetLanguage) + targetLangRef.current = newTargetLanguage + setAlterLanguage(newAlterLanguage) + + db.settings.put({ + id: 'translate:bidirectional:pair', + value: [newTargetLanguage.langCode, newAlterLanguage.langCode] + }) + }, + [initialized] + ) + + // Handle direct target language change from the main dropdown + const handleDirectTargetChange = useCallback( + (langCode: TranslateLanguageCode) => { + if (!initialized) return + const newLang = getLanguageByLangcode(langCode) + setActualTargetLanguage(newLang) + + // Update settings: if new target equals current target, keep as is + // Otherwise, swap if needed or just update target + if (newLang.langCode !== targetLanguage.langCode && newLang.langCode !== alterLanguage.langCode) { + // New language is different from both, update target + setTargetLanguage(newLang) + targetLangRef.current = newLang + db.settings.put({ id: 'translate:bidirectional:pair', value: [newLang.langCode, alterLanguage.langCode] }) + } + }, + [initialized, getLanguageByLangcode, targetLanguage.langCode, alterLanguage.langCode] + ) + + // Settings dropdown menu items + const settingsMenuItems = useMemo( + () => [ + { + key: 'preferred', + label: ( + <SettingsMenuItem> + <SettingsLabel>{t('translate.preferred_target')}</SettingsLabel> + <LanguageSelect + value={targetLanguage.langCode} + style={{ width: '100%' }} + listHeight={160} + size="small" + onClick={(e) => e.stopPropagation()} + onChange={(value) => { + handleChangeLanguage(getLanguageByLangcode(value), alterLanguage) + setSettingsOpen(false) + }} + disabled={isStreaming} + /> + </SettingsMenuItem> + ) + }, + { + key: 'alter', + label: ( + <SettingsMenuItem> + <SettingsLabel>{t('translate.alter_language')}</SettingsLabel> + <LanguageSelect + value={alterLanguage.langCode} + style={{ width: '100%' }} + listHeight={160} + size="small" + onClick={(e) => e.stopPropagation()} + onChange={(value) => { + handleChangeLanguage(targetLanguage, getLanguageByLangcode(value)) + setSettingsOpen(false) + }} + disabled={isStreaming} + /> + </SettingsMenuItem> + ) + } + ], + [t, targetLanguage, alterLanguage, isStreaming, getLanguageByLangcode, handleChangeLanguage] + ) const handlePause = () => { + // FIXME: It doesn't work because abort signal is not set. + logger.silly('Try to pause: ', { id: askId.current }) if (askId.current) { abortCompletion(askId.current) - setIsLoading(false) + } + if (topicRef.current?.id) { + pauseTrace(topicRef.current.id) } } const handleRegenerate = () => { setContentToCopy('') - setIsLoading(true) fetchResult() } return ( <> + <SettingsDropdownStyles /> <Container> <MenuContainer> - <Tooltip placement="bottom" title={t('translate.any.language')} arrow> - <Globe size={16} style={{ flexShrink: 0 }} /> - </Tooltip> - <ArrowRightToLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} /> - <Tooltip placement="bottom" title={t('translate.target_language')} arrow> + <LeftGroup> + {/* Detected language display (read-only) */} + <DetectedLanguageTag> + {isPreparing ? ( + <span>{t('translate.detecting')}</span> + ) : ( + <> + <span style={{ marginRight: 4 }}>{detectedLanguage?.emoji || '🌐'}</span> + <span>{detectedLanguage?.label() || t('translate.detected_source')}</span> + </> + )} + </DetectedLanguageTag> + + <ArrowRight size={16} color="var(--color-text-3)" style={{ flexShrink: 0 }} /> + + {/* Target language selector */} <LanguageSelect - value={targetLanguage.langCode} - style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }} + value={actualTargetLanguage.langCode} + style={{ minWidth: 100, maxWidth: 160 }} listHeight={160} - title={t('translate.target_language')} + size="small" optionFilterProp="label" - onChange={(value) => handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)} - disabled={isLoading} + onChange={handleDirectTargetChange} + disabled={isStreaming} /> - </Tooltip> - <ArrowRightFromLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} /> - <Tooltip placement="bottom" title={t('translate.alter_language')} arrow> - <LanguageSelect - value={alterLanguage.langCode} - style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }} - listHeight={160} - title={t('translate.alter_language')} - optionFilterProp="label" - onChange={(value) => handleChangeLanguage(targetLanguage, getLanguageByLangcode(value))} - disabled={isLoading} - /> - </Tooltip> - <Tooltip placement="bottom" title={t('selection.action.translate.smart_translate_tips')} arrow> - <QuestionIcon size={14} style={{ marginLeft: 4 }} /> - </Tooltip> - <Spacer /> + + {/* Settings dropdown */} + <Dropdown + menu={{ + items: settingsMenuItems, + selectable: false, + className: 'settings-dropdown-menu' + }} + trigger={['click']} + placement="bottomRight" + open={settingsOpen} + onOpenChange={setSettingsOpen}> + <Tooltip title={t('translate.language_settings')} placement="bottom"> + <SettingsButton> + <Settings2 size={14} /> + </SettingsButton> + </Tooltip> + </Dropdown> + + <Tooltip title={t('selection.action.translate.smart_translate_tips')} placement="bottom"> + <HelpIcon size={14} /> + </Tooltip> + </LeftGroup> + <OriginalHeader onClick={() => setShowOriginal(!showOriginal)}> <span> {showOriginal ? t('selection.action.window.original_hide') : t('selection.action.window.original_show')} @@ -267,13 +396,20 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => { </OriginalContent> )} <Result> - {!isContented && isLoading && <LoadingOutlined style={{ fontSize: 16 }} spin />} - {messageContent} + {isPreparing && <LoadingOutlined style={{ fontSize: 16 }} spin />} + {!isPreparing && currentAssistantMessage && ( + <MessageContent key={currentAssistantMessage.id} message={currentAssistantMessage} /> + )} </Result> {error && <ErrorMsg>{error}</ErrorMsg>} </Container> <FooterPadding /> - <WindowFooter loading={isLoading} onPause={handlePause} onRegenerate={handleRegenerate} content={contentToCopy} /> + <WindowFooter + loading={isStreaming} + onPause={handlePause} + onRegenerate={handleRegenerate} + content={contentToCopy} + /> </> ) } @@ -355,12 +491,72 @@ const ErrorMsg = styled.div` word-break: break-all; ` -const Spacer = styled.div` - flex-grow: 0.5; +const LeftGroup = styled.div` + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 1; + min-width: 0; ` -const QuestionIcon = styled(CircleHelp)` + +const DetectedLanguageTag = styled.div` + display: flex; + align-items: center; + padding: 4px 8px; + background-color: var(--color-background-soft); + border-radius: 4px; + font-size: 12px; + color: var(--color-text-secondary); + white-space: nowrap; + flex-shrink: 0; +` + +const SettingsButton = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 4px; cursor: pointer; color: var(--color-text-3); + flex-shrink: 0; + + &:hover { + background-color: var(--color-background-soft); + color: var(--color-text); + } +` + +const SettingsMenuItem = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px 0; + min-width: 180px; + cursor: default; +` + +const SettingsLabel = styled.span` + font-size: 12px; + color: var(--color-text-secondary); +` + +const HelpIcon = styled(CircleHelp)` + cursor: pointer; + color: var(--color-text-3); + flex-shrink: 0; +` + +const SettingsDropdownStyles = createGlobalStyle` + .settings-dropdown-menu { + .ant-dropdown-menu-item { + cursor: default !important; + &:hover { + background-color: transparent !important; + } + } + } ` export default ActionTranslate diff --git a/src/renderer/src/workers/pyodide.worker.ts b/src/renderer/src/workers/pyodide.worker.ts index 9676e9f993..288001b33c 100644 --- a/src/renderer/src/workers/pyodide.worker.ts +++ b/src/renderer/src/workers/pyodide.worker.ts @@ -18,20 +18,19 @@ interface PyodideOutput { const PYODIDE_INDEX_URL = 'https://cdn.jsdelivr.net/pyodide/v0.28.0/full/' const PYODIDE_MODULE_URL = PYODIDE_INDEX_URL + 'pyodide.mjs' -// 垫片代码,用于在 Worker 中捕获 Matplotlib 绘图 const MATPLOTLIB_SHIM_CODE = ` def __cherry_studio_matplotlib_setup(): import os - # 在导入 pyplot 前设置后端 + # Set backend before importing pyplot os.environ["MPLBACKEND"] = "AGG" import io import base64 import matplotlib.pyplot as plt - # 保存原始的 show 函数 + # Save original show function _original_show = plt.show - # 定义并替换为新的 show 函数 + # Define and replace with new show function def _new_show(*args, **kwargs): global pyodide_matplotlib_image fig = plt.gcf() @@ -45,13 +44,13 @@ def __cherry_studio_matplotlib_setup(): img_str = base64.b64encode(buf.read()).decode('utf-8') - # 通过全局变量传递数据 + # Pass data via global variable pyodide_matplotlib_image = f"data:image/png;base64,{img_str}" plt.clf() plt.close(fig) - # 替换全局的 show 函数 + # Replace global show function plt.show = _new_show __cherry_studio_matplotlib_setup() diff --git a/tests/main.setup.ts b/tests/main.setup.ts index 5cadb89d02..9d6731e4a7 100644 --- a/tests/main.setup.ts +++ b/tests/main.setup.ts @@ -10,59 +10,69 @@ vi.mock('@logger', async () => { }) // Mock electron modules that are commonly used in main process -vi.mock('electron', () => ({ - app: { - getPath: vi.fn((key: string) => { - switch (key) { - case 'userData': - return '/mock/userData' - case 'temp': - return '/mock/temp' - case 'logs': - return '/mock/logs' - default: - return '/mock/unknown' +vi.mock('electron', () => { + const mock = { + app: { + getPath: vi.fn((key: string) => { + switch (key) { + case 'userData': + return '/mock/userData' + case 'temp': + return '/mock/temp' + case 'logs': + return '/mock/logs' + default: + return '/mock/unknown' + } + }), + getVersion: vi.fn(() => '1.0.0') + }, + ipcMain: { + handle: vi.fn(), + on: vi.fn(), + once: vi.fn(), + removeHandler: vi.fn(), + removeAllListeners: vi.fn() + }, + BrowserWindow: vi.fn(), + dialog: { + showErrorBox: vi.fn(), + showMessageBox: vi.fn(), + showOpenDialog: vi.fn(), + showSaveDialog: vi.fn() + }, + shell: { + openExternal: vi.fn(), + showItemInFolder: vi.fn() + }, + session: { + defaultSession: { + clearCache: vi.fn(), + clearStorageData: vi.fn() } - }), - getVersion: vi.fn(() => '1.0.0') - }, - ipcMain: { - handle: vi.fn(), - on: vi.fn(), - once: vi.fn(), - removeHandler: vi.fn(), - removeAllListeners: vi.fn() - }, - BrowserWindow: vi.fn(), - dialog: { - showErrorBox: vi.fn(), - showMessageBox: vi.fn(), - showOpenDialog: vi.fn(), - showSaveDialog: vi.fn() - }, - shell: { - openExternal: vi.fn(), - showItemInFolder: vi.fn() - }, - session: { - defaultSession: { - clearCache: vi.fn(), - clearStorageData: vi.fn() - } - }, - webContents: { - getAllWebContents: vi.fn(() => []) - }, - systemPreferences: { - getMediaAccessStatus: vi.fn(), - askForMediaAccess: vi.fn() - }, - screen: { - getPrimaryDisplay: vi.fn(), - getAllDisplays: vi.fn() - }, - Notification: vi.fn() -})) + }, + webContents: { + getAllWebContents: vi.fn(() => []) + }, + systemPreferences: { + getMediaAccessStatus: vi.fn(), + askForMediaAccess: vi.fn() + }, + nativeTheme: { + themeSource: 'system', + shouldUseDarkColors: false, + on: vi.fn(), + removeListener: vi.fn() + }, + screen: { + getPrimaryDisplay: vi.fn(), + getAllDisplays: vi.fn() + }, + Notification: vi.fn() + } + + return { __esModule: true, ...mock, default: mock } +}) // Mock Winston for LoggerService dependencies vi.mock('winston', () => ({ @@ -98,13 +108,17 @@ vi.mock('winston-daily-rotate-file', () => { }) // Mock Node.js modules -vi.mock('node:os', () => ({ - platform: vi.fn(() => 'darwin'), - arch: vi.fn(() => 'x64'), - version: vi.fn(() => '20.0.0'), - cpus: vi.fn(() => [{ model: 'Mock CPU' }]), - totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB -})) +vi.mock('node:os', () => { + const mock = { + platform: vi.fn(() => 'darwin'), + arch: vi.fn(() => 'x64'), + version: vi.fn(() => '20.0.0'), + cpus: vi.fn(() => [{ model: 'Mock CPU' }]), + homedir: vi.fn(() => '/mock/home'), + totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB + } + return { ...mock, default: mock } +}) vi.mock('node:path', async () => { const actual = await vi.importActual('node:path') @@ -115,25 +129,29 @@ vi.mock('node:path', async () => { } }) -vi.mock('node:fs', () => ({ - promises: { - access: vi.fn(), - readFile: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), - readdir: vi.fn(), - stat: vi.fn(), - unlink: vi.fn(), - rmdir: vi.fn() - }, - existsSync: vi.fn(), - readFileSync: vi.fn(), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), - readdirSync: vi.fn(), - statSync: vi.fn(), - unlinkSync: vi.fn(), - rmdirSync: vi.fn(), - createReadStream: vi.fn(), - createWriteStream: vi.fn() -})) +vi.mock('node:fs', () => { + const mock = { + promises: { + access: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + unlink: vi.fn(), + rmdir: vi.fn() + }, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), + statSync: vi.fn(), + unlinkSync: vi.fn(), + rmdirSync: vi.fn(), + createReadStream: vi.fn(), + createWriteStream: vi.fn() + } + + return { ...mock, default: mock } +})