merge main into v2

This commit is contained in:
fullex 2026-01-10 00:39:06 +08:00
commit ad37f0991a
46 changed files with 2027 additions and 473 deletions

View File

@ -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}"

View File

@ -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的html_url>" \
ISSUE_NUMBER="<issue编号>" \
ISSUE_TITLE="<issue标题>" \
ISSUE_AUTHOR="<issue作者>" \
ISSUE_LABELS="<逗号分隔的标签列表排除pending-feishu-notification>" \
ISSUE_SUMMARY="<你生成的中文总结>" \
node scripts/feishu-notify.js
pnpm tsx scripts/feishu-notify.ts issue \
-u "<issue的html_url>" \
-n "<issue编号>" \
-t "<issue标题>" \
-a "<issue作者>" \
-l "<逗号分隔的标签列表排除pending-feishu-notification>" \
-m "<你生成的中文总结>"
```
4. **移除标签**

View File

@ -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}"

View File

@ -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**: `#<issue_number> - <issue_title>`
- **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()`

View File

@ -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 创建者
- **标签**: 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()` 注册命令

View File

@ -144,34 +144,30 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
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
<!--LANG:zh-CN-->
Cherry Studio 1.7.11 - 新功能与问题修复
Cherry Studio 1.7.13 - 安全与问题修复
✨ 新功能
- [MCP] 新增 MCP Hub 智能模式,可自动管理和调用多个 MCP 服务器工具
🔒 安全修复
- [插件] 修复 Windows 系统 DXT 插件的安全漏洞
🐛 问题修复
- [对话] 修复部分代理模型的推理过程无法正确显示的问题
- [对话] 修复操作按钮重复显示加载状态的问题
- [编辑器] 修复段落手柄和加号按钮无法点击的问题
- [绘图] 修复 TokenFlux 模型在绘图面板不显示的问题
- [翻译] 修复翻译功能初始化后卡住的问题
- [错误] 修复查看包含大图片的错误详情时应用卡死的问题
- [笔记] 修复文件夹遮挡网页预览的问题
- [对话] 修复停止生成时思考时间显示问题
- [Agent] 修复系统未安装 Node.js 时 Agent 功能无法使用的问题
- [对话] 修复打开某些智能体时应用崩溃的问题
- [对话] 修复部分服务商推理过程无法正确显示的问题
- [对话] 修复流式对话时的内存泄漏问题
- [MCP] 修复 MCP 配置的 timeout 字段不支持字符串格式的问题
- [设置] 关于页面新增招聘入口
<!--LANG:END-->

View File

@ -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"
}
}

View File

@ -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) => {

View File

@ -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

View File

@ -1,2 +1,8 @@
packages:
- 'packages/*'
supportedArchitectures:
os:
- current
cpu:
- current

View File

@ -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])
}
}

View File

@ -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<void>}
*/
function sendToFeishu(webhookUrl, secret, content) {
return new Promise((resolve, reject) => {
const timestamp = Math.floor(Date.now() / 1000)
const sign = generateSignature(secret, timestamp)
const payload = JSON.stringify({
timestamp: timestamp.toString(),
sign: sign,
msg_type: 'interactive',
card: content
})
const url = new URL(webhookUrl)
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
}
const req = https.request(options, (res) => {
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log('✅ Successfully sent to Feishu:', data)
resolve()
} else {
reject(new Error(`Feishu API error: ${res.statusCode} - ${data}`))
}
})
})
req.on('error', (error) => {
reject(error)
})
req.write(payload)
req.end()
})
}
/**
* Create Feishu card message from issue data
* @param {object} issueData - GitHub issue data
* @returns {object} Feishu card content
*/
function createIssueCard(issueData) {
const { issueUrl, issueNumber, issueTitle, issueSummary, issueAuthor, labels } = issueData
// Build labels section if labels exist
const labelElements =
labels && labels.length > 0
? labels.map((label) => ({
tag: 'markdown',
content: `\`${label}\``
}))
: []
return {
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**👤 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()

421
scripts/feishu-notify.ts Normal file
View File

@ -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<typeof FeishuHeaderTemplateSchema>
/** 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<void> {
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<void> {
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<void> {
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 <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()

View File

@ -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
}

View File

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

View File

@ -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
}
}

View File

@ -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

View File

@ -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) {

View File

@ -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
}

View File

@ -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')
})
})
})

View File

@ -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': {

View File

@ -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 }
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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 }) => {

View File

@ -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: [

View File

@ -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 => {

View File

@ -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: {

View File

@ -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)

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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": "Υποστήριξη για περιεχόμενο μηνύματος με μορφή πίνακα"

View File

@ -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"

View File

@ -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 (à lexclusion 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"

View File

@ -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": "配列形式のメッセージコンテンツをサポート"

View File

@ -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"

View File

@ -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"

View File

@ -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": "поддержка формата массива для содержимого сообщения"

View File

@ -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} />

View File

@ -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>
)
}

View File

@ -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"

View File

@ -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) => {

View File

@ -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包的版本

View File

@ -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([

View File

@ -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)
)
}