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/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/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 9b6f108e6a..09ade53da8 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -144,34 +144,30 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - Cherry Studio 1.7.11 - New Features & Bug Fixes + Cherry Studio 1.7.13 - Security & Bug Fixes - ✨ New Features - - [MCP] Add MCP Hub with Auto mode for intelligent multi-server tool orchestration + 🔒 Security + - [Plugin] Fix security vulnerability in DXT plugin system on Windows 🐛 Bug Fixes - - [Chat] Fix reasoning process not displaying correctly for some proxy models - - [Chat] Fix duplicate loading spinners on action buttons - - [Editor] Fix paragraph handle and plus button not clickable - - [Drawing] Fix TokenFlux models not showing in drawing panel - - [Translate] Fix translation stalling after initialization - - [Error] Fix app freeze when viewing error details with large images - - [Notes] Fix folder overlay blocking webview preview - - [Chat] Fix thinking time display when stopping generation + - [Agent] Fix Agent not working when Node.js is not installed on system + - [Chat] Fix app crash when opening certain agents + - [Chat] Fix reasoning process not displaying correctly for some providers + - [Chat] Fix memory leak issue during streaming conversations + - [MCP] Fix timeout field not accepting string format in MCP configuration + - [Settings] Add careers section in About page - Cherry Studio 1.7.11 - 新功能与问题修复 + Cherry Studio 1.7.13 - 安全与问题修复 - ✨ 新功能 - - [MCP] 新增 MCP Hub 智能模式,可自动管理和调用多个 MCP 服务器工具 + 🔒 安全修复 + - [插件] 修复 Windows 系统 DXT 插件的安全漏洞 🐛 问题修复 - - [对话] 修复部分代理模型的推理过程无法正确显示的问题 - - [对话] 修复操作按钮重复显示加载状态的问题 - - [编辑器] 修复段落手柄和加号按钮无法点击的问题 - - [绘图] 修复 TokenFlux 模型在绘图面板不显示的问题 - - [翻译] 修复翻译功能初始化后卡住的问题 - - [错误] 修复查看包含大图片的错误详情时应用卡死的问题 - - [笔记] 修复文件夹遮挡网页预览的问题 - - [对话] 修复停止生成时思考时间显示问题 + - [Agent] 修复系统未安装 Node.js 时 Agent 功能无法使用的问题 + - [对话] 修复打开某些智能体时应用崩溃的问题 + - [对话] 修复部分服务商推理过程无法正确显示的问题 + - [对话] 修复流式对话时的内存泄漏问题 + - [MCP] 修复 MCP 配置的 timeout 字段不支持字符串格式的问题 + - [设置] 关于页面新增招聘入口 diff --git a/package.json b/package.json index fc232d1584..284e021c61 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "i18n:check": "dotenv -e .env -- tsx scripts/check-i18n.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", @@ -271,6 +271,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", @@ -456,7 +457,8 @@ "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", - "@ai-sdk/openai-compatible@1.0.28": "patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch" + "@ai-sdk/openai-compatible@1.0.28": "patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch", + "@anthropic-ai/claude-agent-sdk@0.1.76": "patches/@anthropic-ai__claude-agent-sdk@0.1.76.patch" }, "onlyBuiltDependencies": [ "@kangfenmao/keyv-storage", @@ -484,5 +486,27 @@ "*.{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-linux-arm64": "0.34.3", + "@img/sharp-linux-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-x64-gnu": "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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 1a65f75d35..c38b109b1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ patchedDependencies: '@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.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 @@ -86,7 +89,7 @@ importers: dependencies: '@anthropic-ai/claude-agent-sdk': specifier: 0.1.76 - version: 0.1.76(zod@4.3.4) + version: 0.1.76(patch_hash=e063a8ede82d78f452f7f1290b9d4d9323866159b5624679163caa8edd4928d5)(zod@4.3.4) '@libsql/client': specifier: 0.14.0 version: 0.14.0 @@ -688,6 +691,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 @@ -1123,6 +1129,67 @@ 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-linux-arm64': + specifier: 0.34.3 + version: 0.34.3 + '@img/sharp-linux-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-x64-gnu': + 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: @@ -4237,6 +4304,9 @@ packages: '@oxc-project/types@0.106.0': resolution: {integrity: sha512-QdsH3rZq480VnOHSHgPYOhjL8O8LBdcnSjM408BpPCCUc0JYYZPG9Gafl9i3OcGk/7137o+gweb4cCv3WAUykg==} + '@oxc-project/types@0.107.0': + resolution: {integrity: sha512-QFDRbYfV2LVx8tyqtyiah3jQPUj1mK2+RYwxyFWyGoys6XJnwTdlzO6rdNNHOPorHAu5Uo34oWRKcvNpbJarmQ==} + '@oxc-project/types@0.95.0': resolution: {integrity: sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==} @@ -5333,6 +5403,12 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.0.0-beta.59': + resolution: {integrity: sha512-6yLLgyswYwiCfls9+hoNFY9F8TQdwo15hpXDHzlAR0X/GojeKF+AuNcXjYNbOJ4zjl/5D6lliE8CbpB5t1OWIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.0-beta.45': resolution: {integrity: sha512-xjCv4CRVsSnnIxTuyH1RDJl5OEQ1c9JYOwfDAHddjJDxCw46ZX9q80+xq7Eok7KC4bRSZudMJllkvOKv0T9SeA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5351,6 +5427,12 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-beta.59': + resolution: {integrity: sha512-hqGXRc162qCCIOAcHN2Cw4eXiVTwYsMFLOhAy1IG2CxY+dwc/l4Ga+dLPkLor3Ikqy5WDn+7kxHbbh6EmshEpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-beta.45': resolution: {integrity: sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5369,6 +5451,12 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-beta.59': + resolution: {integrity: sha512-ezvvGuhteE15JmMhJW0wS7BaXmhwLy1YHeEwievYaPC1PgGD86wgBKfOpHr9tSKllAXbCe0BeeMvasscWLhKdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-beta.45': resolution: {integrity: sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5387,6 +5475,12 @@ packages: cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-beta.59': + resolution: {integrity: sha512-4fhKVJiEYVd5n6no/mrL3LZ9kByfCGwmONOrdtvx8DJGDQhehH/q3RfhG3V/4jGKhpXgbDjpIjkkFdybCTcgew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': resolution: {integrity: sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5405,6 +5499,12 @@ packages: cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.59': + resolution: {integrity: sha512-T3Y52sW6JAhvIqArBw+wtjNU1Ieaz4g0NBxyjSJoW971nZJBZygNlSYx78G4cwkCmo1dYTciTPDOnQygLV23pA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': resolution: {integrity: sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5423,6 +5523,12 @@ packages: cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.59': + resolution: {integrity: sha512-NIW40jQDSQap2KDdmm9z3B/4OzWJ6trf8dwx3FD74kcQb3v34ThsBFTtzE5KjDuxnxgUlV+DkAu+XgSMKrgufw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': resolution: {integrity: sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5441,6 +5547,12 @@ packages: cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.59': + resolution: {integrity: sha512-CCKEk+H+8c0WGe/8n1E20n85Tq4Pv+HNAbjP1KfUXW+01aCWSMjU56ChNrM2tvHnXicfm7QRNoZyfY8cWh7jLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': resolution: {integrity: sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5459,6 +5571,12 @@ packages: cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.59': + resolution: {integrity: sha512-VlfwJ/HCskPmQi8R0JuAFndySKVFX7yPhE658o27cjSDWWbXVtGkSbwaxstii7Q+3Rz87ZXN+HLnb1kd4R9Img==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': resolution: {integrity: sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5477,6 +5595,12 @@ packages: cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.0-beta.59': + resolution: {integrity: sha512-kuO92hTRyGy0Ts3Nsqll0rfO8eFsEJe9dGQGktkQnZ2hrJrDVN0y419dMgKy/gB2S2o7F2dpWhpfQOBehZPwVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': resolution: {integrity: sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5495,6 +5619,12 @@ packages: cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.0-beta.59': + resolution: {integrity: sha512-PXAebvNL4sYfCqi8LdY4qyFRacrRoiPZLo3NoUmiTxm7MPtYYR8CNtBGNokqDmMuZIQIecRaD/jbmFAIDz7DxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': resolution: {integrity: sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ==} engines: {node: '>=14.0.0'} @@ -5510,6 +5640,11 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-beta.59': + resolution: {integrity: sha512-yJoklQg7XIZq8nAg0bbkEXcDK6sfpjxQGxpg2Nd6ERNtvg+eOaEBRgPww0BVTrYFQzje1pB5qPwC2VnJHT3koQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': resolution: {integrity: sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5528,6 +5663,12 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.59': + resolution: {integrity: sha512-ljZ4+McmCbIuZwEBaoGtiG8Rq2nJjaXEnLEIx+usWetXn1ECjXY0LAhkELxOV6ytv4ensEmoJJ8nXg47hRMjlw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': resolution: {integrity: sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5552,6 +5693,12 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.59': + resolution: {integrity: sha512-bMY4tTIwbdZljW+xe/ln1hvs0SRitahQSXfWtvgAtIzgSX9Ar7KqJzU7lRm33YTRFIHLULRi53yNjw9nJGd6uQ==} + 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==} @@ -5564,6 +5711,9 @@ packages: '@rolldown/pluginutils@1.0.0-beta.58': resolution: {integrity: sha512-qWhDs6yFGR5xDfdrwiSa3CWGIHxD597uGE/A9xGqytBjANvh4rLCTTkq7szhMV4+Ygh+PMS90KVJ8xWG/TkX4w==} + '@rolldown/pluginutils@1.0.0-beta.59': + resolution: {integrity: sha512-aoh6LAJRyhtazs98ydgpNOYstxUlsOV1KJXcpf/0c0vFcUA8uyd/hwKRhqE/AAPNqAho9RliGsvitCoOzREoVA==} + '@rollup/pluginutils@5.3.0': resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} engines: {node: '>=14.0.0'} @@ -6040,6 +6190,11 @@ packages: typescript: optional: true + '@strongtz/win32-arm64-msvc@0.4.7': + resolution: {integrity: sha512-LMU0CVfKGxKzIUawkDw6pwCNFb64DSwqEQhVAfEdLGMZcvVIdJghWB9yMW+72DHzALpwHscIssAJGhYAT4AbfA==} + cpu: [arm64] + os: [win32] + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -8077,6 +8232,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==} @@ -12695,6 +12854,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.0.0-beta.59: + resolution: {integrity: sha512-Slm000Gd8/AO9z4Kxl4r8mp/iakrbAuJ1L+7ddpkNxgQ+Vf37WPvY63l3oeyZcfuPD1DRrUYBsRPIXSOhvOsmw==} + 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'} @@ -14611,7 +14775,7 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@anthropic-ai/claude-agent-sdk@0.1.76(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: @@ -18405,6 +18569,8 @@ snapshots: '@oxc-project/types@0.106.0': {} + '@oxc-project/types@0.107.0': {} + '@oxc-project/types@0.95.0': {} '@oxlint-tsgolint/darwin-arm64@0.2.1': @@ -19811,6 +19977,9 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-beta.58': optional: true + '@rolldown/binding-android-arm64@1.0.0-beta.59': + optional: true + '@rolldown/binding-darwin-arm64@1.0.0-beta.45': optional: true @@ -19820,6 +19989,9 @@ snapshots: '@rolldown/binding-darwin-arm64@1.0.0-beta.58': optional: true + '@rolldown/binding-darwin-arm64@1.0.0-beta.59': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-beta.45': optional: true @@ -19829,6 +20001,9 @@ snapshots: '@rolldown/binding-darwin-x64@1.0.0-beta.58': optional: true + '@rolldown/binding-darwin-x64@1.0.0-beta.59': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-beta.45': optional: true @@ -19838,6 +20013,9 @@ snapshots: '@rolldown/binding-freebsd-x64@1.0.0-beta.58': optional: true + '@rolldown/binding-freebsd-x64@1.0.0-beta.59': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': optional: true @@ -19847,6 +20025,9 @@ snapshots: '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.58': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.59': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': optional: true @@ -19856,6 +20037,9 @@ snapshots: '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.58': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.59': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': optional: true @@ -19865,6 +20049,9 @@ snapshots: '@rolldown/binding-linux-arm64-musl@1.0.0-beta.58': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.59': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': optional: true @@ -19874,6 +20061,9 @@ snapshots: '@rolldown/binding-linux-x64-gnu@1.0.0-beta.58': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.59': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': optional: true @@ -19883,6 +20073,9 @@ snapshots: '@rolldown/binding-linux-x64-musl@1.0.0-beta.58': optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-beta.59': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': optional: true @@ -19892,6 +20085,9 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.0.0-beta.58': optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-beta.59': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': dependencies: '@napi-rs/wasm-runtime': 1.1.1 @@ -19907,6 +20103,11 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.1 optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-beta.59': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': optional: true @@ -19916,6 +20117,9 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.58': optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.59': + optional: true + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': optional: true @@ -19928,6 +20132,9 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.0-beta.58': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.59': + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.45': {} @@ -19936,6 +20143,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.58': {} + '@rolldown/pluginutils@1.0.0-beta.59': {} + '@rollup/pluginutils@5.3.0(rollup@4.55.1)': dependencies: '@types/estree': 1.0.8 @@ -20511,6 +20720,9 @@ snapshots: transitivePeerDependencies: - supports-color + '@strongtz/win32-arm64-msvc@0.4.7': + optional: true + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -23190,6 +23402,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.2: {} + commander@2.20.3: {} commander@5.1.0: {} @@ -28590,7 +28804,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.59)(typescript@5.8.3): dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 @@ -28600,7 +28814,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.59 optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260104.1 typescript: 5.8.3 @@ -28608,7 +28822,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.59)(typescript@5.9.2): dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 @@ -28618,7 +28832,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.59 optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260104.1 typescript: 5.9.2 @@ -28736,6 +28950,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.59: + dependencies: + '@oxc-project/types': 0.107.0 + '@rolldown/pluginutils': 1.0.0-beta.59 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.59 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.59 + '@rolldown/binding-darwin-x64': 1.0.0-beta.59 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.59 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.59 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.59 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.59 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.59 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.59 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.59 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.59 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.59 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.59 + rollup-plugin-visualizer@5.14.0(rollup@4.55.1): dependencies: open: 8.4.2 @@ -29665,8 +29898,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.59 + rolldown-plugin-dts: 0.15.10(@typescript/native-preview@7.0.0-dev.20260104.1)(rolldown@1.0.0-beta.59)(typescript@5.8.3) semver: 7.7.3 tinyexec: 1.0.2 tinyglobby: 0.2.15 @@ -29689,8 +29922,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.59 + rolldown-plugin-dts: 0.15.10(@typescript/native-preview@7.0.0-dev.20260104.1)(rolldown@1.0.0-beta.59)(typescript@5.9.2) semver: 7.7.3 tinyexec: 1.0.2 tinyglobby: 0.2.15 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/before-pack.js b/scripts/before-pack.js index 0793096645..9a5abf743f 100644 --- a/scripts/before-pack.js +++ b/scripts/before-pack.js @@ -1,42 +1,35 @@ 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-linux-arm64', + '@img/sharp-linux-x64', + '@img/sharp-win32-arm64', + '@img/sharp-win32-x64', + '@img/sharp-libvips-darwin-arm64', + '@img/sharp-libvips-darwin-x64', + '@img/sharp-libvips-linux-arm64', + '@img/sharp-libvips-linux-x64', + '@libsql/darwin-arm64', + '@libsql/darwin-x64', + '@libsql/linux-arm64-gnu', + '@libsql/linux-x64-gnu', + '@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', @@ -45,61 +38,82 @@ const platformToArch = { } 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/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/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 6c692ce532..151e73addf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -87,6 +87,15 @@ if (isLinux && process.env.XDG_SESSION_TYPE === 'wayland') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal') } +/** + * Set window class and name for X11 + * This ensures the system tray and window manager identify the app correctly + */ +if (isLinux) { + app.commandLine.appendSwitch('class', 'cherry-studio') + app.commandLine.appendSwitch('name', 'cherry-studio') +} + // 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/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/SelectionService.ts b/src/main/services/SelectionService.ts index 0adf50c6d1..b94da0638f 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -1088,18 +1088,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/__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/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index 247dc8e5c8..ab4a5d8c48 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -1,6 +1,6 @@ import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' -import { isGemini3Model, isSupportedThinkingTokenQwenModel } from '@renderer/config/models' +import { isAnthropicModel, isGemini3Model, isSupportedThinkingTokenQwenModel } from '@renderer/config/models' import type { McpMode, MCPTool } from '@renderer/types' import { type Assistant, type Message, type Model, type Provider, SystemProviderIds } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' @@ -10,6 +10,7 @@ import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai' import { getAiSdkProviderId } from '../provider/factory' import { isOpenRouterGeminiGenerateImageModel } from '../utils/image' +import { anthropicCacheMiddleware } from './anthropicCacheMiddleware' import { noThinkMiddleware } from './noThinkMiddleware' import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMiddleware' import { openrouterReasoningMiddleware } from './openrouterReasoningMiddleware' @@ -179,7 +180,12 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: // 根据不同provider添加特定中间件 switch (config.provider.type) { case 'anthropic': - // Anthropic特定中间件 + if (isAnthropicModel(config.model) && config.provider.anthropicCacheControl?.tokenThreshold) { + builder.add({ + name: 'anthropic-cache', + middleware: anthropicCacheMiddleware(config.provider) + }) + } break case 'openai': case 'azure-openai': { diff --git a/src/renderer/src/aiCore/middleware/anthropicCacheMiddleware.ts b/src/renderer/src/aiCore/middleware/anthropicCacheMiddleware.ts new file mode 100644 index 0000000000..df50798940 --- /dev/null +++ b/src/renderer/src/aiCore/middleware/anthropicCacheMiddleware.ts @@ -0,0 +1,79 @@ +/** + * Anthropic Prompt Caching Middleware + * @see https://ai-sdk.dev/providers/ai-sdk-providers/anthropic#cache-control + */ +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: unknown): number { + if (typeof content === 'string') return estimateTextTokens(content) + if (Array.isArray(content)) { + return content.reduce((acc, part) => { + if (typeof part === 'object' && part !== null && 'text' in part) { + return acc + estimateTextTokens(part.text as string) + } + return acc + }, 0) + } + return 0 +} + +function addCacheToContentParts(content: unknown): unknown { + if (typeof content === 'string') { + return [{ type: 'text', text: content, providerOptions: cacheProviderOptions }] + } + if (Array.isArray(content) && content.length > 0) { + const result = [...content] + const last = result[result.length - 1] + if (typeof last === 'object' && last !== null) { + result[result.length - 1] = { ...last, providerOptions: cacheProviderOptions } + } + return result + } + return content +} + +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 any + 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) { + for (let i = messages.length - 1; i >= 0 && cachedCount < cacheLastNMessages; i--) { + const msg = messages[i] as any + if (msg.role !== 'system' && estimateContentTokens(msg.content) >= tokenThreshold) { + messages[i] = { ...msg, content: addCacheToContentParts(msg.content) } + cachedCount++ + } + } + } + + return { ...params, prompt: messages } + } + } +} diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index 8dc7a10af9..1a16ea05da 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 + } } } } diff --git a/src/renderer/src/config/models/__tests__/reasoning.test.ts b/src/renderer/src/config/models/__tests__/reasoning.test.ts index 0f58be4ef0..5173eed9f0 100644 --- a/src/renderer/src/config/models/__tests__/reasoning.test.ts +++ b/src/renderer/src/config/models/__tests__/reasoning.test.ts @@ -1368,7 +1368,9 @@ 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 } } ] it.each(cases)('returns correct limits for $modelId', ({ modelId, expected }) => { diff --git a/src/renderer/src/config/models/default.ts b/src/renderer/src/config/models/default.ts index 408c047639..1c15064a11 100644 --- a/src/renderer/src/config/models/default.ts +++ b/src/renderer/src/config/models/default.ts @@ -713,6 +713,30 @@ 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' } ], modelscope: [ diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index b2b6119b76..0b42ed0934 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -640,6 +640,16 @@ 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 是推理模型(注意:M2-Plus 不是推理模型) + return modelId.includes('baichuan-m2') && !modelId.includes('plus') +} + export function isReasoningModel(model?: Model): boolean { if (!model || isEmbeddingModel(model) || isRerankModel(model) || isTextToImageModel(model)) { return false @@ -675,6 +685,7 @@ export function isReasoningModel(model?: Model): boolean { isLingReasoningModel(model) || isMiniMaxReasoningModel(model) || isMiMoReasoningModel(model) || + isBaichuanReasoningModel(model) || modelId.includes('magistral') || modelId.includes('pangu-pro-moe') || modelId.includes('seed-oss') || @@ -718,7 +729,10 @@ 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 } } export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => { diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index f49794aaa7..f38fd2b163 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -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/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/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index c701ae2ab0..70930a5222 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3115,6 +3115,10 @@ }, "settings": { "about": { + "careers": { + "button": "View", + "title": "Careers" + }, "checkUpdate": { "available": "Update", "label": "Check Update" @@ -4475,6 +4479,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" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0150e5db89..200968e822 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3115,6 +3115,10 @@ }, "settings": { "about": { + "careers": { + "button": "查看", + "title": "加入我们" + }, "checkUpdate": { "available": "立即更新", "label": "检查更新" @@ -4475,6 +4479,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" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index d93ba8caf1..bb996e50f4 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3115,6 +3115,10 @@ }, "settings": { "about": { + "careers": { + "button": "查看", + "title": "加入我們" + }, "checkUpdate": { "available": "立即更新", "label": "檢查更新" @@ -4475,6 +4479,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" diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index f56ba628ba..a34b91d2f3 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -3115,6 +3115,10 @@ }, "settings": { "about": { + "careers": { + "button": "Ansicht", + "title": "Karriere" + }, "checkUpdate": { "available": "Jetzt aktualisieren", "label": "Auf Updates prüfen" @@ -4475,6 +4479,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" diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 3f1697cbbe..8b8ba8dd0d 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3115,6 +3115,10 @@ }, "settings": { "about": { + "careers": { + "button": "Προβολή", + "title": "Καριέρα" + }, "checkUpdate": { "available": "Άμεση ενημέρωση", "label": "Έλεγχος ενημερώσεων" @@ -4475,6 +4479,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": "Υποστήριξη για περιεχόμενο μηνύματος με μορφή πίνακα" diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index b91cabc838..7b284d6870 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3115,6 +3115,10 @@ }, "settings": { "about": { + "careers": { + "button": "Vista", + "title": "Carreras" + }, "checkUpdate": { "available": "Actualizar ahora", "label": "Comprobar actualizaciones" @@ -4475,6 +4479,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" diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 65a64614de..bd81a88fdc 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3115,6 +3115,10 @@ }, "settings": { "about": { + "careers": { + "button": "Vue", + "title": "Carrières" + }, "checkUpdate": { "available": "Mettre à jour maintenant", "label": "Vérifier les mises à jour" @@ -4475,6 +4479,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" diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index e76dce2d9b..a2b3e7138e 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -3115,6 +3115,10 @@ }, "settings": { "about": { + "careers": { + "button": "表示", + "title": "キャリア" + }, "checkUpdate": { "available": "今すぐ更新", "label": "更新を確認" @@ -4475,6 +4479,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": "配列形式のメッセージコンテンツをサポート" diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 76cbbf5c9e..efa28630de 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3115,6 +3115,10 @@ }, "settings": { "about": { + "careers": { + "button": "Visualizar", + "title": "Carreiras" + }, "checkUpdate": { "available": "Atualizar agora", "label": "Verificar atualizações" @@ -4475,6 +4479,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" diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index 9c0ba398c9..fb45d9c647 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -3115,6 +3115,10 @@ }, "settings": { "about": { + "careers": { + "button": "Vedere", + "title": "Carieră" + }, "checkUpdate": { "available": "Actualizare", "label": "Verifică actualizări" @@ -4475,6 +4479,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" diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 60b488c0d0..84d62c962e 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -3115,6 +3115,10 @@ }, "settings": { "about": { + "careers": { + "button": "Вид", + "title": "Карьера" + }, "checkUpdate": { "available": "Обновить", "label": "Проверить обновления" @@ -4475,6 +4479,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": "поддержка формата массива для содержимого сообщения" diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index 783cddc6d9..6c35cfb047 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -16,7 +16,7 @@ import { ThemeMode, UpgradeChannel } from '@shared/data/preference/preferenceTyp import { Link } from '@tanstack/react-router' import { Avatar, Progress, Radio, Row, Tag } 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' @@ -324,6 +324,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/ProviderSettings/ApiOptionsSettings/ApiOptionsSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiOptionsSettings/ApiOptionsSettings.tsx index 4ea5cdc050..9fb02ec65c 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 { ColFlex, RowFlex } from '@cherrystudio/ui' -import { Switch } from '@cherrystudio/ui' +import { ColFlex, RowFlex, Switch } from '@cherrystudio/ui' import { InfoTooltip } from '@cherrystudio/ui' import { useProvider } from '@renderer/hooks/useProvider' -import type { Provider } from '@renderer/types' +import { type AnthropicCacheControlSettings, type Provider } from '@renderer/types' +import { isSupportAnthropicPromptCacheProvider } from '@renderer/utils/provider' +import { Divider, InputNumber } from 'antd' import { startTransition, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -114,11 +115,32 @@ 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 ( <ColFlex className="gap-4"> {options.map((item) => ( <RowFlex key={item.key} className="justify-between"> - <RowFlex className="items-center gap-1.5"> + <RowFlex className="items-center gap-2"> <label style={{ cursor: 'pointer' }} htmlFor={item.key}> {item.label} </label> @@ -127,6 +149,52 @@ const ApiOptionsSettings = ({ providerId }: Props) => { <Switch id={item.key} checked={item.checked} onCheckedChange={item.onChange} /> </RowFlex> ))} + + {isSupportAnthropicPromptCache && ( + <> + <Divider style={{ margin: '8px 0' }} /> + <RowFlex className="justify-between"> + <RowFlex className="items-center gap-2"> + <span>{t('settings.provider.api.options.anthropic_cache.token_threshold')}</span> + <InfoTooltip title={t('settings.provider.api.options.anthropic_cache.token_threshold_help')} /> + </RowFlex> + <InputNumber + min={0} + max={100000} + value={cacheSettings.tokenThreshold} + onChange={(v) => updateCacheSettings({ tokenThreshold: v ?? 0 })} + style={{ width: 100 }} + /> + </RowFlex> + {cacheSettings.tokenThreshold > 0 && ( + <> + <RowFlex className="justify-between"> + <RowFlex className="items-center gap-2"> + <span>{t('settings.provider.api.options.anthropic_cache.cache_system')}</span> + <InfoTooltip title={t('settings.provider.api.options.anthropic_cache.cache_system_help')} /> + </RowFlex> + <Switch + checked={cacheSettings.cacheSystemMessage} + onCheckedChange={(v) => updateCacheSettings({ cacheSystemMessage: v })} + /> + </RowFlex> + <RowFlex className="justify-between"> + <RowFlex className="items-center gap-2"> + <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')} /> + </RowFlex> + <InputNumber + min={0} + max={10} + value={cacheSettings.cacheLastNMessages} + onChange={(v) => updateCacheSettings({ cacheLastNMessages: v ?? 0 })} + style={{ width: 100 }} + /> + </RowFlex> + </> + )} + </> + )} </ColFlex> ) } diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 7e499e5ad9..a8bfd37036 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -31,6 +31,7 @@ import { isOllamaProvider, isOpenAICompatibleProvider, isOpenAIProvider, + isSupportAnthropicPromptCacheProvider, isVertexProvider } from '@renderer/utils/provider' import { Divider, Input, Select, Space } from 'antd' @@ -403,7 +404,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => { </Button> </Link> )} - {!isSystemProvider(provider) && ( + {(!isSystemProvider(provider) || isSupportAnthropicPromptCacheProvider(provider)) && ( <Tooltip content={t('settings.provider.api.options.label')}> <Button variant="ghost" 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/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/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) + ) +}