diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 119e68c6..1eaf622c 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -141,6 +141,7 @@
]
},
"disabledMcpServers": [
- "claude-in-chrome"
+ "claude-in-chrome",
+ "email"
]
}
\ No newline at end of file
diff --git a/.claude/skills/playwright-cli/SKILL.md b/.claude/skills/playwright-cli/SKILL.md
deleted file mode 100644
index f5cb7004..00000000
--- a/.claude/skills/playwright-cli/SKILL.md
+++ /dev/null
@@ -1,225 +0,0 @@
----
-name: playwright-cli
-description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
-allowed-tools: Bash(playwright-cli:*)
----
-
-# Browser Automation with playwright-cli
-
-## Quick start
-
-```bash
-# open new browser
-playwright-cli open
-# navigate to a page
-playwright-cli goto https://playwright.dev
-# interact with the page using refs from the snapshot
-playwright-cli click e15
-playwright-cli type "page.click"
-playwright-cli press Enter
-# take a screenshot
-playwright-cli screenshot
-# close the browser
-playwright-cli close
-```
-
-## Commands
-
-### Core
-
-```bash
-playwright-cli open
-# open and navigate right away
-playwright-cli open https://example.com/
-playwright-cli goto https://playwright.dev
-playwright-cli type "search query"
-playwright-cli click e3
-playwright-cli dblclick e7
-playwright-cli fill e5 "user@example.com"
-playwright-cli drag e2 e8
-playwright-cli hover e4
-playwright-cli select e9 "option-value"
-playwright-cli upload ./document.pdf
-playwright-cli check e12
-playwright-cli uncheck e12
-playwright-cli snapshot
-playwright-cli snapshot --filename=after-click.yaml
-playwright-cli eval "document.title"
-playwright-cli eval "el => el.textContent" e5
-playwright-cli dialog-accept
-playwright-cli dialog-accept "confirmation text"
-playwright-cli dialog-dismiss
-playwright-cli resize 1920 1080
-playwright-cli close
-```
-
-### Navigation
-
-```bash
-playwright-cli go-back
-playwright-cli go-forward
-playwright-cli reload
-```
-
-### Keyboard
-
-```bash
-playwright-cli press Enter
-playwright-cli press ArrowDown
-playwright-cli keydown Shift
-playwright-cli keyup Shift
-```
-
-### Mouse
-
-```bash
-playwright-cli mousemove 150 300
-playwright-cli mousedown
-playwright-cli mousedown right
-playwright-cli mouseup
-playwright-cli mouseup right
-playwright-cli mousewheel 0 100
-```
-
-### Save as
-
-```bash
-playwright-cli screenshot
-playwright-cli screenshot e5
-playwright-cli screenshot --filename=page.png
-playwright-cli pdf --filename=page.pdf
-```
-
-### Tabs
-
-```bash
-playwright-cli tab-list
-playwright-cli tab-new
-playwright-cli tab-new https://example.com/page
-playwright-cli tab-close
-playwright-cli tab-close 2
-playwright-cli tab-select 0
-```
-
-### Storage
-
-```bash
-playwright-cli state-save
-playwright-cli state-save auth.json
-playwright-cli state-load auth.json
-
-# Cookies
-playwright-cli cookie-list
-playwright-cli cookie-list --domain=example.com
-playwright-cli cookie-get session_id
-playwright-cli cookie-set session_id abc123
-playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
-playwright-cli cookie-delete session_id
-playwright-cli cookie-clear
-
-# LocalStorage
-playwright-cli localstorage-list
-playwright-cli localstorage-get theme
-playwright-cli localstorage-set theme dark
-playwright-cli localstorage-delete theme
-playwright-cli localstorage-clear
-
-# SessionStorage
-playwright-cli sessionstorage-list
-playwright-cli sessionstorage-get step
-playwright-cli sessionstorage-set step 3
-playwright-cli sessionstorage-delete step
-playwright-cli sessionstorage-clear
-```
-
-### Network
-
-```bash
-playwright-cli route "**/*.jpg" --status=404
-playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
-playwright-cli route-list
-playwright-cli unroute "**/*.jpg"
-playwright-cli unroute
-```
-
-### DevTools
-
-```bash
-playwright-cli console
-playwright-cli console warning
-playwright-cli network
-playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
-playwright-cli tracing-start
-playwright-cli tracing-stop
-playwright-cli video-start
-playwright-cli video-stop video.webm
-```
-
-### Configuration
-
-```bash
-# Use specific browser when creating session
-playwright-cli open --browser=chrome
-playwright-cli open --browser=firefox
-# Connect to browser via extension
-playwright-cli open --extension
-# Use persistent profile
-playwright-cli open --persistent
-# Use persistent profile with custom directory
-playwright-cli open --profile=/path/to/profile
-# Start with config file
-playwright-cli open --config=my-config.json
-# Close the browser
-playwright-cli close
-# Delete user data for the default session
-playwright-cli delete-data
-```
-
-### Browser Sessions
-
-```bash
-# create new browser session named "mysession" with persistent profile
-playwright-cli -s=mysession open example.com --persistent
-playwright-cli -s=mysession click e6
-playwright-cli -s=mysession close
-playwright-cli -s=mysession delete-data
-
-playwright-cli list
-playwright-cli close-all
-playwright-cli kill-all
-```
-
-## Example: Form submission
-
-```bash
-playwright-cli open https://example.com/form
-playwright-cli snapshot
-
-playwright-cli fill e1 "user@example.com"
-playwright-cli fill e2 "password123"
-playwright-cli click e3
-playwright-cli snapshot
-playwright-cli close
-```
-
-## Example: Multi-tab workflow
-
-```bash
-playwright-cli open https://example.com
-playwright-cli tab-new https://example.com/other
-playwright-cli tab-list
-playwright-cli tab-select 0
-playwright-cli snapshot
-playwright-cli close
-```
-
-## Example: Debugging with DevTools
-
-```bash
-playwright-cli open https://example.com
-playwright-cli click e4
-playwright-cli fill e7 "test"
-playwright-cli console
-playwright-cli network
-playwright-cli close
-```
diff --git a/.gitignore b/.gitignore
index 7328085f..e9ed48e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,14 +15,32 @@ dist/
**/*secret*
**/*password*
**/*token*
+!src/utils/token-estimate.ts
__tests__/*
# Video generation intermediate files
demo-screenshots/video-frames/
demo-screenshots/*.mp4
demo-screenshots/*.gif
+!demo-screenshots/demo.gif
# Skill template assets (large binary files)
.claude/skills/promo-video/templates/
+# 临时测试文件
+test-*.js
+test-*.cjs
+test-*.txt
+test-*.md
+*-demo.txt
+*-report.md
+*-completed.txt
+
+# WeChat bot memory
+*.memory-card.json
+
+# 推广脚本和文案
+demo-screenshots/make-video-twitter.py
+demo-screenshots/twitter-post-*.txt
+
# Windows: git-bash 中 > nul 会创建字面文件 nul(cmd.exe 的空设备名)
nul
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-02-27T12-42-54-052Z.yml b/.playwright-cli/page-2026-02-27T12-42-54-052Z.yml
new file mode 100644
index 00000000..ae75ac3a
--- /dev/null
+++ b/.playwright-cli/page-2026-02-27T12-42-54-052Z.yml
@@ -0,0 +1,13 @@
+- generic [ref=e2]:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - dialog:
+ - group [active] [ref=e3]:
+ - dialog [ref=e5]:
+ - progressbar "加载中" [ref=e7]:
+ - img [ref=e9]
+ - main [ref=e13]
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-02-27T12-43-23-520Z.yml b/.playwright-cli/page-2026-02-27T12-43-23-520Z.yml
new file mode 100644
index 00000000..13794f60
--- /dev/null
+++ b/.playwright-cli/page-2026-02-27T12-43-23-520Z.yml
@@ -0,0 +1,17 @@
+- generic [ref=e2]:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - dialog:
+ - group [active] [ref=e3]:
+ - dialog [ref=e5]:
+ - generic [ref=e7]:
+ - generic [ref=e8]: 出错了。请尝试重新加载。
+ - button "重试" [ref=e9] [cursor=pointer]:
+ - generic [ref=e10]:
+ - img [ref=e11]
+ - generic [ref=e15]: 重试
+ - main [ref=e17]
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-02-27T12-43-34-519Z.yml b/.playwright-cli/page-2026-02-27T12-43-34-519Z.yml
new file mode 100644
index 00000000..758695c3
--- /dev/null
+++ b/.playwright-cli/page-2026-02-27T12-43-34-519Z.yml
@@ -0,0 +1,17 @@
+- generic [ref=e2]:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - dialog:
+ - group [ref=e3]:
+ - dialog [ref=e5]:
+ - generic [ref=e19]:
+ - generic [ref=e20]: 出错了。请尝试重新加载。
+ - button "重试" [ref=e21] [cursor=pointer]:
+ - generic [ref=e22]:
+ - img [ref=e23]
+ - generic [ref=e27]: 重试
+ - main [ref=e17]
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-02-27T12-44-01-432Z.yml b/.playwright-cli/page-2026-02-27T12-44-01-432Z.yml
new file mode 100644
index 00000000..9f356423
--- /dev/null
+++ b/.playwright-cli/page-2026-02-27T12-44-01-432Z.yml
@@ -0,0 +1,17 @@
+- generic [ref=e2]:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - dialog:
+ - group [ref=e3]:
+ - dialog [ref=e5]:
+ - generic [ref=e7]:
+ - generic [ref=e8]: 出错了。请尝试重新加载。
+ - button "重试" [active] [ref=e9] [cursor=pointer]:
+ - generic [ref=e10]:
+ - img [ref=e11]
+ - generic [ref=e15]: 重试
+ - main [ref=e17]
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-02-27T12-44-42-184Z.yml b/.playwright-cli/page-2026-02-27T12-44-42-184Z.yml
new file mode 100644
index 00000000..57433737
--- /dev/null
+++ b/.playwright-cli/page-2026-02-27T12-44-42-184Z.yml
@@ -0,0 +1,13 @@
+- generic [ref=e2]:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - dialog:
+ - group [active] [ref=e3]:
+ - dialog [ref=e5]:
+ - progressbar [ref=e8]:
+ - img [ref=e10]
+ - main [ref=e14]
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-02-27T12-45-03-059Z.yml b/.playwright-cli/page-2026-02-27T12-45-03-059Z.yml
new file mode 100644
index 00000000..91170d07
--- /dev/null
+++ b/.playwright-cli/page-2026-02-27T12-45-03-059Z.yml
@@ -0,0 +1,13 @@
+- generic [ref=e2]:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - dialog:
+ - group [active] [ref=e3]:
+ - dialog [ref=e5]:
+ - progressbar [ref=e7]:
+ - img [ref=e9]
+ - main [ref=e13]
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-02-27T12-45-16-510Z.yml b/.playwright-cli/page-2026-02-27T12-45-16-510Z.yml
new file mode 100644
index 00000000..84a34c93
--- /dev/null
+++ b/.playwright-cli/page-2026-02-27T12-45-16-510Z.yml
@@ -0,0 +1,40 @@
+- generic [ref=e2]:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - dialog:
+ - group [ref=e3]:
+ - dialog "注册 X" [ref=e15]:
+ - group [ref=e17]:
+ - generic [ref=e22]:
+ - button "关闭" [active] [ref=e24] [cursor=pointer]:
+ - img [ref=e26]
+ - img "X" [ref=e31]
+ - generic [ref=e38]:
+ - heading "注册 X" [level=1] [ref=e41]:
+ - generic [ref=e42]: 注册 X
+ - iframe [ref=e45]:
+ - button "使用 Google 账号登录。在新标签页中打开" [ref=f4e3] [cursor=pointer]:
+ - generic [ref=f4e5]:
+ - img [ref=f4e7]
+ - generic [ref=f4e14]: 使用 Google 账号登录
+ - button "使用 Apple 登录" [ref=e46] [cursor=pointer]:
+ - generic [ref=e47]:
+ - img [ref=e48]
+ - generic [ref=e52]: 使用 Apple 登录
+ - generic [ref=e57]: 或
+ - generic [ref=e62]:
+ - generic [ref=e64]: 手机号码、邮件地址或用户名
+ - textbox "手机号码、邮件地址或用户名" [ref=e67]
+ - button "下一步" [ref=e70] [cursor=pointer]:
+ - generic [ref=e73]: 下一步
+ - button "忘记密码?" [ref=e74] [cursor=pointer]:
+ - generic [ref=e77]: 忘记密码?
+ - generic [ref=e78]:
+ - generic [ref=e79]: 还没有账号?
+ - button "注册" [ref=e80]:
+ - generic [ref=e81]: 注册
+ - main [ref=e13]
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-02-27T12-46-32-103Z.yml b/.playwright-cli/page-2026-02-27T12-46-32-103Z.yml
new file mode 100644
index 00000000..ae75ac3a
--- /dev/null
+++ b/.playwright-cli/page-2026-02-27T12-46-32-103Z.yml
@@ -0,0 +1,13 @@
+- generic [ref=e2]:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - dialog:
+ - group [active] [ref=e3]:
+ - dialog [ref=e5]:
+ - progressbar "加载中" [ref=e7]:
+ - img [ref=e9]
+ - main [ref=e13]
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-02-27T12-46-36-353Z.png b/.playwright-cli/page-2026-02-27T12-46-36-353Z.png
new file mode 100644
index 00000000..213d90c0
Binary files /dev/null and b/.playwright-cli/page-2026-02-27T12-46-36-353Z.png differ
diff --git a/.playwright-cli/page-2026-02-27T12-47-02-216Z.yml b/.playwright-cli/page-2026-02-27T12-47-02-216Z.yml
new file mode 100644
index 00000000..57433737
--- /dev/null
+++ b/.playwright-cli/page-2026-02-27T12-47-02-216Z.yml
@@ -0,0 +1,13 @@
+- generic [ref=e2]:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - generic:
+ - dialog:
+ - group [active] [ref=e3]:
+ - dialog [ref=e5]:
+ - progressbar [ref=e8]:
+ - img [ref=e10]
+ - main [ref=e14]
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-02-27T12-47-09-719Z.yml b/.playwright-cli/page-2026-02-27T12-47-09-719Z.yml
new file mode 100644
index 00000000..afc822f1
--- /dev/null
+++ b/.playwright-cli/page-2026-02-27T12-47-09-719Z.yml
@@ -0,0 +1,3 @@
+- main [ref=e4]:
+ - progressbar "加载中" [ref=e7]:
+ - img [ref=e9]
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-02-27T12-47-13-561Z.png b/.playwright-cli/page-2026-02-27T12-47-13-561Z.png
new file mode 100644
index 00000000..e98be410
Binary files /dev/null and b/.playwright-cli/page-2026-02-27T12-47-13-561Z.png differ
diff --git a/.playwright-cli/proxy-config.json b/.playwright-cli/proxy-config.json
new file mode 100644
index 00000000..291291dc
--- /dev/null
+++ b/.playwright-cli/proxy-config.json
@@ -0,0 +1,5 @@
+{
+ "proxy": {
+ "server": "http://127.0.0.1:7897"
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
index a28be941..92f53aa7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -40,6 +40,12 @@ This is an educational reverse-engineering project that recreates Claude Code CL
### 铁律7:采用第一性原理思考和解决问题
+### 铁律8:先查 Skills,再动手
+- **执行任务前,必须检查可用 Skills 列表中是否有匹配当前任务的 Skill**。
+- 有匹配的 Skill 就**必须先调用**,获取专业指导后再动手。
+- "我自己会"不是跳过 Skill 的理由——Skill 里有你没想到的最佳实践。
+- 违反此条 = 用业余方式做了本可以专业完成的事。
+
## 项目性质
- 这是一个**复刻还原项目**,目标是还原 Claude Code CLI v2.1.4。
- 唯一准则:**保持和官方一致**。不要"改进",不要"优化",不要"我觉得这样更好"。
diff --git a/Dockerfile.railway b/Dockerfile.railway
new file mode 100644
index 00000000..66371458
--- /dev/null
+++ b/Dockerfile.railway
@@ -0,0 +1,55 @@
+# ============================================
+# Stage 1: Build
+# ============================================
+FROM node:18-slim AS builder
+
+RUN apt-get update && apt-get install -y --fix-missing \
+ python3 \
+ make \
+ g++ \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm install
+
+COPY . .
+RUN npm run build
+
+# Frontend build
+RUN cd src/web/client && \
+ npm install && \
+ npm run build
+
+# ============================================
+# Stage 2: Production
+# ============================================
+FROM node:18-slim
+
+RUN apt-get update && apt-get install -y --fix-missing \
+ git \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Copy build artifacts
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/package.json ./
+COPY --from=builder /app/src/web/client/dist ./src/web/client/dist
+
+# Create working directories
+RUN mkdir -p /root/.claude /workspace
+WORKDIR /workspace
+
+# Railway injects PORT env var
+EXPOSE ${PORT:-3456}
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD curl -f http://localhost:${PORT:-3456}/api/health || exit 1
+
+# Start Web UI (no evolve mode for public demo, use compiled dist)
+CMD ["node", "/app/dist/web-cli.js", "-H", "0.0.0.0"]
diff --git a/PLAN.md b/PLAN.md
deleted file mode 100644
index caa9c230..00000000
--- a/PLAN.md
+++ /dev/null
@@ -1,70 +0,0 @@
-# 定时任务完整 CRUD 支持
-
-## 现状
-
-- Create/Read/Delete 完整
-- Update 严重缺失:除 enabled 开关外所有字段创建后不可修改
-- 后端无通用 update API
-- 前端无编辑表单
-- ScheduleTask 工具无 update action
-
-## 方案
-
-### 1. 后端:添加 PATCH API (`schedule-api.ts`)
-
-新增 `PATCH /api/schedule/tasks/:id` 路由:
-- 接受可修改字段:name, prompt, model, timeoutMs, triggerAt, intervalMs, watchPaths, watchEvents, debounceMs, notify, feishuChatId, silentToken, context
-- 不可修改字段:id, createdAt, createdBy, workingDir, type(类型变更太复杂,改了得重算调度逻辑)
-- 验证逻辑:类型特定字段校验(interval 改 intervalMs 要 > 0,once 改 triggerAt 要有效时间戳)
-- 修改后调用 `store.signalReload()` 通知调度器
-
-### 2. 前端:详情页增加内联编辑 (`SchedulePage/index.tsx`)
-
-**方案:点击字段进入编辑模式(inline edit)**
-
-不用弹模态框,直接在详情页的各 infoCard 上点击即可编辑:
-- 可编辑字段显示一个编辑图标(铅笔)
-- 点击后 infoValue 变为 input/textarea
-- 修改后自动 PATCH 保存(失焦或回车确认)
-- Prompt 区域:点击后变为 textarea
-- 不可编辑字段(type, createdAt, runCount 等)保持只读
-
-需要的状态:
-- `editingField: string | null` — 当前编辑中的字段名
-- `editValue: string` — 当前编辑值
-
-可编辑字段列表:
-| 字段 | 编辑控件 | 适用类型 |
-|------|---------|---------|
-| name | input text | 全部 |
-| prompt | textarea | 全部 |
-| model | select | 全部 |
-| intervalMs | number + unit select | interval |
-| triggerAt | datetime-local | once |
-| watchPaths | input text (逗号分隔) | watch |
-| silentToken | input text | 全部 |
-| timeoutMs | number (秒) | 全部 |
-
-### 3. ScheduleTask 工具增加 update action (`schedule.ts`)
-
-- 新增 `action: 'update'`
-- 输入:taskId + 要修改的字段(与 create 类似但都是可选的)
-- 调用 `store.updateTask()` 后 `signalReload()`
-
-### 4. WebScheduler 调度器兼容 (`web-scheduler.ts`)
-
-**无需改动**。WebScheduler 每次 tick 从 store 读取最新数据,PATCH 修改后 signalReload 触发重新加载,调度器自然读到新值。interval 类型改了 intervalMs 后,下次 `recomputeNextRuns` 会用新值计算。
-
-## 修改文件清单
-
-1. `src/web/server/routes/schedule-api.ts` — 新增 PATCH 路由
-2. `src/web/client/src/pages/SchedulePage/index.tsx` — 添加内联编辑
-3. `src/web/client/src/pages/SchedulePage/SchedulePage.module.css` — 编辑相关样式
-4. `src/tools/schedule.ts` — 新增 update action
-5. `src/prompt/templates.ts` — ScheduleTask 工具描述更新(系统提示词里的描述)
-
-## 不做的事
-
-- 不支持修改 type(once↔interval↔watch):类型变更涉及调度逻辑重构,复杂度过高且场景少,用户可以删了重建
-- 不支持修改 id/createdAt/createdBy/workingDir:这些是系统字段
-- 不支持修改执行状态字段(lastRunAt, runCount 等):由系统自动管理
diff --git a/README.md b/README.md
index 31df1c5d..3fa7f972 100644
--- a/README.md
+++ b/README.md
@@ -1,475 +1,178 @@
-# Claude Code Open - Open Source AI Coding Platform
+# Claude Code Open
-> The world needs an open-source Claude Code. It will become the foundational infrastructure of AI in the future, running on every PC.
+### Free & Open Source Claude Code with Web IDE, Multi-Agent, and Self-Evolution
-[](https://www.chatbi.site)
-[](http://voicegpt.site:3456/)
+[](https://www.npmjs.com/package/claude-code-open)
[](https://github.com/kill136/claude-code-open)
[](LICENSE)
[](https://nodejs.org)
+[](https://discord.gg/bNyJKk6PVZ)
-[Visit Website](https://www.chatbi.site) | [Live Demo](http://voicegpt.site:3456/) | [中文文档](README.zh-CN.md) | [操作手册](https://www.chatbi.site/zh/user-guide.html) | [Discord](https://discord.gg/bNyJKk6PVZ)
+[Website](https://www.chatbi.site) | [Live Demo](https://voicegpt.site) | [User Guide](https://www.chatbi.site/zh/user-guide.html) | [Discord](https://discord.gg/bNyJKk6PVZ) | [中文](README.zh-CN.md)
-### Demo
-
-
-
+
+
-**Web UI IDE** • **Blueprint Multi-Agent** • **37+ Tools** • **Self-Evolution**
-
-
-
-
-
-
-
-Blueprint Multi-Agent System
-Real-time AI Streaming
-
-
-
-> [Watch Promo Video](demo-screenshots/promo-video.mp4) • [Try Live Demo](http://voicegpt.site:3456/) • [Join Discord](https://discord.gg/bNyJKk6PVZ)
+
Watch full video | Try Live Demo
-A reverse-engineered open-source reimplementation based on `@anthropic-ai/claude-code`.
-
-**For educational and research purposes only.**
-
-## Disclaimer
-
-This is an educational project for studying and learning CLI tool architecture design. This is **NOT** the official Claude Code source code, but a reimplementation based on public APIs and type definitions.
-
-For the official Claude Code, please install the official version:
-```bash
-npm install -g @anthropic-ai/claude-code
-```
-
-## Features at a Glance
-
-| Category | Highlights |
-| --- | --- |
-| **37+ Built-in Tools** | File ops, search, execution, web access, task management, scheduled tasks, browser automation, and more |
-| **Web UI IDE** | Full browser IDE with Monaco editor, file tree, AI-enhanced editing, Blueprint visualization, Swarm console |
-| **Blueprint Multi-Agent** | Smart Planner + Lead Agent + Autonomous Workers + Task Queue + Quality Reviewer + E2E Testing |
-| **One-Click Installer** | Automated scripts for Windows/macOS/Linux with auto dependency detection (Node.js, Git, g++, make) |
-| **Scheduled Task Daemon** | Background daemon for time-based tasks, interval jobs, file watching, and multi-channel notifications |
-| **Self-Evolution** | AI can modify its own source code with safety checks and hot-reload |
-| **Checkpoint & Rewind** | File snapshot management and session time-travel |
-| **Memory System** | Vector store, BM25 search, intent extraction, conversation memory |
-| **MCP Protocol** | Full Model Context Protocol with auto-discovery (stdio, HTTP, SSE) |
-| **Multi-Provider** | Anthropic, AWS Bedrock, Google Vertex AI |
-| **Proxy Server** | Share your Claude subscription across devices |
-| **Browser Automation** | Custom browser control + Chrome MCP integration + Playwright support |
-| **i18n** | Chinese and English language support |
-| **Feishu Bot** | Feishu (Lark) messaging integration with Web UI mode |
-| **WeChat Bot** | WeChat messaging integration |
-| **Docker Support** | Containerized deployment with mirror acceleration |
-| **Auto-Update** | Version management with rollback support |
-| **Plugin & Hook System** | Extensible architecture with lifecycle hooks |
-| **Fast Mode** | Optimized output speed using the same model |
-| **Extended Thinking** | Extended reasoning mode for deeper analysis |
-| **Teams** | Team collaboration features |
-
## Why Claude Code Open?
-- **Open Source & Transparent** — Full MIT licensed source code. No black boxes, complete community-driven development.
-- **Web UI IDE Experience** — Not just a CLI. A complete browser-based IDE with Monaco editor, VS Code-style file tree, AI-enhanced code editing (hover tips, ask AI, code tour, heatmap decorations), Blueprint visualization, and Swarm multi-agent console.
-- **Multi-Agent Collaboration** — Blueprint system breaks complex tasks across multiple AI agents working in parallel, with E2E testing, visual comparison, and verification services.
-- **24/7 Automation** — Scheduled task daemon runs AI workflows automatically: natural language time config, file watching, multi-channel notifications (Desktop + Feishu), SQLite persistence.
-- **One-Click Install** — Single command for Windows/macOS/Linux. Auto-detects and installs missing dependencies (Node.js, Git, g++, make). Creates desktop shortcuts automatically.
-- **Self-Evolution** — The AI can modify its own source code, run TypeScript compilation checks, and hot-reload — enabling continuous self-improvement.
-
-## Quick Installation (Recommended)
+| | Official Claude Code | Claude Code Open |
+|---|---|---|
+| **Price** | $20/month (Max plan required) | Free (bring your own API key) |
+| **Interface** | Terminal only | Terminal + **Web IDE** (Monaco editor, file tree, AI-enhanced editing) |
+| **Complex tasks** | Single agent | **Multi-agent Blueprint** system (parallel workers, task queue, auto-review) |
+| **Customization** | Closed source | Fully open source, **Self-Evolution** (AI modifies its own code) |
+| **Deployment** | Local only | Local, Docker, Cloud, **share via Proxy Server** |
+| **Integrations** | GitHub only | GitHub, Feishu, WeChat, MCP protocol, 37+ tools |
-### Windows Quick Install (Easiest)
+## Quick Start
-**Option A: One-Click Installer** — download and double-click, no command line needed!
-
-[](https://raw.githubusercontent.com/kill136/claude-code-open/private_web_ui/install.bat)
-[](https://gitee.com/lubanbbs/claude-code-open/raw/private_web_ui/install.bat)
+```bash
+# Install globally
+npm install -g claude-code-open
-1. Click the button above to download `install.bat`
-2. Double-click the downloaded file to run
-3. Done! The installer handles everything (Node.js, dependencies, build, shortcut)
+# Set your API key
+export ANTHROPIC_API_KEY="sk-..." # or on Windows: $env:ANTHROPIC_API_KEY="sk-..."
-**Option B: Pre-built Package** — download, unzip, run. No build needed!
+# CLI mode
+claude
-[](https://github.com/kill136/claude-code-open/releases/latest)
+# Web IDE mode
+claude-web
+```
-1. Download `claude-code-open-windows-x64-*.zip` from the latest Release
-2. Unzip to any folder
-3. Double-click `start.bat` to launch (requires [Node.js](https://nodejs.org/) pre-installed)
+That's it. Open `http://localhost:3456` for the Web IDE.
----
+### Other install methods
-### macOS / Linux Quick Install
+
+One-click installer (no Node.js required)
-**Option A: One-Click Install Script**
+**Windows:** Download [install.bat](https://github.com/kill136/claude-code-open/releases/latest/download/install.bat) and double-click.
+**macOS / Linux:**
```bash
curl -fsSL https://raw.githubusercontent.com/kill136/claude-code-open/private_web_ui/install.sh | bash
```
-China mirror:
+**China mirror:**
```bash
curl -fsSL https://gitee.com/lubanbbs/claude-code-open/raw/private_web_ui/install.sh | bash
```
+
-**Option B: Pre-built Package** — download, extract, run!
-
-[](https://github.com/kill136/claude-code-open/releases/latest)
-
-| Platform | File |
-| --- | --- |
-| macOS Apple Silicon (M1/M2/M3/M4) | `claude-code-open-macos-arm64-*.tar.gz` |
-| macOS Intel | `claude-code-open-macos-x64-*.tar.gz` |
-| Linux x64 | `claude-code-open-linux-x64-*.tar.gz` |
-
-```bash
-# Extract and run (requires Node.js pre-installed)
-tar -xzf claude-code-open-*.tar.gz
-cd claude-code-open-*/
-./start.sh
-```
-
----
-
-### Advanced: PowerShell (Windows)
-
-```powershell
-irm https://raw.githubusercontent.com/kill136/claude-code-open/private_web_ui/install.ps1 | iex
-```
-
-The install script will automatically:
-- ✅ Detect and install missing dependencies (Node.js, Git, g++, make)
-- ✅ Clone the repository
-- ✅ Install all npm dependencies
-- ✅ Build frontend and backend
-- ✅ Create desktop shortcut
-- ✅ Preset API configuration
-- ✅ Link global commands
-
-**After installation:**
-1. Double-click the desktop shortcut "Claude Code WebUI"
-2. Browser opens http://localhost:3456 automatically
-3. Start using!
-
-### Manual Installation
-
-```bash
-# Clone repository
-git clone https://github.com/kill136/claude-code-open.git
-cd claude-code-open
-
-# Install dependencies
-npm install
-
-# Build frontend
-cd src/web/client
-npm install
-npm run build
-cd ../../..
-
-# Build backend
-npm run build
-
-# Link globally (optional)
-npm link
-
-# Optional: Install Playwright CLI (browser automation)
-npm run install:playwright
-```
-
-### Windows Notes
-
-**Native addon compilation (usually NOT required):**
-
-The project depends on native addons (`better-sqlite3`, `node-pty`, `sharp`, etc.), but they all ship with **prebuilt binaries** for Windows x64. Under normal circumstances, `npm install` downloads the prebuilt binaries directly — no compilation needed.
-
-If prebuilt download fails (e.g., network issues, uncommon Node.js version), npm falls back to compiling from source. **Only in this case** do you need:
-
-- **Python 3.6+** — required by node-gyp
-- **Visual Studio Build Tools 2022** — "Desktop development with C++" workload
-
-**Environment variable conflicts:**
-
-| Variable | Purpose |
-| --- | --- |
-| `ANTHROPIC_API_KEY` / `CLAUDE_API_KEY` | API authentication |
-| `ANTHROPIC_BASE_URL` | Custom API endpoint (default: `https://api.anthropic.com`) |
-
-If you already have these set system-wide, set them per session to avoid conflicts:
-
-```powershell
-# PowerShell (current session only)
-$env:ANTHROPIC_API_KEY="your-key-for-this-project"
-$env:ANTHROPIC_BASE_URL="https://your-api-endpoint"
-```
-
-> Note: The `.env` file in the project root is **NOT** loaded automatically. Environment variables must be set via system settings, `settings.json`, or the `--env` CLI flag.
-
-### Docker Deployment
+
+Docker
```bash
-# Build Docker image
-docker build -t claude-code-open .
-
-# For users in China (with mirror acceleration)
-docker build --build-arg REGISTRY=docker.1ms.run -t claude-code-open .
-
-# Run CLI
+# Web IDE
docker run -it \
-e ANTHROPIC_API_KEY=your-api-key \
+ -p 3456:3456 \
-v $(pwd):/workspace \
-v ~/.claude:/root/.claude \
- claude-code-open
+ claude-code-open node /app/dist/web-cli.js --host 0.0.0.0
-# Run Web UI
+# CLI only
docker run -it \
-e ANTHROPIC_API_KEY=your-api-key \
- -p 3456:3456 \
-v $(pwd):/workspace \
-v ~/.claude:/root/.claude \
- claude-code-open node /app/dist/web-cli.js --host 0.0.0.0
+ claude-code-open
```
+
-## Usage
-
-### CLI Mode
+
+From source
```bash
-# Interactive mode
-npm run dev
-
-# Or run after building
-node dist/cli.js
-
-# With initial prompt
-node dist/cli.js "Hello, please analyze this project"
-
-# Print mode (non-interactive)
-node dist/cli.js -p "Explain this code"
-
-# Specify model (opus/sonnet/haiku)
-node dist/cli.js -m opus "Complex task"
-
-# Resume last session
-node dist/cli.js --resume
-
-# List sessions
-node dist/cli.js --list
-
-# Fork a session
-node dist/cli.js --fork
+git clone https://github.com/kill136/claude-code-open.git
+cd claude-code-open
+npm install && npm run build
+node dist/cli.js # CLI
+node dist/web-cli.js # Web IDE
```
+
-### Web UI Mode
+## Key Features
-```bash
-# Development mode
-npm run web
+### Web IDE
-# Production mode
-npm run web:start
+A full browser-based IDE built with React + Monaco Editor + WebSocket:
-# Custom port and host
-npm run web -- -p 8080 -H 0.0.0.0
+- **Monaco Editor** with multi-tab, syntax highlighting, AI hover tips
+- **VS Code-style file tree** with right-click context menus
+- **AI-enhanced editing** — select code and ask AI, code tour, heatmap decorations
+- **Real-time streaming** of AI responses via WebSocket
+- **Session management** — create, resume, fork, export
+- **Checkpoint & Rewind** — file snapshots and session time-travel
-# With ngrok public tunnel
-npm run web -- --ngrok
+
+
+
+
+
+
-# Self-evolution mode
-npm run web:evolve
-```
+### Blueprint Multi-Agent System
-### Proxy Server Mode
+Break complex tasks across multiple AI agents working in parallel:
-Share your Claude subscription with other devices:
+- **Smart Planner** — decomposes tasks into an execution plan
+- **Lead Agent** — coordinates workers, tracks progress
+- **Autonomous Workers** — independent execution with full tool access
+- **Task Queue** — priority-based scheduling with persistence
+- **Quality Reviewer** — automated review and verification
-```bash
-# Start proxy server
-npm run proxy
-# or after building
-node dist/proxy-cli.js --proxy-key my-secret
-
-# Client usage (on other devices)
-export ANTHROPIC_API_KEY="my-secret"
-export ANTHROPIC_BASE_URL="http://your-server-ip:8082"
-claude
-```
+
-### Feishu Bot Mode
+### Self-Evolution
-```bash
-# Start Feishu bot
-npm run feishu
+The AI can modify its own source code, run TypeScript compilation checks, and hot-reload:
-# Feishu bot with Web UI
-npm run feishu:webui
```
-
-### WeChat Bot Mode
-
-```bash
-npm run wechat
+You: "Add a new tool that queries weather data"
+Claude: *writes the tool, compiles, restarts itself, tool is now available*
```
-## Web UI Features
-
-The Web UI is a full-featured browser-based IDE built with React, Express, and WebSocket:
+### 37+ Built-in Tools
-```bash
-npm run web
-# Open http://localhost:3456
-```
-
-**Core Features:**
-- **Monaco Editor** — Multi-tab code editing with syntax highlighting
-- **File Tree** — VS Code-style file browser with right-click context menus
-- **AI-Enhanced Editing** — Intelligent hover tips, select-to-ask AI, code tour, heatmap decorations
-- **Real-time WebSocket** — Live streaming of AI responses
-- **Session Management** — Create, resume, fork, and export sessions
-- **Blueprint Visualization** — Visual task decomposition and agent coordination
-- **Swarm Console** — Multi-agent monitoring with task tree, architecture flow graph
-- **Terminal Integration** — In-browser terminal panel
-- **Checkpoint & Rewind** — File snapshot management and session time-travel
-- **Continuous Development** — Cycle review, impact analysis, TDD panel
-- **Authentication** — OAuth and API key support
-- **Artifacts Panel** — Rich content rendering
-- **Debug Panel** — Development debugging tools
-
-## Blueprint Multi-Agent System
-
-Orchestrate complex tasks with multiple AI agents working in parallel:
-
-- **Smart Planner** — Intelligent task decomposition and planning (97KB of planning logic)
-- **Lead Agent** — Coordinates worker agents, tracks progress, auto-project-selection
-- **Autonomous Workers** — Independent task execution with full tool access
-- **Task Queue** — Priority-based task scheduling with persistence
-- **Task Reviewer** — Quality assurance and verification
-- **Real-time Coordinator** — Agent communication, synchronization, and conflict resolution
-- **Model Selector** — Adaptive model selection per task complexity
-- **E2E Test Agent** — End-to-end testing automation
-- **Environment Checker** — Runtime environment validation
-- **Visual Comparator** — Visual diff and comparison
-- **Verification Service** — Result verification pipeline
-
-## Scheduled Task Daemon
-
-A background daemon system for automated task execution:
-
-**Features:**
-- **Time-based Tasks** — Natural language scheduling ("tomorrow 3pm", "every day at 9am", "in 2 hours")
-- **File Watching** — Monitor file changes and trigger AI tasks automatically
-- **Multi-channel Notifications** — Desktop notifications and Feishu (Lark) messaging
-- **Persistent Storage** — SQLite-based task storage survives restarts
-- **Model Selection** — Choose different Claude models per task
-
-**Usage:**
-```bash
-# Start daemon
-claude daemon start
+| Category | Tools |
+|---|---|
+| File ops | Read, Write, Edit, MultiEdit, Glob, Grep |
+| Execution | Bash, background tasks, task output |
+| Web | WebFetch, WebSearch |
+| Code | NotebookEdit, LSP, Tree-sitter parsing |
+| Browser | Playwright-based automation |
+| Planning | Plan mode, Blueprint, sub-agents |
+| Memory | Semantic search, vector store, BM25 |
+| Integration | MCP protocol, Skills system |
+| Scheduling | Cron-like daemon, file watching, notifications |
-# Schedule via conversation
-"Schedule a daily code review at 9am and notify me on Feishu"
-```
+### More
-**Task Types:** `once` (one-time), `interval` (recurring), `watch` (file monitoring)
-
-## Implemented Tools (37+)
-
-| Tool | Description |
-| --- | --- |
-| **File Operations** | |
-| Read | File reading with image/PDF/Notebook support + external modification detection |
-| Write | File writing with overwrite protection |
-| Edit | File editing (string replacement) |
-| MultiEdit | Batch file editing (atomic operations) |
-| **Search & Discovery** | |
-| Glob | File pattern matching |
-| Grep | Content search (ripgrep-based) |
-| **Execution** | |
-| Bash | Command execution with background & sandbox support |
-| BashHistory | Command history tracking |
-| TaskOutput | Get background command/agent output |
-| **Web Access** | |
-| WebFetch | Web page fetching with caching |
-| WebSearch | Server-side web search |
-| **Task Management** | |
-| TodoWrite | Task management with auto-reminder system |
-| Task | Sub-agents (explore, plan, guide, etc.) |
-| TaskStatus | Query task execution status |
-| ScheduleTask | Create/cancel/list scheduled tasks |
-| **Planning** | |
-| EnterPlanMode | Enter plan mode |
-| ExitPlanMode | Exit plan mode |
-| **Interaction** | |
-| AskUserQuestion | Ask user questions (multiSelect, options, validation) |
-| **Code Tools** | |
-| NotebookEdit | Jupyter Notebook cell editing |
-| NotebookWrite | Jupyter Notebook creation |
-| LSP | Language Server Protocol integration |
-| **Integration** | |
-| MCP Tools | ListMcpResources, ReadMcpResource, MCPSearch |
-| Skill | Skill system with args parameter |
-| **Browser** | |
-| Browser | Custom browser automation and control |
-| **Memory** | |
-| MemorySearch | Semantic memory search |
-| **Multi-Agent** | |
-| GenerateBlueprint | Generate task execution blueprints |
-| GenerateDesign | Generate design documents |
-| StartLeadAgent | Launch lead agent coordinator |
-| DispatchWorker | Dispatch autonomous worker agents |
-| SubmitReview | Submit quality reviews |
-| SubmitE2EResult | Submit E2E test results |
-| TriggerE2ETest | Trigger E2E testing |
-| UpdateTaskPlan | Update task execution plans |
-| **Advanced** | |
-| SelfEvolve | AI self-modification with safety checks |
-| StructuredOutput | Structured data output |
-| OutputPersistence | Persistent output storage |
-| Teammate | Team collaboration |
+- **Proxy Server** — share your API key across devices
+- **Multi-provider** — Anthropic, AWS Bedrock, Google Vertex AI
+- **Plugin & Hook system** — extend with custom logic
+- **i18n** — English and Chinese
+- **Feishu & WeChat bots** — messaging integrations
## Configuration
-Set up your API key:
-
-**Linux/macOS:**
-```bash
-export ANTHROPIC_API_KEY=your-api-key
-```
-
-**Windows PowerShell:**
-```powershell
-$env:ANTHROPIC_API_KEY="your-api-key"
-```
-
-### Environment Variables
-
| Variable | Description | Default |
-| --- | --- | --- |
-| `ANTHROPIC_API_KEY` / `CLAUDE_API_KEY` | API Key | - |
+|---|---|---|
+| `ANTHROPIC_API_KEY` | API key (required) | - |
| `ANTHROPIC_BASE_URL` | Custom API endpoint | `https://api.anthropic.com` |
-| `CLAUDE_CODE_LANG` | Language (en/zh) | auto-detect |
-| `BASH_MAX_OUTPUT_LENGTH` | Max Bash output length | 30000 |
-| `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | Max output tokens | 32000 |
-| `USE_BUILTIN_RIPGREP` | Use system ripgrep | false |
-
-### Multi-Provider Support
-
-- **Anthropic** — Direct API (default)
-- **AWS Bedrock** — Set `ANTHROPIC_BEDROCK=1` and configure AWS credentials
-- **Google Vertex AI** — Set `ANTHROPIC_VERTEX=1` and configure GCP credentials
+| `CLAUDE_CODE_LANG` | Language (`en`/`zh`) | auto-detect |
### MCP Protocol
-Full Model Context Protocol implementation:
-
```json
// .claude/settings.json
{
@@ -483,214 +186,36 @@ Full Model Context Protocol implementation:
}
```
-Supports stdio, HTTP, and SSE transports with auto-discovery.
-
-### Hooks System
-
-Execute custom scripts before/after tool calls:
-
-```json
-{
- "hooks": [
- {
- "event": "PreToolUse",
- "matcher": "Bash",
- "command": "/path/to/script.sh",
- "blocking": true
- }
- ]
-}
-```
-
-Events: `PreToolUse`, `PostToolUse`, `PrePromptSubmit`, `PostPromptSubmit`, `Notification`, `Stop`
-
-## Project Structure
-
-```
-src/
-├── cli.ts # CLI entry point (Commander.js)
-├── web-cli.ts # Web UI entry point
-├── proxy-cli.ts # Proxy server entry point
-├── feishu-cli.ts # Feishu bot entry point
-├── wechat-cli.ts # WeChat bot entry point
-├── index.ts # Main export barrel
-│
-├── core/ # Core engine
-│ ├── client.ts # Anthropic API client (streaming, retry, cost)
-│ ├── session.ts # Session state management
-│ ├── loop.ts # Conversation orchestrator
-│ └── backgroundTasks.ts # Async background task processing
-│
-├── tools/ # 37+ tools (see tools table above)
-│
-├── web/ # Web UI system
-│ ├── server/ # Express + WebSocket backend
-│ │ ├── websocket.ts # WebSocket handler
-│ │ ├── conversation.ts # Conversation manager
-│ │ ├── session-manager.ts
-│ │ ├── auth-manager.ts # Authentication
-│ │ ├── routes/ # API routes
-│ │ └── handlers/ # Request handlers
-│ └── client/ # React frontend
-│ └── src/
-│ ├── components/
-│ │ ├── CodeView/ # Monaco editor + file tree + AI hooks
-│ │ ├── BlueprintSummaryCard/
-│ │ ├── continuous/ # Continuous dev panels
-│ │ ├── config/ # Settings panels
-│ │ └── ... # 40+ UI components
-│ ├── hooks/ # Custom React hooks
-│ └── contexts/ # React contexts
-│
-├── blueprint/ # Blueprint multi-agent system (16 files)
-│ ├── smart-planner.ts # Intelligent task planner
-│ ├── lead-agent.ts # Lead agent coordinator
-│ ├── autonomous-worker.ts # Autonomous worker
-│ ├── task-queue.ts # Task priority queue
-│ ├── task-reviewer.ts # Quality reviewer
-│ ├── realtime-coordinator.ts # Real-time coordination
-│ ├── model-selector.ts # Adaptive model selection
-│ ├── e2e-test-agent.ts # E2E testing agent
-│ ├── verification-service.ts # Result verification
-│ ├── visual-comparator.ts # Visual diff
-│ └── ...
-│
-├── agents/ # Specialized sub-agents
-│ ├── explore.ts # Codebase exploration
-│ ├── plan.ts # Implementation planning
-│ ├── guide.ts # Documentation guide
-│ ├── parallel.ts # Parallel execution
-│ ├── monitor.ts # Monitoring agent
-│ └── resume.ts # Session resume
-│
-├── memory/ # Unified memory system
-│ ├── unified-memory.ts # Memory manager
-│ ├── vector-store.ts # Vector storage
-│ ├── bm25-engine.ts # BM25 text search
-│ ├── chat-memory.ts # Conversation memory
-│ ├── embedder.ts # Embedding model
-│ └── intent-extractor.ts # Intent extraction
-│
-├── checkpoint/ # File snapshot management
-├── rewind/ # Session time-travel
-├── updater/ # Auto-update system
-├── browser/ # Custom browser control
-├── chrome/ # Chrome integration
-├── chrome-mcp/ # Chrome MCP bridge
-├── daemon/ # Scheduled task daemon
-├── feishu/ # Feishu (Lark) integration
-├── wechat/ # WeChat integration
-├── i18n/ # Internationalization (en, zh)
-├── teams/ # Team management
-├── mcp/ # MCP protocol (full implementation)
-├── permissions/ # Permission system
-├── session/ # Session persistence
-├── context/ # Context management & summarization
-├── config/ # Configuration management
-├── models/ # Model config (Anthropic/Bedrock/Vertex)
-├── hooks/ # Hook system
-├── plugins/ # Plugin system
-├── commands/ # Slash commands
-├── auth/ # Authentication (API Key + OAuth)
-├── parser/ # Code parsing (Tree-sitter WASM)
-├── search/ # Search (ripgrep integration)
-├── proxy/ # Proxy server
-├── providers/ # Cloud providers
-├── fast-mode/ # Fast mode
-├── lsp/ # Language Server Protocol
-├── git/ # Git operations
-├── github/ # GitHub integration
-├── sandbox/ # Sandbox execution
-├── security/ # Security constraints
-├── trust/ # Trust verification
-├── ratelimit/ # Rate limiting
-├── rules/ # Rule engine
-├── diagnostics/ # Diagnostic tools
-├── notifications/ # Notification system
-├── ui/ # Terminal UI (Ink/React)
-├── streaming/ # Streaming I/O
-├── telemetry/ # Local telemetry
-├── types/ # TypeScript definitions
-└── utils/ # Utility functions
-```
-
-## Slash Commands
-
-| Command | Description |
-| --- | --- |
-| `/help` | Show help |
-| `/clear` | Clear conversation history |
-| `/status` | Show session status |
-| `/resume` | Resume a previous session |
-| `/context` | Show context usage |
-| `/compact` | Compress conversation history |
-| `/rename` | Rename current session |
-| `/export` | Export session (JSON/Markdown) |
-| `/transcript` | Export session transcript |
-| `/config` | View configuration |
-| `/tools` | List available tools |
-| `/model` | View/switch model |
-| `/fast` | Toggle fast mode |
-| `/exit` | Exit |
-
-## Testing
+## CLI Reference
```bash
-npm test # Run all tests (vitest)
-npm run test:unit # Unit tests only
-npm run test:integration # Integration tests
-npm run test:e2e # End-to-end CLI tests
-npm run test:coverage # Run with coverage report
-npm run test:watch # Watch mode
-npm run test:ui # Vitest UI
+claude # Interactive mode
+claude "Analyze this project" # With initial prompt
+claude -p "Explain this code" # Print mode (non-interactive)
+claude -m opus "Complex task" # Specify model
+claude --resume # Resume last session
+claude-web # Web IDE
+claude-web -p 8080 -H 0.0.0.0 # Custom port and host
+claude-web --ngrok # Public tunnel
+claude-web --evolve # Self-evolution mode
```
-## Development
-
-```bash
-# Development mode (using tsx)
-npm run dev
-
-# Web UI development
-npm run web
-
-# Web UI with file watch
-npm run web:dev
+## Community
-# Build
-npm run build
+- **Website:** [chatbi.site](https://www.chatbi.site)
+- **Discord:** [Join us](https://discord.gg/bNyJKk6PVZ)
+- **X (Twitter):** [@wangbingjie1989](https://x.com/wangbingjie1989)
-# Type checking
-npx tsc --noEmit
-```
+## Contributing
-## Tech Stack
-
-- **TypeScript** — Type safety
-- **Anthropic SDK** — API calls
-- **Ink + React** — Terminal UI
-- **Express + WebSocket** — Web backend
-- **React + Monaco Editor** — Web frontend
-- **Commander** — CLI framework
-- **Zod** — Schema validation
-- **Tree-sitter WASM** — Code parsing
-- **better-sqlite3** — Local database
-- **sharp** — Image processing
-- **ngrok** — Public tunnel
-- **Vitest** — Testing framework
+PRs and issues are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
-## Community
+## Acknowledgment
-- **Website:** https://www.chatbi.site
-- **Discord:** [Join our Discord](https://discord.gg/bNyJKk6PVZ)
-- **X (Twitter):** [@wangbingjie1989](https://x.com/wangbingjie1989)
+This project is inspired by Anthropic's Claude Code CLI. It is an independent open-source reimplementation using public APIs. For the official version, see [@anthropic-ai/claude-code](https://www.npmjs.com/package/@anthropic-ai/claude-code).
## License
-This project is for educational purposes only. Original Claude Code is owned by Anthropic PBC.
-
----
-
-*This project is a reverse engineering study of obfuscated code and does not represent the official implementation.*
+MIT
[中文版 README](README.zh-CN.md)
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 0b557bfc..b4c6aba7 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,475 +1,180 @@
-# Claude Code Open - 开源 AI 编程平台
+# Claude Code Open
-> 世界需要一个开源的 Claude Code。它将成为未来 AI 的基础设施,运行在每台 PC 上。
+### 免费开源的 Claude Code,带 Web IDE、多智能体和自我进化
-[](https://www.chatbi.site)
-[](http://voicegpt.site:3456/)
+[](https://www.npmjs.com/package/claude-code-open)
[](https://github.com/kill136/claude-code-open)
[](LICENSE)
[](https://nodejs.org)
+[](https://discord.gg/bNyJKk6PVZ)
-[访问网站](https://www.chatbi.site) | [在线体验](http://voicegpt.site:3456/) | [操作手册](https://www.chatbi.site/zh/user-guide.html) | [English README](README.md) | [Discord](https://discord.gg/bNyJKk6PVZ)
+[官网](https://www.chatbi.site) | [在线体验](https://voicegpt.site) | [操作手册](https://www.chatbi.site/zh/user-guide.html) | [Discord](https://discord.gg/bNyJKk6PVZ) | [English](README.md)
-### 演示
-
-
-
+
+
-**Web UI IDE** • **蓝图多智能体** • **37+ 工具** • **自我进化**
-
-
-
-
-
-
-
-蓝图多智能体系统
-实时 AI 流式响应
-
-
-
-> [观看宣传视频](demo-screenshots/promo-video.mp4) • [在线体验](http://voicegpt.site:3456/) • [加入 Discord](https://discord.gg/bNyJKk6PVZ)
+
观看完整视频 | 在线体验
-基于 `@anthropic-ai/claude-code` 的开源逆向重新实现。
-
-**仅用于教育和研究目的。**
-
-## 免责声明
-
-这是一个教育项目,用于研究和学习 CLI 工具的架构设计。这**不是**官方 Claude Code 的源代码,而是基于公开 API 和类型定义的重新实现。
-
-如需使用官方 Claude Code,请安装官方版本:
-```bash
-npm install -g @anthropic-ai/claude-code
-```
-
-## 功能概览
-
-| 类别 | 亮点 |
-| --- | --- |
-| **37+ 内置工具** | 文件操作、搜索、执行、Web 访问、任务管理、定时任务、浏览器自动化等 |
-| **Web UI IDE** | 完整的浏览器 IDE,集成 Monaco 编辑器、文件树、AI 增强编辑、Blueprint 可视化、Swarm 控制台 |
-| **Blueprint 多 Agent** | 智能规划器 + Lead Agent + 自主 Worker + 任务队列 + 质量审查员 + E2E 测试 |
-| **一键安装** | Windows/macOS/Linux 自动化脚本,自动检测并安装缺失依赖(Node.js、Git、g++、make) |
-| **定时任务守护进程** | 后台守护进程,支持定时任务、间隔任务、文件监控、多渠道通知 |
-| **自我进化** | AI 可修改自身源码,带安全检查和热重载 |
-| **检查点与回退** | 文件快照管理和会话时间旅行 |
-| **记忆系统** | 向量存储、BM25 搜索、意图提取、对话记忆 |
-| **MCP 协议** | 完整的 Model Context Protocol,支持自动发现(stdio、HTTP、SSE) |
-| **多云服务商** | Anthropic、AWS Bedrock、Google Vertex AI |
-| **代理服务器** | 跨设备共享 Claude 订阅 |
-| **浏览器自动化** | 自定义浏览器控制 + Chrome MCP 集成 + Playwright 支持 |
-| **国际化** | 中英文双语支持 |
-| **飞书机器人** | 飞书消息集成,支持 Web UI 模式 |
-| **微信机器人** | 微信消息集成 |
-| **Docker 部署** | 容器化部署,支持镜像加速 |
-| **自动更新** | 版本管理,支持回滚 |
-| **插件与 Hook 系统** | 可扩展架构,支持生命周期钩子 |
-| **快速模式** | 使用相同模型但优化输出速度 |
-| **Extended Thinking** | 扩展推理模式,更深入的分析 |
-| **团队协作** | 团队管理功能 |
-
## 为什么选择 Claude Code Open?
-- **开源透明** — 完整的 MIT 许可证源代码。没有黑盒,完全社区驱动开发。
-- **Web UI IDE 体验** — 不仅仅是 CLI。完整的浏览器 IDE,集成 Monaco 编辑器、VS Code 风格文件树、AI 增强代码编辑(悬浮提示、选中即问 AI、代码导游、热力图装饰器)、Blueprint 可视化、Swarm 多 Agent 控制台。
-- **多智能体协作** — Blueprint 系统将复杂任务分解给多个并行工作的 AI Agent,配备 E2E 测试、视觉对比和验证服务。
-- **7×24 自动化** — 定时任务守护进程自动运行 AI 工作流:自然语言时间配置、文件监控、多渠道通知(桌面 + 飞书)、SQLite 持久化。
-- **一键安装** — Windows/macOS/Linux 单命令安装。自动检测并安装缺失依赖(Node.js、Git、g++、make)。自动创建桌面快捷方式。
-- **自我进化** — AI 可修改自身源码,运行 TypeScript 编译检查,热重载——实现持续自我改进。
+| | 官方 Claude Code | Claude Code Open |
+|---|---|---|
+| **价格** | $20/月 (需要 Max 订阅) | 免费(自备 API Key) |
+| **界面** | 仅终端 | 终端 + **Web IDE**(Monaco 编辑器、文件树、AI 增强编辑) |
+| **复杂任务** | 单 Agent | **多智能体蓝图**系统(并行 Worker、任务队列、自动评审) |
+| **可定制** | 闭源 | 完全开源,**自我进化**(AI 可修改自身代码) |
+| **部署** | 仅本地 | 本地、Docker、云端、**代理服务器共享** |
+| **集成** | 仅 GitHub | GitHub、飞书、微信、MCP 协议、37+ 工具 |
-## 快速安装(推荐)
+## 快速开始
-### Windows 快速安装(最简单)
-
-**方式 A:一键安装器** — 下载后双击即可,无需命令行操作!
+```bash
+# 全局安装
+npm install -g claude-code-open
-[](https://raw.githubusercontent.com/kill136/claude-code-open/private_web_ui/install.bat)
-[](https://gitee.com/lubanbbs/claude-code-open/raw/private_web_ui/install.bat)
+# 设置 API Key
+export ANTHROPIC_API_KEY="sk-..." # Windows: $env:ANTHROPIC_API_KEY="sk-..."
-1. 点击上方按钮下载 `install.bat`
-2. 双击下载的文件运行
-3. 完成!安装器会自动处理一切(Node.js、依赖安装、编译构建、桌面快捷方式)
+# CLI 模式
+claude
-**方式 B:预编译包** — 下载解压即用,无需编译!
+# Web IDE 模式
+claude-web
+```
-[](https://github.com/kill136/claude-code-open/releases/latest)
+打开 `http://localhost:3456` 即可使用 Web IDE。
-1. 从最新 Release 下载 `claude-code-open-windows-x64-*.zip`
-2. 解压到任意文件夹
-3. 双击 `start.bat` 启动(需预装 [Node.js](https://nodejs.org/))
+### 其他安装方式
----
+
+一键安装(无需 Node.js)
-### macOS / Linux 快速安装
+**Windows:** 下载 [install.bat](https://github.com/kill136/claude-code-open/releases/latest/download/install.bat) 双击运行。
-**方式 A:一键安装脚本**
+[Gitee 国内镜像](https://gitee.com/lubanbbs/claude-code-open/raw/private_web_ui/install.bat)
+**macOS / Linux:**
```bash
curl -fsSL https://raw.githubusercontent.com/kill136/claude-code-open/private_web_ui/install.sh | bash
```
-国内镜像:
+**国内镜像:**
```bash
curl -fsSL https://gitee.com/lubanbbs/claude-code-open/raw/private_web_ui/install.sh | bash
```
+
-**方式 B:预编译包** — 下载解压即用!
-
-[](https://github.com/kill136/claude-code-open/releases/latest)
-
-| 平台 | 文件名 |
-| --- | --- |
-| macOS Apple Silicon (M1/M2/M3/M4) | `claude-code-open-macos-arm64-*.tar.gz` |
-| macOS Intel | `claude-code-open-macos-x64-*.tar.gz` |
-| Linux x64 | `claude-code-open-linux-x64-*.tar.gz` |
-
-```bash
-# 解压并运行(需预装 Node.js)
-tar -xzf claude-code-open-*.tar.gz
-cd claude-code-open-*/
-./start.sh
-```
-
----
-
-### 进阶:PowerShell 安装(Windows)
-
-```powershell
-irm https://raw.githubusercontent.com/kill136/claude-code-open/private_web_ui/install.ps1 | iex
-```
-
-安装脚本会自动完成:
-- ✅ 检测并安装缺失依赖(Node.js、Git、g++、make)
-- ✅ 克隆代码仓库
-- ✅ 安装所有 npm 依赖
-- ✅ 构建前端和后端
-- ✅ 创建桌面快捷方式
-- ✅ 预设 API 配置
-- ✅ 全局命令链接
-
-**安装完成后:**
-1. 双击桌面快捷方式 "Claude Code WebUI"
-2. 浏览器自动打开 http://localhost:3456
-3. 开始使用!
-
-### 手动安装
+
+Docker
```bash
-# 克隆仓库
-git clone https://github.com/kill136/claude-code-open.git
-cd claude-code-open
-
-# 安装依赖
-npm install
-
-# 构建前端
-cd src/web/client
-npm install
-npm run build
-cd ../../..
-
-# 构建后端
-npm run build
-
-# 全局链接(可选)
-npm link
-
-# 可选:安装 Playwright CLI(浏览器自动化)
-npm run install:playwright
-```
-
-### Windows 部署注意事项
-
-**Native addon 编译(通常不需要):**
-
-项目依赖了 `better-sqlite3`、`node-pty`、`sharp` 等 native addon,但它们都自带 **Windows x64 预编译二进制**。正常情况下,`npm install` 会直接下载预编译文件,无需本地编译。
-
-如果预编译下载失败(如无法访问 GitHub releases、冷门 Node.js 版本),npm 会回退到从源码编译。**仅在此情况下**才需要:
-
-- **Python 3.6+** — node-gyp 依赖
-- **Visual Studio Build Tools 2022** — 安装时选择"使用 C++ 的桌面开发"工作负载
-
-**环境变量冲突:**
-
-| 变量 | 用途 |
-| --- | --- |
-| `ANTHROPIC_API_KEY` / `CLAUDE_API_KEY` | API 认证 |
-| `ANTHROPIC_BASE_URL` | 自定义 API 端点(默认:`https://api.anthropic.com`) |
-
-如果系统级已设置这些变量,按会话设置以避免冲突:
-
-```powershell
-# PowerShell(仅当前会话生效)
-$env:ANTHROPIC_API_KEY="your-key-for-this-project"
-$env:ANTHROPIC_BASE_URL="https://your-api-endpoint"
-```
-
-> 注意:项目根目录的 `.env` 文件**不会被自动加载**。环境变量需通过系统设置、`settings.json` 或 `--env` CLI 参数配置。
-
-### Docker 部署
-
-```bash
-# 构建 Docker 镜像
-docker build -t claude-code-open .
-
-# 国内用户(使用镜像加速)
-docker build --build-arg REGISTRY=docker.1ms.run -t claude-code-open .
-
-# 运行 CLI
+# Web IDE
docker run -it \
-e ANTHROPIC_API_KEY=your-api-key \
+ -p 3456:3456 \
-v $(pwd):/workspace \
-v ~/.claude:/root/.claude \
- claude-code-open
+ claude-code-open node /app/dist/web-cli.js --host 0.0.0.0
-# 运行 Web UI
+# 仅 CLI
docker run -it \
-e ANTHROPIC_API_KEY=your-api-key \
- -p 3456:3456 \
-v $(pwd):/workspace \
-v ~/.claude:/root/.claude \
- claude-code-open node /app/dist/web-cli.js --host 0.0.0.0
+ claude-code-open
```
+
-## 使用
-
-### CLI 模式
+
+从源码构建
```bash
-# 交互模式
-npm run dev
-
-# 或构建后运行
-node dist/cli.js
-
-# 带初始 prompt
-node dist/cli.js "你好,请帮我分析这个项目"
-
-# 打印模式(非交互)
-node dist/cli.js -p "解释这段代码"
-
-# 指定模型(opus/sonnet/haiku)
-node dist/cli.js -m opus "复杂任务"
-
-# 恢复上一次会话
-node dist/cli.js --resume
-
-# 列出会话
-node dist/cli.js --list
-
-# 分叉会话
-node dist/cli.js --fork
+git clone https://github.com/kill136/claude-code-open.git
+cd claude-code-open
+npm install && npm run build
+node dist/cli.js # CLI
+node dist/web-cli.js # Web IDE
```
+
-### Web UI 模式
+## 核心功能
-```bash
-# 开发模式
-npm run web
+### Web IDE
-# 生产模式
-npm run web:start
+基于 React + Monaco Editor + WebSocket 的完整浏览器 IDE:
-# 自定义端口和主机
-npm run web -- -p 8080 -H 0.0.0.0
+- **Monaco 编辑器**,多标签页、语法高亮、AI 悬浮提示
+- **VS Code 风格文件树**,右键上下文菜单
+- **AI 增强编辑** — 选中代码即问 AI、代码导游、热力图装饰器
+- **WebSocket 实时流式传输** AI 响应
+- **会话管理** — 创建、恢复、分叉、导出
+- **检查点与回退** — 文件快照和会话时间旅行
-# 启用 ngrok 公网隧道
-npm run web -- --ngrok
+
+
+
+
+
+
-# 自我进化模式
-npm run web:evolve
-```
+### 蓝图多智能体系统
-### 代理服务器模式
+将复杂任务分解给多个并行工作的 AI Agent:
-跨设备共享你的 Claude 订阅:
+- **智能规划器** — 将任务分解为执行计划
+- **Lead Agent** — 协调 Worker,追踪进度
+- **自主 Worker** — 独立执行,拥有完整工具权限
+- **任务队列** — 基于优先级的调度,支持持久化
+- **质量评审器** — 自动化评审和验证
-```bash
-# 启动代理服务器
-npm run proxy
-# 或构建后
-node dist/proxy-cli.js --proxy-key my-secret
-
-# 客户端使用(在其他设备上)
-export ANTHROPIC_API_KEY="my-secret"
-export ANTHROPIC_BASE_URL="http://your-server-ip:8082"
-claude
-```
+
-### 飞书机器人模式
+### 自我进化
-```bash
-# 启动飞书机器人
-npm run feishu
+AI 可修改自身源码,运行 TypeScript 编译检查,热重载:
-# 飞书机器人 + Web UI
-npm run feishu:webui
```
-
-### 微信机器人模式
-
-```bash
-npm run wechat
+你:「增加一个查询天气的工具」
+Claude:*编写工具代码,编译,重启自己,工具立即可用*
```
-## Web UI 功能
-
-Web UI 是一个基于 React、Express 和 WebSocket 构建的完整浏览器 IDE:
-
-```bash
-npm run web
-# 打开 http://localhost:3456
-```
+### 37+ 内置工具
-**核心功能:**
-- **Monaco 编辑器** — 多标签页代码编辑,语法高亮
-- **文件树** — VS Code 风格文件浏览器,支持右键上下文菜单
-- **AI 增强编辑** — 智能悬浮提示、选中即问 AI、代码导游、热力图装饰器
-- **WebSocket 实时通信** — AI 响应实时流式传输
-- **会话管理** — 创建、恢复、分叉、导出会话
-- **Blueprint 可视化** — 任务分解和 Agent 协调可视化
-- **Swarm 控制台** — 多 Agent 监控,任务树、架构流程图
-- **终端集成** — 浏览器内终端面板
-- **检查点与回退** — 文件快照管理和会话时间旅行
-- **持续开发** — 周期评审、影响分析、TDD 面板
-- **认证** — OAuth 和 API 密钥支持
-- **Artifacts 面板** — 富内容渲染
-- **调试面板** — 开发调试工具
-
-## Blueprint 多 Agent 系统
-
-通过多个并行工作的 AI Agent 编排复杂任务:
-
-- **智能规划器** — 智能任务拆解和规划(97KB 规划逻辑)
-- **Lead Agent** — 协调 Worker Agent,追踪进度,自动项目选择
-- **自主 Worker** — 独立任务执行,拥有完整工具访问权限
-- **任务队列** — 基于优先级的任务调度,支持持久化
-- **任务评审器** — 质量保证和验证
-- **实时协调器** — Agent 通信、同步和冲突解决
-- **模型选择器** — 根据任务复杂度自适应选择模型
-- **E2E 测试 Agent** — 端到端测试自动化
-- **环境检查器** — 运行时环境验证
-- **视觉对比器** — 视觉 diff 和对比
-- **验证服务** — 结果验证流水线
-
-## 定时任务守护进程
-
-强大的后台守护进程系统,支持自动化任务执行:
-
-**功能特性:**
-- **基于时间的任务** — 使用自然语言调度("明天下午3点"、"每天早上9点"、"2小时后")
-- **文件监控** — 监控文件变化并自动触发 AI 任务
-- **多渠道通知** — 桌面通知和飞书消息推送
-- **持久化存储** — 基于 SQLite 的任务存储,重启后保持
-- **模型选择** — 为每个任务选择不同的 Claude 模型
-
-**使用方法:**
-```bash
-# 启动守护进程
-claude daemon start
+| 类别 | 工具 |
+|---|---|
+| 文件操作 | Read, Write, Edit, MultiEdit, Glob, Grep |
+| 执行 | Bash, 后台任务, 任务输出 |
+| Web | WebFetch, WebSearch |
+| 代码 | NotebookEdit, LSP, Tree-sitter 解析 |
+| 浏览器 | 基于 Playwright 的自动化 |
+| 规划 | 规划模式, 蓝图, 子代理 |
+| 记忆 | 语义搜索, 向量存储, BM25 |
+| 集成 | MCP 协议, 技能系统 |
+| 定时任务 | 类 Cron 守护进程, 文件监控, 通知 |
-# 通过对话调度任务
-"在每天早上9点进行代码审查,并通过飞书通知我"
-```
+### 更多功能
-**任务类型:** `once`(一次性)、`interval`(循环执行)、`watch`(文件监控)
-
-## 已实现工具(37+)
-
-| 工具 | 说明 |
-| --- | --- |
-| **文件操作** | |
-| Read | 文件读取,支持图像/PDF/Notebook + 外部修改检测 |
-| Write | 文件写入,带覆盖保护 |
-| Edit | 文件编辑(字符串替换) |
-| MultiEdit | 批量文件编辑(原子操作) |
-| **搜索与发现** | |
-| Glob | 文件模式匹配 |
-| Grep | 内容搜索(基于 ripgrep) |
-| **执行** | |
-| Bash | 命令执行,支持后台和沙箱 |
-| BashHistory | 命令历史追踪 |
-| TaskOutput | 获取后台命令/代理输出 |
-| **Web 访问** | |
-| WebFetch | Web 页面获取,带缓存 |
-| WebSearch | 服务端 Web 搜索 |
-| **任务管理** | |
-| TodoWrite | 任务管理,带自动提醒系统 |
-| Task | 子代理(explore、plan、guide 等) |
-| TaskStatus | 查询任务执行状态 |
-| ScheduleTask | 创建/取消/列出定时任务 |
-| **规划** | |
-| EnterPlanMode | 进入规划模式 |
-| ExitPlanMode | 退出规划模式 |
-| **交互** | |
-| AskUserQuestion | 询问用户问题(multiSelect、选项、验证) |
-| **代码工具** | |
-| NotebookEdit | Jupyter Notebook 单元格编辑 |
-| NotebookWrite | Jupyter Notebook 创建 |
-| LSP | 语言服务器协议集成 |
-| **集成** | |
-| MCP 工具 | ListMcpResources、ReadMcpResource、MCPSearch |
-| Skill | 技能系统,带 args 参数 |
-| **浏览器** | |
-| Browser | 自定义浏览器自动化和控制 |
-| **记忆** | |
-| MemorySearch | 语义记忆搜索 |
-| **多 Agent** | |
-| GenerateBlueprint | 生成任务执行蓝图 |
-| GenerateDesign | 生成设计文档 |
-| StartLeadAgent | 启动 Lead Agent 协调器 |
-| DispatchWorker | 派发自主 Worker Agent |
-| SubmitReview | 提交质量评审 |
-| SubmitE2EResult | 提交 E2E 测试结果 |
-| TriggerE2ETest | 触发 E2E 测试 |
-| UpdateTaskPlan | 更新任务执行计划 |
-| **高级** | |
-| SelfEvolve | AI 自我修改,带安全检查 |
-| StructuredOutput | 结构化数据输出 |
-| OutputPersistence | 持久化输出存储 |
-| Teammate | 团队协作 |
+- **代理服务器** — 跨设备共享 API Key
+- **多云服务商** — Anthropic, AWS Bedrock, Google Vertex AI
+- **插件与 Hook 系统** — 自定义扩展
+- **国际化** — 中英文双语
+- **飞书和微信机器人** — 消息集成
## 配置
-设置 API 密钥:
-
-**Linux/macOS:**
-```bash
-export ANTHROPIC_API_KEY=your-api-key
-```
-
-**Windows PowerShell:**
-```powershell
-$env:ANTHROPIC_API_KEY="your-api-key"
-```
-
-### 环境变量
-
| 变量 | 说明 | 默认值 |
-| --- | --- | --- |
-| `ANTHROPIC_API_KEY` / `CLAUDE_API_KEY` | API 密钥 | - |
+|---|---|---|
+| `ANTHROPIC_API_KEY` | API 密钥(必填) | - |
| `ANTHROPIC_BASE_URL` | 自定义 API 端点 | `https://api.anthropic.com` |
-| `CLAUDE_CODE_LANG` | 语言(en/zh) | 自动检测 |
-| `BASH_MAX_OUTPUT_LENGTH` | Bash 输出最大长度 | 30000 |
-| `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | 最大输出 tokens | 32000 |
-| `USE_BUILTIN_RIPGREP` | 使用系统 ripgrep | false |
-
-### 多云服务商支持
-
-- **Anthropic** — 直连 API(默认)
-- **AWS Bedrock** — 设置 `ANTHROPIC_BEDROCK=1` 并配置 AWS 凭证
-- **Google Vertex AI** — 设置 `ANTHROPIC_VERTEX=1` 并配置 GCP 凭证
+| `CLAUDE_CODE_LANG` | 语言(`en`/`zh`) | 自动检测 |
### MCP 协议
-完整的 Model Context Protocol 实现:
-
```json
// .claude/settings.json
{
@@ -483,215 +188,37 @@ $env:ANTHROPIC_API_KEY="your-api-key"
}
```
-支持 stdio、HTTP 和 SSE 传输,含自动发现。
-
-### Hooks 系统
-
-在工具调用前后执行自定义脚本:
-
-```json
-{
- "hooks": [
- {
- "event": "PreToolUse",
- "matcher": "Bash",
- "command": "/path/to/script.sh",
- "blocking": true
- }
- ]
-}
-```
-
-支持事件:`PreToolUse`、`PostToolUse`、`PrePromptSubmit`、`PostPromptSubmit`、`Notification`、`Stop`
-
-## 项目结构
-
-```
-src/
-├── cli.ts # CLI 入口点(Commander.js)
-├── web-cli.ts # Web UI 入口点
-├── proxy-cli.ts # 代理服务器入口点
-├── feishu-cli.ts # 飞书机器人入口点
-├── wechat-cli.ts # 微信机器人入口点
-├── index.ts # 主导出文件
-│
-├── core/ # 核心引擎
-│ ├── client.ts # Anthropic API 客户端(流式、重试、成本)
-│ ├── session.ts # 会话状态管理
-│ ├── loop.ts # 对话编排器
-│ └── backgroundTasks.ts # 异步后台任务处理
-│
-├── tools/ # 37+ 工具(见工具表)
-│
-├── web/ # Web UI 系统
-│ ├── server/ # Express + WebSocket 后端
-│ │ ├── websocket.ts # WebSocket 处理器
-│ │ ├── conversation.ts # 对话管理器
-│ │ ├── session-manager.ts
-│ │ ├── auth-manager.ts # 认证管理
-│ │ ├── routes/ # API 路由
-│ │ └── handlers/ # 请求处理器
-│ └── client/ # React 前端
-│ └── src/
-│ ├── components/
-│ │ ├── CodeView/ # Monaco 编辑器 + 文件树 + AI hooks
-│ │ ├── BlueprintSummaryCard/
-│ │ ├── continuous/ # 持续开发面板
-│ │ ├── config/ # 设置面板
-│ │ └── ... # 40+ UI 组件
-│ ├── hooks/ # 自定义 React hooks
-│ └── contexts/ # React Context
-│
-├── blueprint/ # Blueprint 多 Agent 系统(16 个文件)
-│ ├── smart-planner.ts # 智能任务规划器
-│ ├── lead-agent.ts # Lead Agent 协调器
-│ ├── autonomous-worker.ts # 自主 Worker
-│ ├── task-queue.ts # 任务优先级队列
-│ ├── task-reviewer.ts # 质量评审器
-│ ├── realtime-coordinator.ts # 实时协调器
-│ ├── model-selector.ts # 自适应模型选择器
-│ ├── e2e-test-agent.ts # E2E 测试 Agent
-│ ├── verification-service.ts # 结果验证
-│ ├── visual-comparator.ts # 视觉 diff
-│ └── ...
-│
-├── agents/ # 专用子代理
-│ ├── explore.ts # 代码库探索
-│ ├── plan.ts # 实现规划
-│ ├── guide.ts # 文档引导
-│ ├── parallel.ts # 并行执行
-│ ├── monitor.ts # 监控代理
-│ └── resume.ts # 会话恢复
-│
-├── memory/ # 统一记忆系统
-│ ├── unified-memory.ts # 记忆管理器
-│ ├── vector-store.ts # 向量存储
-│ ├── bm25-engine.ts # BM25 文本搜索
-│ ├── chat-memory.ts # 对话记忆
-│ ├── embedder.ts # 嵌入模型
-│ └── intent-extractor.ts # 意图提取
-│
-├── checkpoint/ # 文件快照管理
-├── rewind/ # 会话时间旅行
-├── updater/ # 自动更新系统
-├── browser/ # 自定义浏览器控制
-├── chrome/ # Chrome 集成
-├── chrome-mcp/ # Chrome MCP 桥接
-├── daemon/ # 定时任务守护进程
-├── feishu/ # 飞书集成
-├── wechat/ # 微信集成
-├── i18n/ # 国际化(en、zh)
-├── teams/ # 团队管理
-├── mcp/ # MCP 协议(完整实现)
-├── permissions/ # 权限系统
-├── session/ # 会话持久化
-├── context/ # 上下文管理与摘要
-├── config/ # 配置管理
-├── models/ # 模型配置(Anthropic/Bedrock/Vertex)
-├── hooks/ # Hook 系统
-├── plugins/ # 插件系统
-├── commands/ # 斜杠命令
-├── auth/ # 认证(API 密钥 + OAuth)
-├── parser/ # 代码解析(Tree-sitter WASM)
-├── search/ # 搜索(ripgrep 集成)
-├── proxy/ # 代理服务器
-├── providers/ # 云服务商
-├── fast-mode/ # 快速模式
-├── lsp/ # 语言服务器协议
-├── git/ # Git 操作
-├── github/ # GitHub 集成
-├── sandbox/ # 沙箱执行
-├── security/ # 安全约束
-├── trust/ # 信任验证
-├── ratelimit/ # 速率限制
-├── rules/ # 规则引擎
-├── diagnostics/ # 诊断工具
-├── notifications/ # 通知系统
-├── ui/ # 终端 UI(Ink/React)
-├── streaming/ # 流式 I/O
-├── telemetry/ # 本地遥测
-├── types/ # TypeScript 类型定义
-└── utils/ # 工具函数
-```
-
-## 斜杠命令
-
-| 命令 | 说明 |
-| --- | --- |
-| `/help` | 显示帮助 |
-| `/clear` | 清除对话历史 |
-| `/status` | 显示会话状态 |
-| `/resume` | 恢复历史会话 |
-| `/context` | 显示上下文使用情况 |
-| `/compact` | 压缩对话历史 |
-| `/rename` | 重命名当前会话 |
-| `/export` | 导出会话(JSON/Markdown) |
-| `/transcript` | 导出会话转录记录 |
-| `/config` | 查看配置 |
-| `/tools` | 列出可用工具 |
-| `/model` | 查看/切换模型 |
-| `/fast` | 切换快速模式 |
-| `/exit` | 退出 |
-
-## 测试
-
-```bash
-npm test # 运行所有测试(vitest)
-npm run test:unit # 仅单元测试
-npm run test:integration # 集成测试
-npm run test:e2e # 端到端 CLI 测试
-npm run test:coverage # 覆盖率报告
-npm run test:watch # 监视模式
-npm run test:ui # Vitest UI
-```
-
-## 开发
+## CLI 参考
```bash
-# 开发模式(使用 tsx)
-npm run dev
-
-# Web UI 开发
-npm run web
-
-# Web UI 文件监视
-npm run web:dev
-
-# 构建
-npm run build
-
-# 类型检查
-npx tsc --noEmit
+claude # 交互模式
+claude "分析这个项目" # 带初始 prompt
+claude -p "解释这段代码" # 打印模式(非交互)
+claude -m opus "复杂任务" # 指定模型
+claude --resume # 恢复上次会话
+claude-web # Web IDE
+claude-web -p 8080 -H 0.0.0.0 # 自定义端口
+claude-web --ngrok # 公网隧道
+claude-web --evolve # 自我进化模式
```
-## 技术栈
-
-- **TypeScript** — 类型安全
-- **Anthropic SDK** — API 调用
-- **Ink + React** — 终端 UI
-- **Express + WebSocket** — Web 后端
-- **React + Monaco Editor** — Web 前端
-- **Commander** — CLI 框架
-- **Zod** — 模式验证
-- **Tree-sitter WASM** — 代码解析
-- **better-sqlite3** — 本地数据库
-- **sharp** — 图像处理
-- **ngrok** — 公网隧道
-- **Vitest** — 测试框架
-
## 社区
-- **网站:** https://www.chatbi.site
-- **Discord:** [加入我们的 Discord](https://discord.gg/bNyJKk6PVZ)
+- **官网:** [chatbi.site](https://www.chatbi.site)
+- **Discord:** [加入我们](https://discord.gg/bNyJKk6PVZ)
- **X (Twitter):** [@wangbingjie1989](https://x.com/wangbingjie1989)
- **微信:** h694623326
-## 许可证
+## 贡献
+
+欢迎 PR 和 Issue。
-本项目仅用于教育目的。原始 Claude Code 归 Anthropic PBC 所有。
+## 致谢
----
+本项目灵感来自 Anthropic 的 Claude Code CLI,是基于公开 API 的独立开源重新实现。官方版本请见 [@anthropic-ai/claude-code](https://www.npmjs.com/package/@anthropic-ai/claude-code)。
+
+## 许可证
-*这个项目是对混淆代码的逆向工程研究,不代表官方实现。*
+MIT
[English README](README.md)
diff --git a/claude-wechat-bot.memory-card.json b/claude-wechat-bot.memory-card.json
deleted file mode 100644
index e69de29b..00000000
diff --git a/demo-screenshots/02-blueprint.png b/demo-screenshots/02-blueprint.png
index 624f3c78..d69dd2b2 100644
Binary files a/demo-screenshots/02-blueprint.png and b/demo-screenshots/02-blueprint.png differ
diff --git a/demo-screenshots/05-typing.png b/demo-screenshots/05-typing.png
index 6bbd4e33..8c94c201 100644
Binary files a/demo-screenshots/05-typing.png and b/demo-screenshots/05-typing.png differ
diff --git a/demo-screenshots/demo.gif b/demo-screenshots/demo.gif
index 670323cd..8ed775a3 100644
Binary files a/demo-screenshots/demo.gif and b/demo-screenshots/demo.gif differ
diff --git a/demo-screenshots/make-video.py b/demo-screenshots/make-video.py
index acda7bb6..325ba5a1 100644
--- a/demo-screenshots/make-video.py
+++ b/demo-screenshots/make-video.py
@@ -124,7 +124,7 @@ def scene_opening(frame_idx, total_frames):
if t > 0.3:
tag_alpha = min((t - 0.3) * 3, 1.0)
tag_color = tuple(int(c * tag_alpha) for c in ACCENT)
- center_text(draw, 410, "v2.1.34 | MIT License", FONT_MED, fill=tag_color)
+ center_text(draw, 410, "v2.1.36 | MIT License", FONT_MED, fill=tag_color)
# subtitle
if t > 0.5:
@@ -136,7 +136,7 @@ def scene_opening(frame_idx, total_frames):
if t > 0.7:
stat_alpha = min((t - 0.7) * 3, 1.0)
stat_color = tuple(int(c * stat_alpha) for c in WHITE)
- center_text(draw, 580, "126+ Stars | 47 Forks | 37+ Tools", FONT_TAG, fill=stat_color)
+ center_text(draw, 580, "136+ Stars | 52 Forks | 37+ Tools", FONT_TAG, fill=stat_color)
# bottom gradient bar
if t > 0.4:
diff --git a/demo-screenshots/promo-video.mp4 b/demo-screenshots/promo-video.mp4
index 4e4f7f66..a7c453db 100644
Binary files a/demo-screenshots/promo-video.mp4 and b/demo-screenshots/promo-video.mp4 differ
diff --git a/install.ps1 b/install.ps1
index bf08c056..da596c63 100644
--- a/install.ps1
+++ b/install.ps1
@@ -740,9 +740,15 @@ function Clone-Repository {
)
Write-Info "Cloning repository... (this may take a while)"
+ # git writes progress to stderr, which PowerShell treats as NativeCommandError
+ # under $ErrorActionPreference = "Stop". Temporarily relax to avoid false failures.
+ $prevEAP = $ErrorActionPreference
+ $ErrorActionPreference = "Continue"
git clone -b private_web_ui --progress $RepoUrl $InstallDir 2>&1 | Write-Host
- if ($LASTEXITCODE -ne 0) {
- Write-Error "Git clone failed. Please check your network connection and try again."
+ $cloneExit = $LASTEXITCODE
+ $ErrorActionPreference = $prevEAP
+ if ($cloneExit -ne 0) {
+ Write-Error "Git clone failed (exit code $cloneExit). Please check your network connection and try again."
}
if (-not (Test-Path $InstallDir)) {
Write-Error "Installation directory was not created. Git clone may have failed."
@@ -772,11 +778,16 @@ function Install-Npm {
Write-Info "Updating existing installation..."
Push-Location $InstallDir
# Reset local changes (e.g. package-lock.json modified by npm install)
- git checkout -- .
- git clean -fd --exclude=.node
- git pull origin private_web_ui
- if ($LASTEXITCODE -ne 0) {
- Write-Error "Git pull failed. Please check your network connection."
+ # Temporarily relax ErrorActionPreference — git writes to stderr
+ $prevEAP = $ErrorActionPreference
+ $ErrorActionPreference = "Continue"
+ git checkout -- . 2>&1 | Out-Null
+ git clean -fd --exclude=.node 2>&1 | Out-Null
+ git pull origin private_web_ui 2>&1 | Write-Host
+ $pullExit = $LASTEXITCODE
+ $ErrorActionPreference = $prevEAP
+ if ($pullExit -ne 0) {
+ Write-Error "Git pull failed (exit code $pullExit). Please check your network connection."
}
} else {
Write-Warn "Existing directory is not a git repository. Removing and re-installing..."
@@ -792,14 +803,14 @@ function Install-Npm {
# Auto-detect China network and set npm mirror
if ($script:RepoUrl -like '*gitee*') {
Write-Info "Detected China network, setting npm registry to npmmirror..."
- npm config set registry https://registry.npmmirror.com
+ npm.cmd config set registry https://registry.npmmirror.com
}
Write-Info "Installing dependencies..."
- npm install --legacy-peer-deps
+ npm.cmd install --legacy-peer-deps
if ($LASTEXITCODE -ne 0) {
Write-Warn "Some native modules failed to compile, trying without optional dependencies..."
- npm install --no-optional --legacy-peer-deps
+ npm.cmd install --no-optional --legacy-peer-deps
}
Write-Info "Building frontend..."
@@ -818,32 +829,32 @@ This usually means the git clone was incomplete. Please try:
}
Push-Location src\web\client
- npm install
+ npm.cmd install
if ($LASTEXITCODE -ne 0) {
Write-Error "Frontend npm install failed."
}
- npm run build
+ npm.cmd run build
if ($LASTEXITCODE -ne 0) {
Write-Error "Frontend build failed."
}
Pop-Location
Write-Info "Building backend..."
- npm run build
+ npm.cmd run build
# Link globally using npm's default prefix
# On Windows, Node.js MSI sets default prefix to %APPDATA%\npm and adds it to PATH
# We use the default prefix to avoid PATH issues; if custom prefix is needed, we handle it
Write-Info "Linking globally..."
- npm link
+ npm.cmd link
if ($LASTEXITCODE -ne 0) {
Write-Warn "npm link failed, trying with --force..."
- npm link --force
+ npm.cmd link --force
}
# Determine where npm placed the global .cmd files
# On Windows, npm global bin = prefix root (not prefix/bin)
- $NpmGlobalDir = (npm config get prefix 2>$null)
+ $NpmGlobalDir = (npm.cmd config get prefix 2>$null)
if ($NpmGlobalDir) {
$NpmGlobalDir = $NpmGlobalDir.Trim()
# Ensure this directory is in user PATH
@@ -941,7 +952,7 @@ function Uninstall {
if (Test-Path $InstallDir) {
Push-Location $InstallDir
- try { npm unlink 2>$null } catch {}
+ try { npm.cmd unlink 2>$null } catch {}
Pop-Location
Remove-Item -Recurse -Force $InstallDir
Write-Ok "Removed source directory"
diff --git a/package-lock.json b/package-lock.json
index 71b315f5..85de9a2a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "claude-code-open",
- "version": "2.1.33",
+ "version": "2.1.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-code-open",
- "version": "2.1.33",
+ "version": "2.1.37",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.70.0",
@@ -37,17 +37,20 @@
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"i18next": "^25.8.4",
+ "imap-simple": "^5.1.0",
"ink": "^5.0.0",
"ioredis": "^5.6.1",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"lru-cache": "^11.2.4",
+ "mailparser": "^3.9.3",
"marked": "^12.0.0",
"mime": "^4.1.0",
"mongodb": "^6.17.0",
"mysql2": "^3.17.3",
"node-notifier": "^10.0.1",
"node-pty": "^1.1.0",
+ "nodemailer": "^8.0.1",
"open": "^11.0.0",
"pg": "^8.16.0",
"playwright-core": "^1.58.2",
@@ -78,8 +81,10 @@
"@types/eventsource": "^1.1.15",
"@types/express": "^5.0.6",
"@types/js-yaml": "^4.0.9",
+ "@types/mailparser": "^3.4.6",
"@types/node": "^20.19.30",
"@types/node-notifier": "^8.0.5",
+ "@types/nodemailer": "^7.0.11",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.3.7",
"@types/shell-quote": "^1.7.5",
@@ -2526,6 +2531,19 @@
"win32"
]
},
+ "node_modules/@selderee/plugin-htmlparser2": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
+ "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "selderee": "^0.11.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@@ -2704,6 +2722,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/mailparser": {
+ "version": "3.4.6",
+ "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
+ "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "iconv-lite": "^0.6.3"
+ }
+ },
"node_modules/@types/node": {
"version": "20.19.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
@@ -2733,6 +2762,16 @@
"@types/node": "*"
}
},
+ "node_modules/@types/nodemailer": {
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
+ "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2993,6 +3032,17 @@
"addons/*"
]
},
+ "node_modules/@zone-eu/mailsplit": {
+ "version": "5.4.8",
+ "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
+ "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
+ "license": "(MIT OR EUPL-1.1+)",
+ "dependencies": {
+ "libbase64": "1.3.0",
+ "libmime": "5.3.7",
+ "libqp": "2.1.1"
+ }
+ },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -4199,6 +4249,15 @@
"node": ">=4.0.0"
}
},
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/default-browser": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz",
@@ -4409,6 +4468,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/encoding-japanese": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
+ "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
@@ -5344,6 +5412,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
@@ -5367,6 +5444,41 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/html-to-text": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
+ "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
+ "license": "MIT",
+ "dependencies": {
+ "@selderee/plugin-htmlparser2": "^0.11.0",
+ "deepmerge": "^4.3.1",
+ "dom-serializer": "^2.0.0",
+ "htmlparser2": "^8.0.2",
+ "selderee": "^0.11.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/html-to-text/node_modules/htmlparser2": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+ "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "entities": "^4.4.0"
+ }
+ },
"node_modules/htmlparser2": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
@@ -5516,6 +5628,71 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/imap": {
+ "version": "0.8.19",
+ "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
+ "integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==",
+ "dependencies": {
+ "readable-stream": "1.1.x",
+ "utf7": ">=1.0.2"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/imap-simple": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/imap-simple/-/imap-simple-5.1.0.tgz",
+ "integrity": "sha512-FLZm1v38C5ekN46l/9X5gBRNMQNVc5TSLYQ3Hsq3xBLvKwt1i5fcuShyth8MYMPuvId1R46oaPNrH92hFGHr/g==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "~0.4.13",
+ "imap": "^0.8.18",
+ "nodeify": "^1.0.0",
+ "quoted-printable": "^1.0.0",
+ "utf8": "^2.1.1",
+ "uuencode": "0.0.4"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/imap-simple/node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/imap/node_modules/isarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
+ "license": "MIT"
+ },
+ "node_modules/imap/node_modules/readable-stream": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+ "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.1",
+ "isarray": "0.0.1",
+ "string_decoder": "~0.10.x"
+ }
+ },
+ "node_modules/imap/node_modules/string_decoder": {
+ "version": "0.10.31",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+ "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
+ "license": "MIT"
+ },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -6060,6 +6237,39 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/leac": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
+ "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
+ "node_modules/libbase64": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
+ "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
+ "license": "MIT"
+ },
+ "node_modules/libmime": {
+ "version": "5.3.7",
+ "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
+ "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
+ "license": "MIT",
+ "dependencies": {
+ "encoding-japanese": "2.2.0",
+ "iconv-lite": "0.6.3",
+ "libbase64": "1.3.0",
+ "libqp": "2.1.1"
+ }
+ },
+ "node_modules/libqp": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
+ "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
+ "license": "MIT"
+ },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -6069,6 +6279,15 @@
"immediate": "~3.0.5"
}
},
+ "node_modules/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
@@ -6164,6 +6383,49 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/mailparser": {
+ "version": "3.9.3",
+ "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
+ "integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@zone-eu/mailsplit": "5.4.8",
+ "encoding-japanese": "2.2.0",
+ "he": "1.2.0",
+ "html-to-text": "9.0.5",
+ "iconv-lite": "0.7.2",
+ "libmime": "5.3.7",
+ "linkify-it": "5.0.0",
+ "nodemailer": "7.0.13",
+ "punycode.js": "2.3.1",
+ "tlds": "1.261.0"
+ }
+ },
+ "node_modules/mailparser/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/mailparser/node_modules/nodemailer": {
+ "version": "7.0.13",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
+ "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/marked": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
@@ -6684,6 +6946,31 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/nodeify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/nodeify/-/nodeify-1.0.1.tgz",
+ "integrity": "sha512-n7C2NyEze8GCo/z73KdbjRsBiLbv6eBn1FxwYKQ23IqGo7pQY3mhQan61Sv7eEDJCiyUjTVrVkXTzJCo1dW7Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-promise": "~1.0.0",
+ "promise": "~1.3.0"
+ }
+ },
+ "node_modules/nodeify/node_modules/is-promise": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-1.0.1.tgz",
+ "integrity": "sha512-mjWH5XxnhMA8cFnDchr6qRP9S/kLntKuEfIYku+PaN1CnS8v+OG9O/BKpRCVRJvpIkgAZm0Pf5Is3iSSOILlcg==",
+ "license": "MIT"
+ },
+ "node_modules/nodemailer": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
+ "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -6879,6 +7166,19 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/parseley": {
+ "version": "0.12.1",
+ "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
+ "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
+ "license": "MIT",
+ "dependencies": {
+ "leac": "^0.6.0",
+ "peberminta": "^0.9.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -6955,6 +7255,15 @@
"node": ">= 14.16"
}
},
+ "node_modules/peberminta": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
+ "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/peek-readable": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.4.2.tgz",
@@ -7210,6 +7519,21 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
+ "node_modules/promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/promise/-/promise-1.3.0.tgz",
+ "integrity": "sha512-R9WrbTF3EPkVtWjp7B7umQGVndpsi+rsDAfrR4xAALQpFLa/+2OriecLhawxzvii2gd9+DZFwROWDuUUaqS5yA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-promise": "~1"
+ }
+ },
+ "node_modules/promise/node_modules/is-promise": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-1.0.1.tgz",
+ "integrity": "sha512-mjWH5XxnhMA8cFnDchr6qRP9S/kLntKuEfIYku+PaN1CnS8v+OG9O/BKpRCVRJvpIkgAZm0Pf5Is3iSSOILlcg==",
+ "license": "MIT"
+ },
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
@@ -7272,6 +7596,15 @@
"node": ">=6"
}
},
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qrcode-terminal": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
@@ -7295,6 +7628,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/quoted-printable": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz",
+ "integrity": "sha512-cihC68OcGiQOjGiXuo5Jk6XHANTHl1K4JLk/xlEJRTIXfy19Sg6XzB95XonYgr+1rB88bCpr7WZE7D7AlZow4g==",
+ "license": "MIT",
+ "dependencies": {
+ "utf8": "^2.1.0"
+ },
+ "bin": {
+ "quoted-printable": "bin/quoted-printable"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -7650,6 +7995,18 @@
"loose-envify": "^1.1.0"
}
},
+ "node_modules/selderee": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
+ "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
+ "license": "MIT",
+ "dependencies": {
+ "parseley": "^0.12.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -8412,6 +8769,15 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tlds": {
+ "version": "1.261.0",
+ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
+ "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
+ "license": "MIT",
+ "bin": {
+ "tlds": "bin.js"
+ }
+ },
"node_modules/tldts": {
"version": "7.0.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",
@@ -8633,6 +8999,12 @@
"node": ">=14.17"
}
},
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "license": "MIT"
+ },
"node_modules/undici": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
@@ -8688,12 +9060,40 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/utf7": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz",
+ "integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==",
+ "dependencies": {
+ "semver": "~5.3.0"
+ }
+ },
+ "node_modules/utf7/node_modules/semver": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
+ "integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/utf8": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz",
+ "integrity": "sha512-QXo+O/QkLP/x1nyi54uQiG0XrODxdysuQvE5dtVqv7F5K2Qb6FsN+qbr6KhF5wQ20tfcV3VQp0/2x1e1MRSPWg==",
+ "license": "MIT"
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
+ "node_modules/uuencode": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/uuencode/-/uuencode-0.0.4.tgz",
+ "integrity": "sha512-yEEhCuCi5wRV7Z5ZVf9iV2gWMvUZqKJhAs1ecFdKJ0qzbyaVelmsE3QjYAamehfp9FKLiZbKldd+jklG3O0LfA=="
+ },
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
diff --git a/package.json b/package.json
index 880e2be4..afc6a6cb 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "claude-code-open",
- "version": "2.1.33",
- "description": "Open source AI coding platform with 37+ tools, Web IDE, multi-agent system, and MCP protocol support",
+ "version": "2.1.37",
+ "description": "Free & open source Claude Code alternative with Web IDE, multi-agent system, and self-evolution",
"type": "module",
"bin": {
"claude": "./dist/cli.js",
@@ -14,6 +14,7 @@
},
"files": [
"dist",
+ "scripts/postinstall.js",
"README.md",
"LICENSE"
],
@@ -43,6 +44,7 @@
"test:e2e:basic": "tsx tests/e2e/cli-basic.test.ts",
"test:e2e:session": "tsx tests/e2e/cli-session.test.ts",
"test:e2e:tools": "tsx tests/e2e/cli-tools.test.ts",
+ "postinstall": "node scripts/postinstall.js || true",
"prepublishOnly": "npm run build"
},
"keywords": [
@@ -99,17 +101,20 @@
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"i18next": "^25.8.4",
+ "imap-simple": "^5.1.0",
"ink": "^5.0.0",
"ioredis": "^5.6.1",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"lru-cache": "^11.2.4",
+ "mailparser": "^3.9.3",
"marked": "^12.0.0",
"mime": "^4.1.0",
"mongodb": "^6.17.0",
"mysql2": "^3.17.3",
"node-notifier": "^10.0.1",
"node-pty": "^1.1.0",
+ "nodemailer": "^8.0.1",
"open": "^11.0.0",
"pg": "^8.16.0",
"playwright-core": "^1.58.2",
@@ -138,8 +143,10 @@
"@types/eventsource": "^1.1.15",
"@types/express": "^5.0.6",
"@types/js-yaml": "^4.0.9",
+ "@types/mailparser": "^3.4.6",
"@types/node": "^20.19.30",
"@types/node-notifier": "^8.0.5",
+ "@types/nodemailer": "^7.0.11",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.3.7",
"@types/shell-quote": "^1.7.5",
diff --git a/railway.toml b/railway.toml
new file mode 100644
index 00000000..e941e149
--- /dev/null
+++ b/railway.toml
@@ -0,0 +1,9 @@
+[build]
+builder = "DOCKERFILE"
+dockerfilePath = "Dockerfile.railway"
+
+[deploy]
+healthcheckPath = "/api/health"
+healthcheckTimeout = 300
+restartPolicyType = "ON_FAILURE"
+restartPolicyMaxRetries = 3
diff --git a/scripts/postinstall.js b/scripts/postinstall.js
new file mode 100644
index 00000000..6bc08483
--- /dev/null
+++ b/scripts/postinstall.js
@@ -0,0 +1,110 @@
+#!/usr/bin/env node
+
+/**
+ * postinstall script for claude-code-open
+ * Creates desktop shortcut on Windows / .desktop entry on Linux
+ * Only runs during `npm install -g` (global install)
+ */
+
+import { execSync } from 'child_process';
+import { existsSync, writeFileSync, mkdirSync } from 'fs';
+import { join } from 'path';
+
+// Detect global install via npm environment variables
+// npm sets npm_config_global when -g flag is used
+const isGlobal = process.env.npm_config_global === 'true' ||
+ process.env.npm_config_global === '';
+
+if (!isGlobal) {
+ process.exit(0);
+}
+
+console.log('[postinstall] Global install detected, creating desktop shortcut...');
+
+if (process.platform === 'win32') {
+ createWindowsShortcut();
+} else {
+ createLinuxDesktopEntry();
+}
+
+function createWindowsShortcut() {
+ try {
+ const desktop = execSync(
+ 'powershell -NoProfile -Command "[Environment]::GetFolderPath(\'Desktop\')"',
+ { encoding: 'utf8' }
+ ).trim();
+
+ if (!desktop || !existsSync(desktop)) {
+ console.log('[postinstall] Desktop path not found, skipping.');
+ return;
+ }
+
+ // Create launcher bat
+ const launcherDir = join(process.env.USERPROFILE || '', '.local', 'bin');
+ if (!existsSync(launcherDir)) {
+ mkdirSync(launcherDir, { recursive: true });
+ }
+
+ const launcherPath = join(launcherDir, 'claude-web-start.bat');
+ writeFileSync(launcherPath, [
+ '@echo off',
+ 'chcp 65001 >nul 2>&1',
+ 'echo.',
+ 'echo Starting Claude Code WebUI...',
+ 'echo.',
+ 'claude-web -H 0.0.0.0',
+ 'pause',
+ ''
+ ].join('\r\n'), 'ascii');
+
+ // Create .lnk via PowerShell
+ const shortcutPath = join(desktop, 'Claude Code WebUI.lnk');
+ const esc = s => s.replace(/'/g, "''");
+ const psScript = [
+ `$s = (New-Object -ComObject WScript.Shell).CreateShortcut('${esc(shortcutPath)}')`,
+ `$s.TargetPath = '${esc(launcherPath)}'`,
+ `$s.Description = 'Launch Claude Code Web Interface'`,
+ `$s.WorkingDirectory = $env:USERPROFILE`,
+ `$s.Save()`
+ ].join('; ');
+
+ execSync(`powershell -NoProfile -Command "${psScript.replace(/"/g, '\\"')}"`, { stdio: 'ignore' });
+ console.log(`[postinstall] Desktop shortcut created: ${shortcutPath}`);
+ } catch (e) {
+ console.log(`[postinstall] Failed to create shortcut: ${e.message}`);
+ }
+}
+
+function createLinuxDesktopEntry() {
+ try {
+ const home = process.env.HOME || '';
+ const entry = [
+ '[Desktop Entry]',
+ 'Name=Claude Code WebUI',
+ 'Comment=Launch Claude Code Web Interface',
+ 'Exec=claude-web -H 0.0.0.0',
+ 'Terminal=true',
+ 'Type=Application',
+ 'Categories=Development;',
+ ''
+ ].join('\n');
+
+ // ~/.local/share/applications (app menu)
+ const appDir = join(home, '.local', 'share', 'applications');
+ if (existsSync(appDir)) {
+ writeFileSync(join(appDir, 'claude-code-webui.desktop'), entry);
+ console.log(`[postinstall] App menu entry created.`);
+ }
+
+ // ~/Desktop
+ const desktopDir = join(home, 'Desktop');
+ if (existsSync(desktopDir)) {
+ const p = join(desktopDir, 'claude-code-webui.desktop');
+ writeFileSync(p, entry);
+ try { execSync(`chmod +x "${p}"`); } catch {}
+ console.log(`[postinstall] Desktop shortcut created: ${p}`);
+ }
+ } catch (e) {
+ console.log(`[postinstall] Failed to create desktop entry: ${e.message}`);
+ }
+}
diff --git a/src/agents/tools.ts b/src/agents/tools.ts
index a699e585..d2e0f6e1 100644
--- a/src/agents/tools.ts
+++ b/src/agents/tools.ts
@@ -99,7 +99,6 @@ export const AGENT_TOOL_CONFIGS: Record = {
'Blueprint', // CLI 模式工具,Chat Tab 使用 GenerateBlueprint
'UpdateTaskPlan', // LeadAgent 专用 - 更新执行计划中的任务状态
'DispatchWorker', // LeadAgent 专用 - 派发任务给 Worker 执行
- 'TriggerE2ETest', // LeadAgent 专用 - 触发 E2E 端到端测试
],
permissionLevel: 'standard',
},
@@ -107,8 +106,8 @@ export const AGENT_TOOL_CONFIGS: Record = {
agentType: 'lead-agent',
allowedTools: [
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
- 'UpdateTaskPlan', 'DispatchWorker', 'TriggerE2ETest',
- 'AskUserQuestion', 'Database', 'Debugger',
+ 'UpdateTaskPlan', 'DispatchWorker',
+ 'AskUserQuestion', 'Database',
],
permissionLevel: 'elevated',
},
diff --git a/src/auth/index.ts b/src/auth/index.ts
index 4cc5f1d6..9458eaf0 100644
--- a/src/auth/index.ts
+++ b/src/auth/index.ts
@@ -109,29 +109,32 @@ const ENCRYPTION_KEY = crypto
.update(os.hostname() + os.userInfo().username)
.digest();
-// OAuth scope 定义(与官方一致)
-// qB4 = ["org:create_api_key", "user:profile"]
-// Aq1 = ["user:profile", "user:inference", "user:sessions:claude_code"]
-// CBQ = 合并去重
-const OAUTH_SCOPES = ['org:create_api_key', 'user:profile', 'user:inference', 'user:sessions:claude_code'];
+// OAuth scope 定义(从官方混淆源码还原)
+// hKK (Console scopes) = ["org:create_api_key", "user:profile"]
+// ph6 (Claude.ai scopes) = ["user:profile", "user:inference", "user:sessions:claude_code", "user:mcp_servers"]
+// vqA = 合并去重后的全部 scopes(官方在构建 authUrl 时统一使用 vqA)
+const CONSOLE_SCOPES = ['org:create_api_key', 'user:profile'];
+const CLAUDE_AI_SCOPES = ['user:profile', 'user:inference', 'user:sessions:claude_code', 'user:mcp_servers'];
+const ALL_SCOPES = Array.from(new Set([...CONSOLE_SCOPES, ...CLAUDE_AI_SCOPES]));
// OAuth 端点配置
+// 关键区别:claude.ai 使用 https://claude.ai/oauth/authorize,console 使用 https://platform.claude.com/oauth/authorize
export const OAUTH_ENDPOINTS: Record<'claude.ai' | 'console', OAuthConfig> = {
'claude.ai': {
clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
- authorizationEndpoint: 'https://platform.claude.com/oauth/authorize',
+ authorizationEndpoint: 'https://claude.ai/oauth/authorize',
deviceCodeEndpoint: 'https://platform.claude.com/oauth/device/code',
tokenEndpoint: 'https://platform.claude.com/v1/oauth/token',
- redirectUri: 'https://platform.claude.com/oauth/code/callback', // 使用官方的回调页面
- scope: OAUTH_SCOPES,
+ redirectUri: 'https://platform.claude.com/oauth/code/callback',
+ scope: ALL_SCOPES,
},
console: {
clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
authorizationEndpoint: 'https://platform.claude.com/oauth/authorize',
deviceCodeEndpoint: 'https://platform.claude.com/oauth/device/code',
tokenEndpoint: 'https://platform.claude.com/v1/oauth/token',
- redirectUri: 'https://platform.claude.com/oauth/code/callback', // 使用官方的回调页面
- scope: OAUTH_SCOPES,
+ redirectUri: 'https://platform.claude.com/oauth/code/callback',
+ scope: ALL_SCOPES,
},
};
diff --git a/src/background/shell-manager.ts b/src/background/shell-manager.ts
index 930920fa..29d316ec 100644
--- a/src/background/shell-manager.ts
+++ b/src/background/shell-manager.ts
@@ -107,6 +107,7 @@ export class ShellManager extends EventEmitter {
env: safeEnv,
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
+ windowsHide: true,
});
} else {
// Unix: 使用 bash -c
diff --git a/src/browser/controller.ts b/src/browser/controller.ts
index 42f97df9..3df1dfa0 100644
--- a/src/browser/controller.ts
+++ b/src/browser/controller.ts
@@ -9,10 +9,11 @@
import type { Locator, Page } from 'playwright-core';
import type { BrowserManager } from './manager.js';
-import type { SnapshotResult, TabInfo, CookieOptions, RefEntry } from './types.js';
+import type { SnapshotResult, TabInfo, CookieOptions, RefEntry, DownloadInfo, DialogInfo } from './types.js';
import { isNavigationAllowed } from './navigation-guard.js';
import { toAIFriendlyError, normalizeTimeoutMs } from './errors.js';
import WebSocket from 'ws';
+import * as path from 'node:path';
const INTERACTIVE_ROLES = new Set([
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
@@ -25,11 +26,21 @@ export class BrowserController {
private manager: BrowserManager;
private refsMap: Map = new Map();
private refCounter: number = 0;
+ private selectedFrameIndex: number = 0; // 0 = main frame, >0 = iframe
// 页面状态追踪
private consoleMessages: string[] = [];
private pageErrors: string[] = [];
private listenersAttached = false;
+
+ // Dialog 自动处理
+ private lastDialog: DialogInfo | null = null;
+ private dialogQueue: DialogInfo[] = [];
+ private autoAcceptDialogs: boolean = true; // 默认自动接受弹窗避免卡死
+
+ // Download 处理
+ private downloads: DownloadInfo[] = [];
+ private downloadListenerAttached = false;
// 会话专属 tab:每个 controller 实例拥有独立的 tab,不与用户冲突
private dedicatedPage: Page | null = null;
@@ -67,29 +78,37 @@ export class BrowserController {
let page: Page | undefined;
- // 策略1: 创建新 tab(推荐,保证隔离)
- try {
- page = await context.newPage();
- } catch {
- // 创建失败时回退到策略2
- }
+ // 策略1: 优先复用未被占用的空白页(避免产生多余空 tab)
+ const pages = browser.contexts().flatMap(c => c.pages());
+ page = pages.find(p => !p.isClosed() && !this.manager.isPageClaimed(p) &&
+ (p.url() === 'about:blank' || p.url() === ''));
- // 策略2: 回退 — 找一个未被其他会话占用的已有 page
+ // 策略2: 没有空白页,创建新 tab
+ let isNewlyCreated = false;
if (!page) {
- const pages = browser.contexts().flatMap(c => c.pages());
- // 优先找未被占用的空白页
- page = pages.find(p => !p.isClosed() && !this.manager.isPageClaimed(p) &&
- (p.url() === 'about:blank' || p.url() === ''));
- if (!page) {
- // 找未被占用的任意 page
- page = pages.find(p => !p.isClosed() && !this.manager.isPageClaimed(p));
+ try {
+ page = await context.newPage();
+ isNewlyCreated = true;
+ } catch {
+ // 创建失败时回退到策略3
}
}
+ // 策略3: 回退 — 找一个未被占用的任意 page
+ if (!page) {
+ page = pages.find(p => !p.isClosed() && !this.manager.isPageClaimed(p));
+ }
+
if (!page) {
throw new Error('Cannot create or find an available tab. All tabs are claimed by other sessions.');
}
+ // 新创建的 tab 需要等待 extension auto-attach(extension 有 500ms 延迟)
+ // 否则 Playwright 发的 CDP 命令和事件监听无法到达 extension
+ if (isNewlyCreated && this.manager.isExtensionConnected()) {
+ await new Promise(resolve => setTimeout(resolve, 800));
+ }
+
this.dedicatedPage = page;
this.listenersAttached = false;
this.manager.claimPage(page);
@@ -106,6 +125,22 @@ export class BrowserController {
return this.ensureDedicatedTab();
}
+ /**
+ * 获取当前活动的 frame(用于支持 iframe 操作)
+ */
+ private async getActiveFrame(): Promise {
+ const page = await this.getSessionPage();
+ if (this.selectedFrameIndex === 0) {
+ return page;
+ }
+ const frames = page.frames();
+ if (this.selectedFrameIndex >= frames.length) {
+ throw new Error(`Frame index ${this.selectedFrameIndex} out of bounds (total: ${frames.length})`);
+ }
+ // 注意:Frame 类型与 Page 类型兼容大部分操作
+ return frames[this.selectedFrameIndex] as any as Page;
+ }
+
/**
* 获取当前专属 tab 的 Page 引用(只读,不触发创建)
*/
@@ -175,6 +210,37 @@ export class BrowserController {
}
});
+ // 监听 dialog 事件(alert/confirm/prompt/beforeunload)
+ page.on('dialog', async (dialog) => {
+ const info: DialogInfo = {
+ type: dialog.type(),
+ message: dialog.message(),
+ handled: false,
+ };
+ this.dialogQueue.push(info);
+ this.lastDialog = info;
+
+ // 保留最近 10 条
+ if (this.dialogQueue.length > 10) {
+ this.dialogQueue.shift();
+ }
+
+ // 自动处理模式:立即接受弹窗避免页面挂死
+ if (this.autoAcceptDialogs) {
+ try {
+ if (dialog.type() === 'prompt') {
+ await dialog.accept('');
+ } else {
+ await dialog.accept();
+ }
+ info.handled = true;
+ info.response = 'auto-accepted';
+ } catch {
+ // dialog 可能已被处理
+ }
+ }
+ });
+
this.listenersAttached = true;
}
@@ -183,7 +249,7 @@ export class BrowserController {
/**
* Check if page is responsive
*/
- private async isPageResponsive(timeoutMs: number = 3000): Promise {
+ private async isPageResponsive(timeoutMs: number = 5000): Promise {
try {
// 检查专属 tab 是否响应(如果还没有专属 tab,视为响应正常)
if (!this.dedicatedPage || this.dedicatedPage.isClosed()) {
@@ -261,16 +327,17 @@ export class BrowserController {
// --- Snapshot ---
- async snapshot(options?: { interactive?: boolean }): Promise {
- // Health check: recover from hung page
- const isResponsive = await this.isPageResponsive();
+ async snapshot(options?: { interactive?: boolean; skipHealthCheck?: boolean }): Promise {
+ // Health check: recover from hung page (skip when caller already verified, e.g. after goto)
+ const isResponsive = options?.skipHealthCheck ? true : await this.isPageResponsive();
if (!isResponsive) {
try {
await this.terminateExecution();
// Wait a bit for recovery
await new Promise(resolve => setTimeout(resolve, 500));
- } catch (error) {
- throw new Error('Page is unresponsive and recovery failed. Please reload the page.');
+ } catch {
+ // Recovery failed, but don't give up — let ariaSnapshot below
+ // determine if the page is truly unusable.
}
}
@@ -279,58 +346,153 @@ export class BrowserController {
// 绑定页面事件监听器(如果还没有绑定)
this.attachPageListeners(page);
+ // 当选中了 frame 时,只对该 frame 做 snapshot
+ const targetFrame = this.selectedFrameIndex > 0
+ ? page.frames()[this.selectedFrameIndex]
+ : null;
+
this.refsMap.clear();
this.refCounter = 0;
- const ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
-
- if (!ariaYaml) {
- return {
- title: await page.title(),
- url: page.url(),
- content: 'No accessibility tree available',
- refs: this.refsMap,
- };
- }
-
- // Parse ariaSnapshot YAML and assign ref IDs
- // Format: " - role "name" [extra]" or " - role [extra]"
+ // Parse ariaSnapshot YAML for a single frame, assigning ref IDs
const roleNameCount = new Map();
- const lines = ariaYaml.split('\n');
- const outputLines: string[] = [];
-
- for (const line of lines) {
- // Match: indent + "- " + role + optional ' "name"' + optional rest
- const match = line.match(/^(\s*- )(\w+)(?:\s+"([^"]*)")?(.*)$/);
-
- if (match) {
- const [, prefix, role, name, rest] = match;
- const isInteractive = INTERACTIVE_ROLES.has(role);
-
- if (options?.interactive && !isInteractive) {
- continue; // Skip non-interactive in interactive mode
+ const parseAriaYaml = (ariaYaml: string, frameIndex: number, interactive?: boolean): string[] => {
+ const lines = ariaYaml.split('\n');
+ const outputLines: string[] = [];
+ for (const line of lines) {
+ const match = line.match(/^(\s*- )(\w+)(?:\s+"([^"]*)")?(.*)$/);
+ if (match) {
+ const [, prefix, role, name, rest] = match;
+ const isInteractive = INTERACTIVE_ROLES.has(role);
+ if (interactive && !isInteractive) continue;
+ if (name !== undefined) {
+ const key = `${frameIndex}:${role}:${name}`;
+ const count = roleNameCount.get(key) || 0;
+ roleNameCount.set(key, count + 1);
+ this.refCounter++;
+ const refId = `e${this.refCounter}`;
+ this.refsMap.set(refId, { role, name, nth: count, frameIndex });
+ outputLines.push(`${prefix}${role} "${name}" [ref=${refId}]${rest}`);
+ } else {
+ outputLines.push(line);
+ }
+ } else {
+ if (!interactive) outputLines.push(line);
}
+ }
+ return outputLines;
+ };
- if (name !== undefined) {
- const key = `${role}:${name}`;
- const count = roleNameCount.get(key) || 0;
- roleNameCount.set(key, count + 1);
-
- this.refCounter++;
- const refId = `e${this.refCounter}`;
- this.refsMap.set(refId, { role, name, nth: count });
+ // 如果选中了特定 frame,只扫描该 frame
+ let outputLines: string[];
+ if (targetFrame) {
+ const frameAriaYaml = await targetFrame.locator('body').ariaSnapshot({ timeout: 10000 });
+ if (!frameAriaYaml) {
+ return {
+ title: await page.title(),
+ url: targetFrame.url(),
+ content: 'No accessibility tree available in selected frame',
+ refs: this.refsMap,
+ };
+ }
+ outputLines = parseAriaYaml(frameAriaYaml, this.selectedFrameIndex, options?.interactive);
+ } else {
+ // --- Main frame ---
+ const mainAriaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
+ if (!mainAriaYaml) {
+ return {
+ title: await page.title(),
+ url: page.url(),
+ content: 'No accessibility tree available',
+ refs: this.refsMap,
+ };
+ }
+ outputLines = parseAriaYaml(mainAriaYaml, 0, options?.interactive);
- outputLines.push(`${prefix}${role} "${name}" [ref=${refId}]${rest}`);
- } else {
- outputLines.push(line);
- }
- } else {
- if (!options?.interactive) {
- outputLines.push(line);
+ // --- Iframes (cross-origin included) ---
+ const frames = page.frames();
+ for (let i = 1; i < frames.length; i++) {
+ try {
+ const frame = frames[i];
+ const frameUrl = frame.url();
+ // Skip blank/about frames
+ if (!frameUrl || frameUrl === 'about:blank' || frameUrl === 'about:srcdoc') continue;
+ const frameAriaYaml = await frame.locator('body').ariaSnapshot({ timeout: 3000 });
+ if (frameAriaYaml) {
+ // Extract domain for labeling
+ let frameDomain = '';
+ try { frameDomain = new URL(frameUrl).hostname; } catch {}
+ outputLines.push('');
+ outputLines.push(`=== iframe [${frameDomain || frameUrl}] ===`);
+ outputLines.push(...parseAriaYaml(frameAriaYaml, i, options?.interactive));
+ }
+ } catch {
+ // Skip frames that are not accessible (detached, navigating, etc.)
}
}
}
+ // 在 interactive 模式下,扫描 cursor:pointer 的 clickable 元素
+ if (options?.interactive) {
+ try {
+ const activeFrame = targetFrame || page;
+ // @ts-ignore - runs in browser context, DOM APIs not available in Node types
+ const clickableElements: Array<{ text: string; tag: string; selector: string }> = await activeFrame.evaluate(() => {
+ const elements: any[] = [];
+ const standardTags = new Set(['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA']);
+
+ const allElements = (globalThis as any).document.querySelectorAll('*');
+ let idx = 0;
+ allElements.forEach((el: any) => {
+ idx++;
+ // 跳过标准交互元素
+ if (standardTags.has(el.tagName)) return;
+
+ // 检查 cursor:pointer
+ const computedStyle = (globalThis as any).getComputedStyle(el);
+ if (computedStyle.cursor !== 'pointer') return;
+
+ // 检查可见性和尺寸
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 10 || rect.height < 10) return;
+ if (rect.top > (globalThis as any).innerHeight || rect.bottom < 0) return;
+
+ // 获取文本内容
+ let text = el.innerText || el.textContent || '';
+ text = text.trim().slice(0, 50); // 限制长度
+ if (!text) return;
+
+ // 生成唯一的 selector
+ const selector = `${el.tagName.toLowerCase()}:nth-of-type(${idx})`;
+
+ elements.push({ text, tag: el.tagName, selector });
+ });
+
+ return elements;
+ });
+
+ if (clickableElements.length > 0) {
+ outputLines.push('');
+ outputLines.push('=== Clickable Elements ===');
+ for (const el of clickableElements) {
+ this.refCounter++;
+ const refId = `e${this.refCounter}`;
+ const frameIndex = targetFrame ? this.selectedFrameIndex : 0;
+ this.refsMap.set(refId, {
+ role: 'clickable',
+ name: el.text,
+ nth: 0,
+ frameIndex,
+ selector: el.selector,
+ });
+ outputLines.push(`- ${el.tag.toLowerCase()} "${el.text}" [ref=${refId}]`);
+ }
+ }
+ } catch (error) {
+ // 扫描失败不影响主流程
+ }
+ }
+
// 追加页面错误和 console 信息(如果有的话)
let finalContent = outputLines.join('\n');
@@ -382,7 +544,7 @@ export class BrowserController {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(500);
- return this.snapshot();
+ return this.snapshot({ skipHealthCheck: true });
}
async goBack(): Promise {
@@ -411,7 +573,23 @@ export class BrowserController {
}
const page = await this.getSessionPage();
- let locator = page.getByRole(entry.role as any, {
+
+ // Resolve the correct frame (main frame or iframe)
+ let owner: Page | import('playwright-core').Frame = page;
+ if (entry.frameIndex && entry.frameIndex > 0) {
+ const frames = page.frames();
+ if (entry.frameIndex < frames.length) {
+ owner = frames[entry.frameIndex];
+ }
+ }
+
+ // 如果有 selector,使用 selector 定位(用于非标准 clickable 元素)
+ if (entry.selector) {
+ return owner.locator(entry.selector);
+ }
+
+ // 否则使用 role-based 定位
+ let locator = owner.getByRole(entry.role as any, {
name: entry.name,
exact: true,
});
@@ -425,19 +603,55 @@ export class BrowserController {
// --- Interactions ---
- async click(ref: string): Promise {
+ async click(ref?: string, options?: { x?: number; y?: number }): Promise {
try {
- const locator = await this.resolveLocator(ref);
- await locator.click({ timeout: 5000 });
+ const page = await this.getSessionPage();
+
+ // 坐标模式:直接点击指定坐标
+ if (!ref && options?.x !== undefined && options?.y !== undefined) {
+ await page.mouse.click(options.x, options.y, {
+ delay: 50,
+ });
+ return;
+ }
+
+ // ref 模式:使用 locator 点击
+ if (ref) {
+ const locator = await this.resolveLocator(ref);
+ await locator.click({ timeout: 5000 });
+ return;
+ }
+
+ throw new Error('Either ref or x/y coordinates must be provided for click');
} catch (error) {
throw toAIFriendlyError(error);
}
}
- async fill(ref: string, value: string): Promise {
+ async fill(ref?: string, value?: string, options?: { x?: number; y?: number }): Promise {
try {
- const locator = await this.resolveLocator(ref);
- await locator.fill(value, { timeout: 5000 });
+ if (!value) {
+ throw new Error('value is required for fill');
+ }
+
+ const page = await this.getSessionPage();
+
+ // 坐标模式:先点击聚焦,然后输入
+ if (!ref && options?.x !== undefined && options?.y !== undefined) {
+ await page.mouse.click(options.x, options.y);
+ await page.waitForTimeout(100); // 等待聚焦
+ await page.keyboard.type(value);
+ return;
+ }
+
+ // ref 模式:使用 locator fill
+ if (ref) {
+ const locator = await this.resolveLocator(ref);
+ await locator.fill(value, { timeout: 5000 });
+ return;
+ }
+
+ throw new Error('Either ref or x/y coordinates must be provided for fill');
} catch (error) {
throw toAIFriendlyError(error);
}
@@ -471,6 +685,198 @@ export class BrowserController {
}
}
+ // --- Enhanced Interactions ---
+
+ async dblclick(ref?: string, options?: { x?: number; y?: number }): Promise {
+ try {
+ const page = await this.getSessionPage();
+
+ // 坐标模式:直接双击指定坐标
+ if (!ref && options?.x !== undefined && options?.y !== undefined) {
+ await page.mouse.dblclick(options.x, options.y, {
+ delay: 50,
+ });
+ return;
+ }
+
+ // ref 模式:使用 locator 双击
+ if (ref) {
+ const locator = await this.resolveLocator(ref);
+ await locator.dblclick({ timeout: 5000 });
+ return;
+ }
+
+ throw new Error('Either ref or x/y coordinates must be provided for dblclick');
+ } catch (error) {
+ throw toAIFriendlyError(error);
+ }
+ }
+
+ async rightclick(ref?: string, options?: { x?: number; y?: number }): Promise {
+ try {
+ const page = await this.getSessionPage();
+
+ // 坐标模式:直接右击指定坐标
+ if (!ref && options?.x !== undefined && options?.y !== undefined) {
+ await page.mouse.click(options.x, options.y, {
+ button: 'right',
+ delay: 50,
+ });
+ return;
+ }
+
+ // ref 模式:使用 locator 右击
+ if (ref) {
+ const locator = await this.resolveLocator(ref);
+ await locator.click({ button: 'right', timeout: 5000 });
+ return;
+ }
+
+ throw new Error('Either ref or x/y coordinates must be provided for rightclick');
+ } catch (error) {
+ throw toAIFriendlyError(error);
+ }
+ }
+
+ async dragAndDrop(sourceRef: string, targetRef: string): Promise {
+ try {
+ const source = await this.resolveLocator(sourceRef);
+ const target = await this.resolveLocator(targetRef);
+ await source.dragTo(target, { timeout: 10000 });
+ } catch (error) {
+ throw toAIFriendlyError(error);
+ }
+ }
+
+ async scroll(options: { ref?: string; deltaX?: number; deltaY?: number }): Promise {
+ try {
+ const page = await this.getSessionPage();
+ if (options.ref) {
+ // 先滚动到指定元素可见
+ const locator = await this.resolveLocator(options.ref);
+ await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
+ } else {
+ // 滚动页面(使用 mouse.wheel)
+ const dx = options.deltaX ?? 0;
+ const dy = options.deltaY ?? 300; // 默认向下滚动 300px
+ await page.mouse.wheel(dx, dy);
+ }
+ } catch (error) {
+ throw toAIFriendlyError(error);
+ }
+ }
+
+ // --- Wait mechanisms ---
+
+ async waitForSelector(selector: string, options?: { timeout?: number; state?: 'attached' | 'detached' | 'visible' | 'hidden' }): Promise {
+ try {
+ const page = await this.getSessionPage();
+ await page.waitForSelector(selector, {
+ timeout: options?.timeout ?? 10000,
+ state: options?.state ?? 'visible',
+ });
+ } catch (error) {
+ throw toAIFriendlyError(error);
+ }
+ }
+
+ async waitForUrl(urlPattern: string, options?: { timeout?: number }): Promise {
+ try {
+ const page = await this.getSessionPage();
+ // 支持字符串匹配和正则
+ await page.waitForURL(urlPattern, {
+ timeout: options?.timeout ?? 30000,
+ });
+ } catch (error) {
+ throw toAIFriendlyError(error);
+ }
+ }
+
+ async waitForLoadState(state: 'load' | 'domcontentloaded' | 'networkidle', options?: { timeout?: number }): Promise {
+ try {
+ const page = await this.getSessionPage();
+ await page.waitForLoadState(state, {
+ timeout: options?.timeout ?? 30000,
+ });
+ } catch (error) {
+ throw toAIFriendlyError(error);
+ }
+ }
+
+ async waitForTimeout(ms: number): Promise {
+ const page = await this.getSessionPage();
+ await page.waitForTimeout(Math.min(ms, 30000)); // 最多 30s
+ }
+
+ /**
+ * Wait for DOM to become stable (no mutations for stableMs milliseconds)
+ */
+ async waitForStable(options?: { timeout?: number; stableMs?: number }): Promise {
+ const page = await this.getActiveFrame();
+ const timeout = options?.timeout ?? 10000;
+ const stableMs = options?.stableMs ?? 500;
+
+ try {
+ // @ts-ignore - runs in browser context, MutationObserver/document not available in Node types
+ await page.evaluate(
+ ({ timeoutMs, stableMsParam }: { timeoutMs: number; stableMsParam: number }) => {
+ return new Promise((resolve) => {
+ let timer: any = setTimeout(() => resolve(), stableMsParam);
+
+ const observer = new (globalThis as any).MutationObserver(() => {
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ if (observer) {
+ observer.disconnect();
+ }
+ resolve();
+ }, stableMsParam);
+ });
+
+ observer.observe((globalThis as any).document.body, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ });
+
+ // 超时后停止观察并 resolve
+ setTimeout(() => {
+ if (observer) {
+ observer.disconnect();
+ }
+ resolve();
+ }, timeoutMs);
+ });
+ },
+ { timeoutMs: timeout, stableMsParam: stableMs }
+ );
+ } catch (error) {
+ // 超时或其他错误,不抛出,因为这只是一个等待操作
+ }
+ }
+
+ // --- Mouse precise operations ---
+
+ async mouseMove(x: number, y: number): Promise {
+ const page = await this.getSessionPage();
+ await page.mouse.move(x, y);
+ }
+
+ async mouseDown(options?: { button?: 'left' | 'middle' | 'right' }): Promise {
+ const page = await this.getSessionPage();
+ await page.mouse.down({ button: options?.button ?? 'left' });
+ }
+
+ async mouseUp(options?: { button?: 'left' | 'middle' | 'right' }): Promise {
+ const page = await this.getSessionPage();
+ await page.mouse.up({ button: options?.button ?? 'left' });
+ }
+
+ async mouseWheel(deltaX: number, deltaY: number): Promise {
+ const page = await this.getSessionPage();
+ await page.mouse.wheel(deltaX, deltaY);
+ }
+
// --- File Upload ---
async uploadFile(ref: string | undefined, filePath: string): Promise {
@@ -506,7 +912,11 @@ export class BrowserController {
async screenshot(options?: { fullPage?: boolean }): Promise {
const page = await this.getSessionPage();
- return await page.screenshot({ fullPage: options?.fullPage ?? false });
+ return await page.screenshot({
+ fullPage: options?.fullPage ?? false,
+ scale: 'css',
+ timeout: 30000,
+ });
}
/**
@@ -521,7 +931,11 @@ export class BrowserController {
const refs = Array.from(this.refsMap.entries());
if (refs.length === 0) {
- const buffer = await page.screenshot({ fullPage: options?.fullPage ?? false });
+ const buffer = await page.screenshot({
+ fullPage: options?.fullPage ?? false,
+ scale: 'css',
+ timeout: 15000,
+ });
return { buffer, labelCount: 0, skippedCount: 0 };
}
@@ -605,7 +1019,11 @@ export class BrowserController {
labelCount = labelData.length;
// Take screenshot
- const buffer = await page.screenshot({ fullPage: options?.fullPage ?? false });
+ const buffer = await page.screenshot({
+ fullPage: options?.fullPage ?? false,
+ scale: 'css',
+ timeout: 15000,
+ });
// Remove overlays
// @ts-ignore - This code runs in browser context
@@ -636,6 +1054,33 @@ export class BrowserController {
);
}
+ /**
+ * List all frames (including iframes) in the current page
+ */
+ async frameList(): Promise> {
+ const page = await this.getSessionPage();
+ const frames = page.frames();
+
+ return frames.map((frame, index) => {
+ const parentFrame = frame.parentFrame();
+ const parentIndex = parentFrame ? frames.indexOf(parentFrame) : null;
+
+ return {
+ index,
+ url: frame.url(),
+ name: frame.name(),
+ parentFrame: parentIndex,
+ };
+ });
+ }
+
+ /**
+ * Select a frame to operate on (0 = main frame, >0 = iframe)
+ */
+ frameSelect(frameIndex: number): void {
+ this.selectedFrameIndex = frameIndex;
+ }
+
async tabNew(url?: string): Promise {
const browser = this.manager.getBrowser();
if (!browser) throw new Error('Browser is not running.');
@@ -649,6 +1094,12 @@ export class BrowserController {
}
const newPage = await context.newPage();
+
+ // 等待 extension auto-attach 新 tab(extension 有 500ms 延迟)
+ if (this.manager.isExtensionConnected()) {
+ await new Promise(resolve => setTimeout(resolve, 800));
+ }
+
// 新建的 tab 成为新的专属 tab
this.dedicatedPage = newPage;
this.listenersAttached = false;
@@ -752,7 +1203,7 @@ export class BrowserController {
// --- Evaluate ---
async evaluate(expression: string, timeoutMs?: number): Promise {
- const page = await this.getSessionPage();
+ const page = await this.getActiveFrame();
const timeout = normalizeTimeoutMs(timeoutMs);
try {
@@ -794,4 +1245,174 @@ export class BrowserController {
pageErrors: [...this.pageErrors],
};
}
+
+ // --- Dialog Handling ---
+
+ async handleDialog(action: 'accept' | 'dismiss', text?: string): Promise {
+ // 临时禁用自动处理,等待下一个弹窗
+ // 此方法用于 AI 主动控制弹窗行为
+ const info = this.lastDialog;
+ if (!info) {
+ return null;
+ }
+ // 返回最后一个弹窗的信息
+ return info;
+ }
+
+ getDialogHistory(): DialogInfo[] {
+ return [...this.dialogQueue];
+ }
+
+ setAutoAcceptDialogs(enabled: boolean): void {
+ this.autoAcceptDialogs = enabled;
+ }
+
+ // --- Download Handling ---
+
+ async setupDownloadListener(savePath?: string): Promise {
+ if (this.downloadListenerAttached) return;
+
+ const page = await this.getSessionPage();
+
+ page.on('download', async (download) => {
+ const info: DownloadInfo = {
+ suggestedFilename: download.suggestedFilename(),
+ url: download.url(),
+ };
+
+ try {
+ if (savePath) {
+ const fullPath = savePath.endsWith(download.suggestedFilename())
+ ? savePath
+ : path.join(savePath, download.suggestedFilename());
+ await download.saveAs(fullPath);
+ info.savedPath = fullPath;
+ } else {
+ // 保存到默认下载路径
+ const downloadPath = await download.path();
+ info.savedPath = downloadPath || undefined;
+ }
+ } catch (err: any) {
+ info.savedPath = `FAILED: ${err.message}`;
+ }
+
+ this.downloads.push(info);
+ if (this.downloads.length > 50) {
+ this.downloads.shift();
+ }
+ });
+
+ this.downloadListenerAttached = true;
+ }
+
+ getDownloads(): DownloadInfo[] {
+ return [...this.downloads];
+ }
+
+ // --- Viewport ---
+
+ async setViewport(width: number, height: number): Promise {
+ const page = await this.getSessionPage();
+ await page.setViewportSize({ width, height });
+ }
+
+ // --- Storage (localStorage / sessionStorage) ---
+
+ async storageGet(type: 'local' | 'session', key?: string): Promise {
+ const page = await this.getSessionPage();
+ const storageType = type === 'local' ? 'localStorage' : 'sessionStorage';
+ if (key) {
+ // @ts-ignore - runs in browser context
+ return await page.evaluate(([st, k]) => {
+ return (globalThis as any)[st].getItem(k);
+ }, [storageType, key] as const);
+ } else {
+ // @ts-ignore - runs in browser context
+ return await page.evaluate((st) => {
+ const storage = (globalThis as any)[st];
+ const result: Record = {};
+ for (let i = 0; i < storage.length; i++) {
+ const k = storage.key(i);
+ if (k) result[k] = storage.getItem(k);
+ }
+ return result;
+ }, storageType);
+ }
+ }
+
+ async storageSet(type: 'local' | 'session', key: string, value: string): Promise {
+ const page = await this.getSessionPage();
+ const storageType = type === 'local' ? 'localStorage' : 'sessionStorage';
+ // @ts-ignore - runs in browser context
+ await page.evaluate(([st, k, v]) => {
+ (globalThis as any)[st].setItem(k, v);
+ }, [storageType, key, value] as const);
+ }
+
+ async storageClear(type: 'local' | 'session'): Promise {
+ const page = await this.getSessionPage();
+ const storageType = type === 'local' ? 'localStorage' : 'sessionStorage';
+ // @ts-ignore - runs in browser context
+ await page.evaluate((st) => {
+ (globalThis as any)[st].clear();
+ }, storageType);
+ }
+
+ // --- PDF ---
+
+ async generatePdf(savePath: string): Promise {
+ try {
+ const page = await this.getSessionPage();
+ await page.pdf({ path: savePath });
+ return savePath;
+ } catch (error: any) {
+ if (error.message?.includes('pdf') || error.message?.includes('headless')) {
+ throw new Error('PDF generation requires headless mode. Current browser is running in headed mode.');
+ }
+ throw toAIFriendlyError(error);
+ }
+ }
+
+ // --- Network Interception ---
+
+ private activeRoutes: Map Promise> = new Map();
+
+ async networkIntercept(pattern: string, action: 'block' | 'continue' | 'fulfill', options?: { body?: string; status?: number }): Promise {
+ const page = await this.getSessionPage();
+
+ // 移除已有的同 pattern 路由
+ if (this.activeRoutes.has(pattern)) {
+ await this.activeRoutes.get(pattern)!();
+ this.activeRoutes.delete(pattern);
+ }
+
+ await page.route(pattern, async (route) => {
+ switch (action) {
+ case 'block':
+ await route.abort();
+ break;
+ case 'continue':
+ await route.continue();
+ break;
+ case 'fulfill':
+ await route.fulfill({
+ status: options?.status ?? 200,
+ body: options?.body ?? '',
+ });
+ break;
+ }
+ });
+
+ // 记录卸载函数
+ this.activeRoutes.set(pattern, async () => {
+ await page.unroute(pattern);
+ });
+ }
+
+ async networkAbort(pattern: string): Promise {
+ if (this.activeRoutes.has(pattern)) {
+ await this.activeRoutes.get(pattern)!();
+ this.activeRoutes.delete(pattern);
+ }
+ }
}
diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts
index 69a93486..17a1a6d1 100644
--- a/src/browser/extension-relay.ts
+++ b/src/browser/extension-relay.ts
@@ -128,6 +128,9 @@ async function createRelayServer(
port: number,
): Promise {
const authToken = resolveRelayAuthToken(port);
+
+ // Verbose CDP logging — only when CLAUDE_VERBOSE or RELAY_VERBOSE is set
+ const verbose = process.env.CLAUDE_VERBOSE === 'true' || process.env.RELAY_VERBOSE === 'true';
// State
const connectedTargets = new Map();
@@ -191,7 +194,7 @@ async function createRelayServer(
sessionEventBuffer.set(sessionId, buf);
}
buf.push(message);
- console.log(`[Relay] Buffered ${eventMethod} for session ${sessionId} (${buf.length} buffered)`);
+ if (verbose) console.log(`[Relay] Buffered ${eventMethod} for session ${sessionId} (${buf.length} buffered)`);
return true;
}
@@ -203,7 +206,7 @@ async function createRelayServer(
return;
}
sessionEventBuffer.delete(sessionId);
- console.log(`[Relay] Flushing ${buf.length} buffered events for session ${sessionId}`);
+ if (verbose) console.log(`[Relay] Flushing ${buf.length} buffered events for session ${sessionId}`);
for (const msg of buf) {
broadcastToCDPClients(msg);
}
@@ -313,12 +316,23 @@ async function createRelayServer(
res.end('Not Found');
});
+ // Handle client connection errors (e.g. ECONNRESET when Chrome is force-killed)
+ httpServer.on('clientError', (err, socket) => {
+ console.warn('[Relay] Client error (ignored):', err.message);
+ if (socket.writable) {
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
+ }
+ });
+
// ======================================================================
// WebSocket Servers
// ======================================================================
// Extension WebSocket
const extensionWss = new WebSocketServer({ noServer: true });
+ extensionWss.on('error', (err) => {
+ console.error('[Relay] Extension WSS error (non-fatal):', err.message);
+ });
extensionWss.on('connection', (ws, req) => {
console.log('[Relay] Extension connected');
@@ -357,6 +371,9 @@ async function createRelayServer(
// CDP Client WebSocket
const cdpWss = new WebSocketServer({ noServer: true });
+ cdpWss.on('error', (err) => {
+ console.error('[Relay] CDP WSS error (non-fatal):', err.message);
+ });
cdpWss.on('connection', (ws, req) => {
console.log('[Relay] CDP client connected');
@@ -384,6 +401,11 @@ async function createRelayServer(
// HTTP Upgrade Handler
httpServer.on('upgrade', (request, socket, head) => {
+ // Prevent unhandled socket errors (e.g. ECONNRESET when Chrome is killed)
+ socket.on('error', (err) => {
+ console.warn('[Relay] Upgrade socket error (ignored):', err.message);
+ });
+
const url = new URL(request.url || '/', `http://${request.headers.host}`);
const wsPathname = url.pathname.replace(/\/+$/, '') || '/';
@@ -455,7 +477,7 @@ async function createRelayServer(
return;
}
- console.log(`[Relay] EXT <<< ${method || 'response'}`, id !== undefined ? `id=${id}` : '', params ? JSON.stringify(params).substring(0, 200) : '');
+ if (verbose) console.log(`[Relay] EXT <<< ${method || 'response'}`, id !== undefined ? `id=${id}` : '', params ? JSON.stringify(params).substring(0, 200) : '');
// Handle forwardCDPEvent
if (method === 'forwardCDPEvent') {
@@ -634,13 +656,13 @@ async function createRelayServer(
*/
async function handleCDPMessage(client: WebSocket, message: any) {
const { id, method, params, sessionId } = message;
- console.log(`[Relay] CDP <<< ${method}`, params ? JSON.stringify(params).substring(0, 200) : '', 'targets:', connectedTargets.size);
+ if (verbose) console.log(`[Relay] CDP <<< ${method}`, params ? JSON.stringify(params).substring(0, 200) : '', 'targets:', connectedTargets.size);
// Track Page.getFrameTree commands to buffer Runtime events during init.
const isFrameTree = method === 'Page.getFrameTree' && sessionId;
if (isFrameTree) {
sessionsPendingFrameTree.add(sessionId);
- console.log(`[Relay] Page.getFrameTree pending for session ${sessionId}`);
+ if (verbose) console.log(`[Relay] Page.getFrameTree pending for session ${sessionId}`);
}
try {
@@ -651,7 +673,7 @@ async function createRelayServer(
const response: any = { id, result };
if (sessionId) response.sessionId = sessionId;
const resultStr = JSON.stringify(result || {});
- console.log(`[Relay] CDP >>> id=${id} ${method} ${sessionId ? 'sid=' + sessionId : ''}`, resultStr.substring(0, method === 'Page.getFrameTree' ? 5000 : 200));
+ if (verbose) console.log(`[Relay] CDP >>> id=${id} ${method} ${sessionId ? 'sid=' + sessionId : ''}`, resultStr.substring(0, method === 'Page.getFrameTree' ? 5000 : 200));
sendToCDPClient(client, response);
// After sending Page.getFrameTree response, flush buffered events.
@@ -915,6 +937,11 @@ async function createRelayServer(
httpServer.on('error', reject);
});
+ // Persistent error handler for runtime (the above only covers startup)
+ httpServer.on('error', (err) => {
+ console.error('[Relay] HTTP server error (non-fatal):', err.message);
+ });
+
// Return server interface
const server: ChromeExtensionRelayServer = {
host,
diff --git a/src/browser/extension/background.js b/src/browser/extension/background.js
index cc6873be..0f3cc397 100644
--- a/src/browser/extension/background.js
+++ b/src/browser/extension/background.js
@@ -14,7 +14,28 @@
* - Playwright connects to relay, controls tabs through extension (anti-detection)
*/
-import { reconnectDelayMs, buildRelayWsUrl, isRetryableReconnectError } from './background-utils.js';
+// Inlined from background-utils.js to avoid ES module import issues in some Chrome versions
+function reconnectDelayMs(attempt, opts = {}) {
+ const { baseMs = 1000, maxMs = 30000, factor = 2 } = opts;
+ const delay = baseMs * Math.pow(factor, attempt);
+ return Math.min(delay, maxMs);
+}
+
+function buildRelayWsUrl(port, gatewayToken) {
+ const host = '127.0.0.1';
+ const encodedToken = encodeURIComponent(gatewayToken);
+ return `ws://${host}:${port}/extension?token=${encodedToken}`;
+}
+
+function isRetryableReconnectError(err) {
+ const errMsg = typeof err === 'string' ? err : (err?.message || '');
+ const retryablePatterns = [
+ /ECONNREFUSED/i, /ECONNRESET/i, /ETIMEDOUT/i, /EHOSTUNREACH/i,
+ /socket hang up/i, /network error/i, /Failed to fetch/i,
+ /ERR_CONNECTION_REFUSED/i, /ERR_CONNECTION_RESET/i, /AbortError/i,
+ ];
+ return retryablePatterns.some(pattern => pattern.test(errMsg));
+}
// ========================================================================
// Configuration & State
@@ -492,6 +513,62 @@ async function attachTab(tabId) {
await chrome.debugger.attach({ tabId }, '1.3');
console.log('[Bridge] attachTab: debugger attached successfully');
+ // --- Inject stealth scripts to avoid bot detection ---
+ const stealthScript = `
+ // 1. navigator.webdriver = false
+ Object.defineProperty(navigator, 'webdriver', { get: () => false });
+
+ // 2. Fake plugins (Chrome typically has 5)
+ Object.defineProperty(navigator, 'plugins', {
+ get: () => {
+ const arr = [
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
+ { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
+ ];
+ arr.item = (i) => arr[i] || null;
+ arr.namedItem = (n) => arr.find(p => p.name === n) || null;
+ arr.refresh = () => {};
+ return arr;
+ }
+ });
+
+ // 3. Fake languages
+ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
+
+ // 4. Permissions.query override (notification = prompt)
+ const origQuery = window.Permissions?.prototype?.query;
+ if (origQuery) {
+ window.Permissions.prototype.query = function(desc) {
+ if (desc?.name === 'notifications') {
+ return Promise.resolve({ state: Notification.permission });
+ }
+ return origQuery.call(this, desc);
+ };
+ }
+
+ // 5. Patch chrome.runtime to look non-automated
+ if (!window.chrome) window.chrome = {};
+ if (!window.chrome.runtime) window.chrome.runtime = { connect: () => {}, sendMessage: () => {} };
+ `;
+
+ // Inject for all future navigations
+ try {
+ await chrome.debugger.sendCommand({ tabId }, 'Page.addScriptToEvaluateOnNewDocument', { source: stealthScript });
+ } catch (e) {
+ console.warn('[Bridge] Failed to add stealth script for future navigations:', e);
+ }
+
+ // Execute immediately on current page
+ try {
+ await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
+ expression: stealthScript,
+ returnByValue: true,
+ });
+ } catch (e) {
+ console.warn('[Bridge] Failed to evaluate stealth script on current page:', e);
+ }
+
// Get target info
const targetInfo = await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo', {});
diff --git a/src/browser/extension/manifest.json b/src/browser/extension/manifest.json
index df276e18..3e8d3fb9 100644
--- a/src/browser/extension/manifest.json
+++ b/src/browser/extension/manifest.json
@@ -16,8 +16,7 @@
"http://localhost/*"
],
"background": {
- "service_worker": "background.js",
- "type": "module"
+ "service_worker": "background.js"
},
"action": {
"default_title": "Claude Code Browser Bridge (click to attach/detach)"
diff --git a/src/browser/manager.ts b/src/browser/manager.ts
index a0e7f365..37d2338d 100644
--- a/src/browser/manager.ts
+++ b/src/browser/manager.ts
@@ -70,12 +70,71 @@ async function isPortAvailable(port: number): Promise {
async function findAvailableCdpPort(preferredPort?: number): Promise {
if (preferredPort) {
- if (await isPortAvailable(preferredPort)) return preferredPort;
+ // Must check both CDP port AND relay port (cdpPort+1)
+ if (await isPortAvailable(preferredPort) && await isPortAvailable(preferredPort + 1)) {
+ return preferredPort;
+ }
}
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
- if (await isPortAvailable(port)) return port;
+ // Both CDP port and relay port (port+1) must be free
+ if (await isPortAvailable(port) && await isPortAvailable(port + 1)) {
+ return port;
+ }
+ }
+ throw new Error(`No available CDP+relay port pair found in range ${CDP_PORT_RANGE_START}-${CDP_PORT_RANGE_END}.`);
+}
+
+/**
+ * Kill orphan Chrome processes from previous interrupted sessions.
+ * Looks for chrome.exe processes with --user-data-dir pointing to .claude/browser.
+ */
+async function killOrphanChromes(): Promise {
+ try {
+ const browserDir = path.join(os.homedir(), '.claude', 'browser').replace(/\\/g, '\\\\');
+ let pids: number[] = [];
+
+ if (process.platform === 'win32') {
+ const output = execSync(
+ `wmic process where "name='chrome.exe'" get commandline,processid /format:csv`,
+ { encoding: 'utf-8', timeout: 5000, windowsHide: true }
+ );
+ for (const line of output.split('\n')) {
+ if (line.includes('.claude') && line.includes('browser') && line.includes('--remote-debugging-')) {
+ const pidMatch = line.match(/,(\d+)\s*$/);
+ if (pidMatch) pids.push(parseInt(pidMatch[1], 10));
+ }
+ }
+ for (const pid of pids) {
+ console.log(`[BrowserManager] Killing orphan Chrome PID ${pid}`);
+ try { execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore', windowsHide: true, timeout: 3000 }); } catch { /* best effort */ }
+ }
+ } else {
+ // Unix: pgrep + ps
+ try {
+ const output = execSync(
+ `ps aux | grep -E 'chrome.*\\.claude.browser.*--remote-debugging-' | grep -v grep`,
+ { encoding: 'utf-8', timeout: 5000 }
+ );
+ for (const line of output.trim().split('\n')) {
+ if (!line) continue;
+ const parts = line.trim().split(/\s+/);
+ if (parts[1]) pids.push(parseInt(parts[1], 10));
+ }
+ } catch { /* no matches */ }
+ for (const pid of pids) {
+ console.log(`[BrowserManager] Killing orphan Chrome PID ${pid}`);
+ try { process.kill(pid, 'SIGKILL'); } catch { /* best effort */ }
+ }
+ }
+
+ if (pids.length > 0) {
+ console.log(`[BrowserManager] Cleaned up ${pids.length} orphan Chrome process(es)`);
+ // Wait briefly for ports to be released
+ await new Promise(r => setTimeout(r, 500));
+ }
+ } catch {
+ // Best effort — don't block browser start
}
- throw new Error(`No available CDP port found in range ${CDP_PORT_RANGE_START}-${CDP_PORT_RANGE_END}.`);
}
/** Get auth headers for relay server (no-op for direct Chrome CDP) */
@@ -244,6 +303,24 @@ async function launchChrome(
env: { ...process.env, HOME: os.homedir() },
});
+ // Capture Chrome stderr for debugging extension loading issues
+ if (proc.stderr) {
+ let stderrBuf = '';
+ proc.stderr.on('data', (chunk: Buffer) => {
+ stderrBuf += chunk.toString();
+ // Print lines that mention extension or service worker errors
+ const lines = stderrBuf.split('\n');
+ stderrBuf = lines.pop() || '';
+ for (const line of lines) {
+ if (line && (line.includes('extension') || line.includes('Extension') ||
+ line.includes('service_worker') || line.includes('ServiceWorker') ||
+ line.includes('ERR_') || line.includes('error'))) {
+ console.log('[Chrome stderr]', line.trim());
+ }
+ }
+ });
+ }
+
const cdpUrl = `http://127.0.0.1:${cdpPort}`;
// Wait for CDP HTTP endpoint to come up
@@ -428,11 +505,23 @@ export class BrowserManager {
* Websites cannot detect automation.
*/
async start(options?: BrowserStartOptions): Promise {
- if (this._isRunning) return;
+ if (this._isRunning) {
+ // Verify extension is still connected; if not, force restart
+ if (this._relayServer && !this._relayServer.extensionConnected()) {
+ console.warn('[BrowserManager] Extension disconnected. Restarting browser...');
+ await this.stop();
+ // Fall through to full start
+ } else {
+ return;
+ }
+ }
if (this._starting) throw new Error('Browser is already starting. Please wait.');
this._starting = true;
try {
+ // Kill orphan Chrome processes from previous crashed sessions
+ await killOrphanChromes();
+
// Clean up any orphaned Chrome from a previous interrupted start
if (this.running) {
try { killProc(this.running.proc); } catch { /* best effort */ }
@@ -472,6 +561,17 @@ export class BrowserManager {
ensureCleanExit(userDataDir);
+ // --- Remove stale lockfile to prevent Chrome startup issues ---
+ const lockfile = path.join(userDataDir, 'lockfile');
+ if (fs.existsSync(lockfile)) {
+ try {
+ fs.unlinkSync(lockfile);
+ console.log('[BrowserManager] Removed stale lockfile');
+ } catch {
+ // Chrome may still be holding it; ignore
+ }
+ }
+
// --- Start relay server ---
const relayPort = cdpPort + 1;
const relayUrl = `http://127.0.0.1:${relayPort}`;
@@ -528,16 +628,56 @@ export class BrowserManager {
const extId = await loadExtensionViaPipe(chrome, extensionPath);
console.log('[BrowserManager] Extension loaded, ID:', extId);
- // --- Wait for extension to connect to relay ---
+ // --- Wait for extension service worker to start and connect to relay ---
const maxWait = 15000;
const pollInterval = 500;
let waited = 0;
+ let reloadAttempted = false;
+
while (waited < maxWait) {
if (this._relayServer.extensionConnected()) break;
await new Promise(resolve => setTimeout(resolve, pollInterval));
waited += pollInterval;
+
+ // At 5s mark: check if service worker is running; if not, reload extension
+ if (waited >= 5000 && !reloadAttempted) {
+ reloadAttempted = true;
+ try {
+ const targets = await sendCdpViaPipe(chrome, 'Target.getTargets') as {
+ targetInfos: Array<{ targetId: string; type: string; url: string }>;
+ };
+ const swTargets = targets.targetInfos?.filter(
+ (t: any) => t.type === 'service_worker' && t.url?.includes(extId),
+ ) || [];
+
+ if (swTargets.length === 0) {
+ console.warn('[BrowserManager] No extension SW found after 5s, reloading extension...');
+ try {
+ await loadExtensionViaPipe(chrome, extensionPath);
+ console.log('[BrowserManager] Extension reloaded');
+ } catch (reloadErr: any) {
+ console.warn('[BrowserManager] Extension reload failed:', reloadErr.message);
+ }
+ } else {
+ console.log(`[BrowserManager] SW running but not connected to relay yet (url: ${swTargets[0].url})`);
+ }
+ } catch { /* ignore diagnostic errors */ }
+ }
+
+ if (waited % 5000 === 0 && waited > 0) {
+ console.log(`[BrowserManager] Waiting for extension connection... ${waited / 1000}s`);
+ }
}
+
if (!this._relayServer.extensionConnected()) {
+ // Final diagnostic
+ try {
+ const targets = await sendCdpViaPipe(chrome, 'Target.getTargets') as {
+ targetInfos: Array<{ type: string; url: string }>;
+ };
+ const types = targets.targetInfos?.map((t: any) => `${t.type}:${t.url?.substring(0, 60)}`).join(' | ') || 'none';
+ console.error(`[BrowserManager] Extension connection timeout. Chrome targets: ${types}`);
+ } catch { /* ignore */ }
throw new Error(`Extension did not connect to relay within ${maxWait / 1000}s. Extension ID: ${extId}`);
}
console.log(`[BrowserManager] Extension connected to relay after ${waited}ms`);
@@ -589,22 +729,10 @@ export class BrowserManager {
this._relayServer = null;
}
- // Restore default config in background.js (pipe mode injects real values into dist/)
- try {
- const extSourceDir = this.getExtensionSourcePath();
- const bgPath = path.join(extSourceDir, 'background.js');
- if (fs.existsSync(bgPath)) {
- let bgContent = fs.readFileSync(bgPath, 'utf-8');
- bgContent = bgContent.replace(
- /\/\/ __RELAY_CONFIG_START__[\s\S]*?\/\/ __RELAY_CONFIG_END__/,
- `// __RELAY_CONFIG_START__ (do not edit - replaced by installExtension)\n` +
- `const INJECTED_RELAY_PORT = 0;\n` +
- `const INJECTED_GATEWAY_TOKEN = '';\n` +
- `// __RELAY_CONFIG_END__`
- );
- fs.writeFileSync(bgPath, bgContent);
- }
- } catch { /* ignore */ }
+ // NOTE: We intentionally do NOT reset background.js to port=0 on stop.
+ // Chrome may cache the service worker, so leaving the last valid config
+ // prevents a stale SW from running with port=0. start() always re-injects
+ // the correct config before launching Chrome.
this.cleanup();
}
diff --git a/src/browser/types.ts b/src/browser/types.ts
index 7e6519b1..67ff5402 100644
--- a/src/browser/types.ts
+++ b/src/browser/types.ts
@@ -17,6 +17,10 @@ export interface RefEntry {
role: string;
name: string;
nth: number;
+ /** Index into page.frames() — 0 = main frame, >0 = iframe */
+ frameIndex?: number;
+ /** CSS selector for non-standard clickable elements (optional) */
+ selector?: string;
}
export interface SnapshotResult {
@@ -41,6 +45,19 @@ export interface CookieOptions {
expires?: number;
}
+export interface DownloadInfo {
+ suggestedFilename: string;
+ url: string;
+ savedPath?: string;
+}
+
+export interface DialogInfo {
+ type: string; // 'alert' | 'confirm' | 'prompt' | 'beforeunload'
+ message: string;
+ handled: boolean;
+ response?: string;
+}
+
export type BrowserAction =
| 'start'
| 'stop'
@@ -70,7 +87,32 @@ export type BrowserAction =
| 'profile_list'
| 'profile_create'
| 'profile_delete'
- | 'upload_file';
+ | 'upload_file'
+ | 'scroll'
+ | 'dblclick'
+ | 'rightclick'
+ | 'drag'
+ | 'wait_for_selector'
+ | 'wait_for_url'
+ | 'wait_for_load_state'
+ | 'wait_for_timeout'
+ | 'dialog_handle'
+ | 'download_start'
+ | 'download_list'
+ | 'mouse_move'
+ | 'mouse_down'
+ | 'mouse_up'
+ | 'mouse_wheel'
+ | 'set_viewport'
+ | 'network_intercept'
+ | 'network_abort'
+ | 'storage_get'
+ | 'storage_set'
+ | 'storage_clear'
+ | 'pdf'
+ | 'frame_list'
+ | 'frame_select'
+ | 'wait_for_stable';
export interface BrowserToolInput {
action: BrowserAction;
@@ -87,4 +129,28 @@ export interface BrowserToolInput {
interactive?: boolean;
profileName?: string;
filePath?: string;
+ selector?: string;
+ timeout?: number;
+ deltaX?: number;
+ deltaY?: number;
+ x?: number;
+ y?: number;
+ button?: 'left' | 'middle' | 'right';
+ sourceRef?: string;
+ targetRef?: string;
+ dialogAction?: 'accept' | 'dismiss';
+ dialogText?: string;
+ width?: number;
+ height?: number;
+ loadState?: 'load' | 'domcontentloaded' | 'networkidle';
+ storageType?: 'local' | 'session';
+ storageKey?: string;
+ storageValue?: string;
+ routePattern?: string;
+ routeAction?: 'block' | 'continue' | 'fulfill';
+ routeBody?: string;
+ routeStatus?: number;
+ savePath?: string;
+ frameIndex?: number;
+ stableMs?: number;
}
diff --git a/src/cli.ts b/src/cli.ts
index f1ace78b..363f878e 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1010,6 +1010,7 @@ async function runTextInterface(
detached: true,
stdio: ['ignore', logFd, logFd],
cwd: process.cwd(),
+ windowsHide: true,
});
daemonProcess.unref();
daemonFs.closeSync(logFd);
@@ -3570,6 +3571,13 @@ async function main(): Promise {
return;
}
+ // Email MCP 服务器路径 - 用于邮件 IMAP/SMTP 操作
+ if (args[0] === '--email-mcp') {
+ const { main: runEmailMcp } = await import('./email-mcp/mcp-server.js');
+ await runEmailMcp();
+ return;
+ }
+
// Chrome Native Host 路径 - 用于 Chrome 扩展与 Native Host 通信
if (args[0] === '--chrome-native-host') {
await emitLifecycleEvent('cli_chrome_native_host_path');
diff --git a/src/core/client.ts b/src/core/client.ts
index a9804933..527607b7 100644
--- a/src/core/client.ts
+++ b/src/core/client.ts
@@ -745,8 +745,7 @@ export class ClaudeClient {
const apiKey = authToken ? null : (config.apiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY);
if (!apiKey && !authToken) {
- console.error('[ClaudeClient] ERROR: No API key found!');
- console.error('[ClaudeClient] Please set ANTHROPIC_API_KEY environment variable or provide apiKey in config');
+ console.warn('[ClaudeClient] No API key or auth token configured. API calls will fail until authentication is set up.');
}
// 构建默认 headers(与官方 Claude Code 完全一致)
diff --git a/src/core/loop.ts b/src/core/loop.ts
index 37695ede..a8e9ca81 100644
--- a/src/core/loop.ts
+++ b/src/core/loop.ts
@@ -620,89 +620,80 @@ export function validateToolResults(messages: Message[]): Message[] {
}
// 3. 找出孤立的 tool_use(有 tool_use 但没有对应的 tool_result)
- const orphanedToolUseIds: string[] = [];
+ const orphanedIds = new Set();
for (const id of toolUseIds) {
if (!toolResultIds.has(id)) {
- orphanedToolUseIds.push(id);
+ orphanedIds.add(id);
}
}
// 如果没有孤立的 tool_use,直接返回
- if (orphanedToolUseIds.length === 0) {
+ if (orphanedIds.size === 0) {
return result;
}
- // 4. 创建 error tool_result 块
- const errorToolResults: Array<{ type: 'tool_result'; tool_use_id: string; content: string; is_error: boolean }> = [];
- for (const id of orphanedToolUseIds) {
- const toolName = toolUseNames.get(id) || 'unknown';
- errorToolResults.push({
- type: 'tool_result',
- tool_use_id: id,
- content: `Error: ${ORPHANED_TOOL_ERROR_MESSAGE} (Tool: ${toolName})`,
- is_error: true,
- });
- }
-
- // 5. 将 error tool_result 追加到消息列表
- // 策略:找到最后一条 assistant 消息之后的 user 消息,如果没有则创建一个新的
+ // 4. 逐个 assistant 消息就地修复:为其中的孤立 tool_use 在紧接着的 user 消息中补上 error tool_result
+ // API 要求每个 tool_use block 的 tool_result 必须在"紧接的下一条 user 消息"中,
+ // 不能统一放到消息列表末尾。
+ let fixedCount = 0;
- // 从后往前找最后一个 assistant 消息的索引
- let lastAssistantIndex = -1;
+ // 从后往前遍历,因为 splice 插入会改变后续索引,从后往前不影响前面的索引
for (let i = result.length - 1; i >= 0; i--) {
- if (result[i].role === 'assistant') {
- lastAssistantIndex = i;
- break;
+ const msg = result[i];
+ if (msg.role !== 'assistant' || !Array.isArray(msg.content)) {
+ continue;
}
- }
- if (lastAssistantIndex === -1) {
- // 没有 assistant 消息,这不应该发生,但为了安全起见
- return result;
- }
-
- // 检查最后一个 assistant 消息之后是否有 user 消息包含 tool_result
- let targetUserMsgIndex = -1;
- for (let i = lastAssistantIndex + 1; i < result.length; i++) {
- const msg = result[i];
- if (msg.role === 'user' && Array.isArray(msg.content)) {
- // 检查是否包含 tool_result
- const hasToolResult = msg.content.some(
- (block) =>
- typeof block === 'object' &&
- 'type' in block &&
- block.type === 'tool_result'
- );
- if (hasToolResult) {
- targetUserMsgIndex = i;
- break;
+ // 收集此 assistant 消息中的孤立 tool_use ids
+ const orphansInThisMsg: string[] = [];
+ for (const block of msg.content) {
+ if (
+ typeof block === 'object' &&
+ 'type' in block &&
+ block.type === 'tool_use' &&
+ 'id' in block &&
+ typeof block.id === 'string' &&
+ orphanedIds.has(block.id)
+ ) {
+ orphansInThisMsg.push(block.id);
}
}
- }
- if (targetUserMsgIndex !== -1) {
- // 追加到现有的 user 消息中
- const existingUserMsg = result[targetUserMsgIndex];
- if (Array.isArray(existingUserMsg.content)) {
- result[targetUserMsgIndex] = {
- ...existingUserMsg,
- content: [...existingUserMsg.content, ...errorToolResults],
+ if (orphansInThisMsg.length === 0) {
+ continue;
+ }
+
+ // 创建 error tool_result 块
+ const errorResults = orphansInThisMsg.map(id => ({
+ type: 'tool_result' as const,
+ tool_use_id: id,
+ content: `Error: ${ORPHANED_TOOL_ERROR_MESSAGE} (Tool: ${toolUseNames.get(id) || 'unknown'})`,
+ is_error: true,
+ }));
+
+ // 检查紧接着的下一条消息是否是 user 消息
+ const nextIndex = i + 1;
+ if (nextIndex < result.length && result[nextIndex].role === 'user' && Array.isArray(result[nextIndex].content)) {
+ // 追加到现有 user 消息中
+ result[nextIndex] = {
+ ...result[nextIndex],
+ content: [...(result[nextIndex].content as any[]), ...errorResults],
};
+ } else {
+ // 在 assistant 消息之后插入新的 user 消息
+ result.splice(nextIndex, 0, {
+ role: 'user',
+ content: errorResults,
+ } as Message);
}
- } else {
- // 在最后一个 assistant 消息之后创建新的 user 消息
- const newUserMsg: Message = {
- role: 'user',
- content: errorToolResults,
- };
- // 插入到 lastAssistantIndex + 1 位置
- result.splice(lastAssistantIndex + 1, 0, newUserMsg);
+
+ fixedCount += orphansInThisMsg.length;
}
// 输出调试信息
- if (orphanedToolUseIds.length > 0) {
- console.log(chalk.yellow(`[validateToolResults] Fixed ${orphanedToolUseIds.length} orphaned tool_use(s):`));
- for (const id of orphanedToolUseIds) {
+ if (fixedCount > 0) {
+ console.log(chalk.yellow(`[validateToolResults] Fixed ${fixedCount} orphaned tool_use(s):`));
+ for (const id of orphanedIds) {
const toolName = toolUseNames.get(id) || 'unknown';
console.log(chalk.yellow(` - ${toolName} (${id})`));
}
diff --git a/src/email-mcp/index.ts b/src/email-mcp/index.ts
new file mode 100644
index 00000000..d97d44a1
--- /dev/null
+++ b/src/email-mcp/index.ts
@@ -0,0 +1,12 @@
+/**
+ * Email MCP 模块入口
+ *
+ * 内置的邮件 MCP server,通过 settings.json 的 mcpServers 配置启用。
+ * 作为标准 stdio MCP server 工作,无需特殊集成代码。
+ *
+ * 启用方式:在 settings.json 的 mcpServers 中添加 email 配置
+ * 入口点:cli.ts --email-mcp → mcp-server.ts main()
+ */
+
+export { EMAIL_MCP_TOOLS, getEmailToolNames } from './tools.js';
+export type { McpTool } from './tools.js';
diff --git a/src/email-mcp/mcp-server.ts b/src/email-mcp/mcp-server.ts
new file mode 100644
index 00000000..e9c47abc
--- /dev/null
+++ b/src/email-mcp/mcp-server.ts
@@ -0,0 +1,427 @@
+/**
+ * Email MCP Server - IMAP/SMTP 邮件操作
+ *
+ * 基于 jdickey1/imap-email-mcp (MIT) 改写为 TypeScript
+ * 使用与 chrome-mcp 相同的 JSON-RPC stdio 传输模式
+ *
+ * 环境变量配置:
+ * IMAP_USER - 邮箱账号 (必需)
+ * IMAP_PASSWORD - 邮箱密码/应用密码 (必需)
+ * IMAP_HOST - IMAP 服务器地址 (必需)
+ * IMAP_PORT - IMAP 端口 (默认 993)
+ * SMTP_HOST - SMTP 服务器地址 (默认与 IMAP_HOST 相同)
+ * SMTP_PORT - SMTP 端口 (默认 465)
+ * SMTP_USER - SMTP 用户名 (默认与 IMAP_USER 相同)
+ * SMTP_PASSWORD - SMTP 密码 (默认与 IMAP_PASSWORD 相同)
+ */
+
+import imaps from 'imap-simple';
+import { simpleParser } from 'mailparser';
+import nodemailer from 'nodemailer';
+
+import { EMAIL_MCP_TOOLS } from './tools.js';
+
+// ============ 类型 ============
+
+interface McpToolResult {
+ content: Array<{ type: string; text?: string }>;
+ isError?: boolean;
+}
+
+interface ImapBoxes {
+ [key: string]: {
+ children?: ImapBoxes;
+ [key: string]: unknown;
+ };
+}
+
+// ============ 配置 ============
+
+const IMAP_CONFIG = {
+ imap: {
+ user: process.env.IMAP_USER || '',
+ password: process.env.IMAP_PASSWORD || '',
+ host: process.env.IMAP_HOST || '',
+ port: parseInt(process.env.IMAP_PORT || '993', 10),
+ tls: process.env.IMAP_TLS !== 'false',
+ authTimeout: parseInt(process.env.IMAP_AUTH_TIMEOUT || '10000', 10),
+ tlsOptions: {
+ rejectUnauthorized: process.env.IMAP_TLS_REJECT_UNAUTHORIZED !== 'false',
+ },
+ },
+};
+
+const SMTP_CONFIG = {
+ host: process.env.SMTP_HOST || process.env.IMAP_HOST || '',
+ port: parseInt(process.env.SMTP_PORT || '465', 10),
+ secure: process.env.SMTP_SECURE !== 'false',
+ auth: {
+ user: process.env.SMTP_USER || process.env.IMAP_USER || '',
+ pass: process.env.SMTP_PASSWORD || process.env.IMAP_PASSWORD || '',
+ },
+};
+
+// ============ 验证 ============
+
+function validateConfig(): void {
+ const required = ['IMAP_USER', 'IMAP_PASSWORD', 'IMAP_HOST'];
+ const missing = required.filter((key) => !process.env[key]);
+
+ if (missing.length > 0) {
+ console.error(`Missing required environment variables: ${missing.join(', ')}`);
+ console.error('Please set these variables before starting the server.');
+ process.exit(1);
+ }
+}
+
+// ============ IMAP 辅助 ============
+
+async function connectIMAP(): Promise {
+ if (!IMAP_CONFIG.imap.password) {
+ throw new Error('IMAP_PASSWORD environment variable is not set');
+ }
+ return await imaps.connect(IMAP_CONFIG);
+}
+
+async function findDraftsFolder(connection: imaps.ImapSimple): Promise {
+ const boxes = (await connection.getBoxes()) as ImapBoxes;
+
+ const draftNames = [
+ 'Drafts', 'INBOX.Drafts', '[Gmail]/Drafts',
+ '[Google Mail]/Drafts', 'Draft', 'INBOX/Drafts',
+ ];
+
+ for (const name of draftNames) {
+ if (boxes[name] || name.split('.').reduce((acc, part) => acc?.[part], boxes)) {
+ return name;
+ }
+ }
+
+ if (boxes.INBOX && (boxes.INBOX as any).children && (boxes.INBOX as any).children.Drafts) {
+ return 'INBOX.Drafts';
+ }
+
+ return 'Drafts';
+}
+
+function buildRfc2822Message(opts: {
+ from: string; to: string; subject: string;
+ body?: string; html?: string; cc?: string; bcc?: string;
+}): string {
+ const boundary = `----=_Part_${Date.now()}`;
+ let msg = '';
+ msg += `From: ${opts.from}\r\n`;
+ msg += `To: ${opts.to}\r\n`;
+ if (opts.cc) msg += `Cc: ${opts.cc}\r\n`;
+ if (opts.bcc) msg += `Bcc: ${opts.bcc}\r\n`;
+ msg += `Subject: ${opts.subject}\r\n`;
+ msg += `Date: ${new Date().toUTCString()}\r\n`;
+ msg += `MIME-Version: 1.0\r\n`;
+
+ if (opts.html) {
+ msg += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`;
+ msg += `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${opts.body || ''}\r\n`;
+ msg += `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${opts.html}\r\n`;
+ msg += `--${boundary}--\r\n`;
+ } else {
+ msg += `Content-Type: text/plain; charset=utf-8\r\n\r\n${opts.body || ''}\r\n`;
+ }
+ return msg;
+}
+
+// ============ 工具执行 ============
+
+async function executeToolCall(name: string, args: Record): Promise {
+ try {
+ switch (name) {
+ case 'list_folders': {
+ const connection = await connectIMAP();
+ try {
+ const boxes = (await connection.getBoxes()) as ImapBoxes;
+ const folders: string[] = [];
+ function extract(obj: ImapBoxes, prefix = ''): void {
+ for (const [key, value] of Object.entries(obj)) {
+ const fullPath = prefix ? `${prefix}.${key}` : key;
+ folders.push(fullPath);
+ if (value.children) extract(value.children, fullPath);
+ }
+ }
+ extract(boxes);
+ return { content: [{ type: 'text', text: JSON.stringify(folders, null, 2) }] };
+ } finally {
+ connection.end();
+ }
+ }
+
+ case 'list_emails': {
+ const folder = args.folder || 'INBOX';
+ const limit = args.limit || 20;
+ const connection = await connectIMAP();
+ try {
+ await connection.openBox(folder);
+ let searchCriteria: any[] = ['ALL'];
+ if (args.unseen_only) searchCriteria = ['UNSEEN'];
+ if (args.since_date) searchCriteria = [['SINCE', args.since_date]];
+
+ const messages = await connection.search(searchCriteria, {
+ bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'], struct: true,
+ });
+ const results = messages.slice(-limit).reverse().map((m) => {
+ const h = (m.parts.find((p: any) => p.which.includes('HEADER')) as any)?.body || {};
+ return { uid: m.attributes.uid, date: h.date?.[0], from: h.from?.[0], to: h.to?.[0], subject: h.subject?.[0], flags: m.attributes.flags };
+ });
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
+ } finally {
+ connection.end();
+ }
+ }
+
+ case 'get_email': {
+ const folder = args.folder || 'INBOX';
+ const connection = await connectIMAP();
+ try {
+ await connection.openBox(folder);
+ const messages = await connection.search([['UID', args.uid]], { bodies: [''], struct: true });
+ if (messages.length === 0) return { content: [{ type: 'text', text: 'Email not found' }] };
+
+ const raw = (messages[0].parts.find((p: any) => p.which === '') as any)?.body;
+ const parsed = await simpleParser(raw);
+ const toText = Array.isArray(parsed.to) ? parsed.to.map((a: any) => a.text).join(', ') : parsed.to?.text;
+ const ccText = Array.isArray(parsed.cc) ? parsed.cc.map((a: any) => a.text).join(', ') : parsed.cc?.text;
+
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({
+ uid: messages[0].attributes.uid, from: parsed.from?.text, to: toText, cc: ccText,
+ subject: parsed.subject, date: parsed.date, text: parsed.text,
+ html: parsed.html ? '[HTML content available]' : undefined,
+ attachments: parsed.attachments?.map((a) => ({ filename: a.filename, contentType: a.contentType, size: a.size })),
+ }, null, 2),
+ }],
+ };
+ } finally {
+ connection.end();
+ }
+ }
+
+ case 'search_emails': {
+ const folder = args.folder || 'INBOX';
+ const limit = args.limit || 20;
+ const connection = await connectIMAP();
+ try {
+ await connection.openBox(folder);
+ const criteria: any[] = [];
+ if (args.subject) criteria.push(['SUBJECT', args.subject]);
+ if (args.from) criteria.push(['FROM', args.from]);
+ if (args.body) criteria.push(['BODY', args.body]);
+ if (criteria.length === 0) criteria.push('ALL');
+
+ const messages = await connection.search(criteria, {
+ bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'], struct: true,
+ });
+ const results = messages.slice(-limit).reverse().map((m) => {
+ const h = (m.parts.find((p: any) => p.which.includes('HEADER')) as any)?.body || {};
+ return { uid: m.attributes.uid, date: h.date?.[0], from: h.from?.[0], to: h.to?.[0], subject: h.subject?.[0] };
+ });
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
+ } finally {
+ connection.end();
+ }
+ }
+
+ case 'send_email': {
+ if (!SMTP_CONFIG.host) {
+ return { content: [{ type: 'text', text: 'Error: SMTP_HOST not configured.' }], isError: true };
+ }
+ const transporter = nodemailer.createTransport(SMTP_CONFIG);
+ const info = await transporter.sendMail({
+ from: SMTP_CONFIG.auth.user, to: args.to, subject: args.subject,
+ text: args.body, html: args.html, cc: args.cc, bcc: args.bcc,
+ });
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, messageId: info.messageId, response: info.response }, null, 2) }] };
+ }
+
+ case 'create_draft': {
+ const connection = await connectIMAP();
+ try {
+ const draftsFolder = await findDraftsFolder(connection);
+ const message = buildRfc2822Message({ from: IMAP_CONFIG.imap.user, to: args.to, subject: args.subject, body: args.body, html: args.html, cc: args.cc, bcc: args.bcc });
+ await connection.append(message, { mailbox: draftsFolder, flags: ['\\Draft'] });
+ return { content: [{ type: 'text', text: `Draft created in ${draftsFolder}` }] };
+ } finally {
+ connection.end();
+ }
+ }
+
+ case 'get_draft': {
+ const connection = await connectIMAP();
+ try {
+ const draftsFolder = await findDraftsFolder(connection);
+ await connection.openBox(draftsFolder);
+ const messages = await connection.search([['UID', args.uid]], { bodies: [''], struct: true });
+ if (messages.length === 0) return { content: [{ type: 'text', text: 'Draft not found' }] };
+
+ const raw = (messages[0].parts.find((p: any) => p.which === '') as any)?.body;
+ const parsed = await simpleParser(raw);
+ const toText = Array.isArray(parsed.to) ? parsed.to.map((a: any) => a.text).join(', ') : parsed.to?.text;
+ const ccText = Array.isArray(parsed.cc) ? parsed.cc.map((a: any) => a.text).join(', ') : parsed.cc?.text;
+ const bccText = Array.isArray(parsed.bcc) ? parsed.bcc.map((a: any) => a.text).join(', ') : parsed.bcc?.text;
+
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({
+ uid: messages[0].attributes.uid, to: toText, cc: ccText, bcc: bccText,
+ subject: parsed.subject, date: parsed.date, text: parsed.text,
+ html: parsed.html ? '[HTML content available]' : undefined,
+ }, null, 2),
+ }],
+ };
+ } finally {
+ connection.end();
+ }
+ }
+
+ case 'update_draft': {
+ const connection = await connectIMAP();
+ try {
+ const draftsFolder = await findDraftsFolder(connection);
+ await connection.openBox(draftsFolder);
+ await connection.addFlags(args.uid, ['\\Deleted']);
+ await connection.closeBox(true);
+
+ const message = buildRfc2822Message({ from: IMAP_CONFIG.imap.user, to: args.to, subject: args.subject, body: args.body, html: args.html, cc: args.cc, bcc: args.bcc });
+ await connection.append(message, { mailbox: draftsFolder, flags: ['\\Draft'] });
+ return { content: [{ type: 'text', text: 'Draft updated successfully' }] };
+ } finally {
+ connection.end();
+ }
+ }
+
+ case 'list_drafts': {
+ const limit = args.limit || 20;
+ const connection = await connectIMAP();
+ try {
+ const draftsFolder = await findDraftsFolder(connection);
+ await connection.openBox(draftsFolder);
+ const messages = await connection.search(['ALL'], {
+ bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'], struct: true,
+ });
+ const results = messages.slice(-limit).reverse().map((m) => {
+ const h = (m.parts.find((p: any) => p.which.includes('HEADER')) as any)?.body || {};
+ return { uid: m.attributes.uid, date: h.date?.[0], to: h.to?.[0], subject: h.subject?.[0] };
+ });
+ return { content: [{ type: 'text', text: JSON.stringify({ folder: draftsFolder, drafts: results }, null, 2) }] };
+ } finally {
+ connection.end();
+ }
+ }
+
+ case 'delete_email': {
+ const folder = args.folder || 'INBOX';
+ const connection = await connectIMAP();
+ try {
+ await connection.openBox(folder);
+ await connection.addFlags(args.uid, ['\\Deleted']);
+ await connection.closeBox(true);
+ return { content: [{ type: 'text', text: 'Email deleted successfully' }] };
+ } finally {
+ connection.end();
+ }
+ }
+
+ default:
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
+ }
+ } catch (error: any) {
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
+ }
+}
+
+// ============ JSON-RPC stdio MCP Server ============
+
+function sendResponse(id: string | number, result: unknown): void {
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
+}
+
+function sendError(id: string | number, code: number, message: string): void {
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
+}
+
+async function handleMessage(message: string): Promise {
+ try {
+ const request = JSON.parse(message);
+ const { id, method, params } = request;
+
+ switch (method) {
+ case 'initialize':
+ sendResponse(id, {
+ protocolVersion: '2024-11-05',
+ capabilities: { tools: {} },
+ serverInfo: { name: 'email-mcp', version: '1.0.0' },
+ });
+ break;
+
+ case 'tools/list':
+ sendResponse(id, { tools: EMAIL_MCP_TOOLS });
+ break;
+
+ case 'tools/call': {
+ const { name, arguments: args = {} } = params;
+ const result = await executeToolCall(name, args);
+ sendResponse(id, result);
+ break;
+ }
+
+ case 'notifications/initialized':
+ // 客户端确认初始化,无需响应
+ break;
+
+ default:
+ if (id !== undefined) {
+ sendError(id, -32601, `Method not found: ${method}`);
+ }
+ }
+ } catch (err: any) {
+ console.error('Failed to handle message:', err);
+ }
+}
+
+export async function main(): Promise {
+ validateConfig();
+
+ let buffer = '';
+ process.stdin.setEncoding('utf8');
+
+ process.stdin.on('data', (chunk: string) => {
+ buffer += chunk;
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+ for (const line of lines) {
+ if (line.trim()) handleMessage(line.trim());
+ }
+ });
+
+ process.stdin.on('end', () => process.exit(0));
+
+ process.on('SIGINT', () => process.exit(0));
+ process.on('SIGTERM', () => process.exit(0));
+
+ console.error('Email MCP Server running on stdio');
+
+ // 保持进程运行
+ await new Promise((resolve) => {
+ process.stdin.on('end', resolve);
+ process.stdin.on('close', resolve);
+ });
+}
+
+// 当直接运行此文件时自动启动
+const isDirectRun = process.argv[1] &&
+ (process.argv[1].endsWith('email-mcp/mcp-server.js') ||
+ process.argv[1].endsWith('email-mcp\\mcp-server.js'));
+
+if (isDirectRun) {
+ main().catch(console.error);
+}
diff --git a/src/email-mcp/tools.ts b/src/email-mcp/tools.ts
new file mode 100644
index 00000000..5710885a
--- /dev/null
+++ b/src/email-mcp/tools.ts
@@ -0,0 +1,262 @@
+/**
+ * Email MCP 工具定义
+ * IMAP/SMTP 邮件操作的 10 个工具
+ */
+
+export interface McpTool {
+ name: string;
+ description: string;
+ inputSchema: {
+ type: 'object';
+ properties: Record;
+ required?: string[];
+ };
+}
+
+/**
+ * 所有 Email MCP 工具定义
+ */
+export const EMAIL_MCP_TOOLS: McpTool[] = [
+ {
+ name: 'list_folders',
+ description: 'List all email folders/mailboxes in the IMAP account',
+ inputSchema: {
+ type: 'object',
+ properties: {},
+ required: [],
+ },
+ },
+ {
+ name: 'list_emails',
+ description: 'List emails from a folder with optional filtering',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ folder: {
+ type: 'string',
+ description: 'Folder name (default: INBOX)',
+ },
+ limit: {
+ type: 'number',
+ description: 'Maximum number of emails to return (default: 20)',
+ },
+ unseen_only: {
+ type: 'boolean',
+ description: 'Only return unread emails',
+ },
+ since_date: {
+ type: 'string',
+ description: 'Only return emails since this date (YYYY-MM-DD format)',
+ },
+ },
+ required: [],
+ },
+ },
+ {
+ name: 'get_email',
+ description: 'Get full email content by UID',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ uid: {
+ type: 'number',
+ description: 'Email UID',
+ },
+ folder: {
+ type: 'string',
+ description: 'Folder name (default: INBOX)',
+ },
+ },
+ required: ['uid'],
+ },
+ },
+ {
+ name: 'search_emails',
+ description: 'Search emails by subject, from, or body text',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ folder: {
+ type: 'string',
+ description: 'Folder to search (default: INBOX)',
+ },
+ subject: {
+ type: 'string',
+ description: 'Search in subject line',
+ },
+ from: {
+ type: 'string',
+ description: 'Search by sender',
+ },
+ body: {
+ type: 'string',
+ description: 'Search in body text',
+ },
+ limit: {
+ type: 'number',
+ description: 'Maximum results (default: 20)',
+ },
+ },
+ required: [],
+ },
+ },
+ {
+ name: 'send_email',
+ description: 'Send an email directly via SMTP',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ to: {
+ type: 'string',
+ description: 'Recipient email address(es), comma-separated',
+ },
+ subject: {
+ type: 'string',
+ description: 'Email subject',
+ },
+ body: {
+ type: 'string',
+ description: 'Email body (plain text)',
+ },
+ html: {
+ type: 'string',
+ description: 'Email body (HTML)',
+ },
+ cc: {
+ type: 'string',
+ description: 'CC recipients, comma-separated',
+ },
+ bcc: {
+ type: 'string',
+ description: 'BCC recipients, comma-separated',
+ },
+ },
+ required: ['to', 'subject'],
+ },
+ },
+ {
+ name: 'create_draft',
+ description: 'Create a new draft email',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ to: {
+ type: 'string',
+ description: 'Recipient email address(es), comma-separated',
+ },
+ subject: {
+ type: 'string',
+ description: 'Email subject',
+ },
+ body: {
+ type: 'string',
+ description: 'Email body (plain text)',
+ },
+ html: {
+ type: 'string',
+ description: 'Email body (HTML)',
+ },
+ cc: {
+ type: 'string',
+ description: 'CC recipients, comma-separated',
+ },
+ bcc: {
+ type: 'string',
+ description: 'BCC recipients, comma-separated',
+ },
+ },
+ required: ['to', 'subject'],
+ },
+ },
+ {
+ name: 'get_draft',
+ description: 'Get a specific draft email by UID',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ uid: {
+ type: 'number',
+ description: 'Draft UID',
+ },
+ },
+ required: ['uid'],
+ },
+ },
+ {
+ name: 'update_draft',
+ description: 'Update an existing draft by deleting old and creating new',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ uid: {
+ type: 'number',
+ description: 'UID of draft to update',
+ },
+ to: {
+ type: 'string',
+ description: 'Recipient email address(es)',
+ },
+ subject: {
+ type: 'string',
+ description: 'Email subject',
+ },
+ body: {
+ type: 'string',
+ description: 'Email body (plain text)',
+ },
+ html: {
+ type: 'string',
+ description: 'Email body (HTML)',
+ },
+ cc: {
+ type: 'string',
+ description: 'CC recipients',
+ },
+ bcc: {
+ type: 'string',
+ description: 'BCC recipients',
+ },
+ },
+ required: ['uid', 'to', 'subject'],
+ },
+ },
+ {
+ name: 'list_drafts',
+ description: 'List all draft emails',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ limit: {
+ type: 'number',
+ description: 'Maximum number of drafts to return (default: 20)',
+ },
+ },
+ required: [],
+ },
+ },
+ {
+ name: 'delete_email',
+ description: 'Delete an email by UID',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ uid: {
+ type: 'number',
+ description: 'Email UID to delete',
+ },
+ folder: {
+ type: 'string',
+ description: 'Folder name (default: INBOX)',
+ },
+ },
+ required: ['uid'],
+ },
+ },
+];
+
+/**
+ * 获取带前缀的工具名列表
+ */
+export function getEmailToolNames(serverName = 'email'): string[] {
+ return EMAIL_MCP_TOOLS.map((t) => `mcp__${serverName}__${t.name}`);
+}
diff --git a/src/mcp/lifecycle.ts b/src/mcp/lifecycle.ts
index 7783f698..1203ae1f 100644
--- a/src/mcp/lifecycle.ts
+++ b/src/mcp/lifecycle.ts
@@ -224,6 +224,7 @@ export class McpLifecycleManager extends EventEmitter {
const childProcess = spawn(config.command, config.args || [], {
env: { ...(globalThis.process?.env || {}), ...config.env },
stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
});
this.childProcesses.set(serverName, childProcess);
diff --git a/src/prompt/attachments.ts b/src/prompt/attachments.ts
index d5241d17..8805e334 100644
--- a/src/prompt/attachments.ts
+++ b/src/prompt/attachments.ts
@@ -432,10 +432,9 @@ export class AttachmentManager {
You are running as a team lead in delegate mode. Your role is to:
1. Create and manage tasks using TaskCreate, TaskGet, TaskUpdate, TaskList
2. Spawn teammate agents using the Task tool
-3. Communicate with teammates using SendMessage
-4. Coordinate work and track progress
+3. Coordinate work and track progress
-You have access to team management tools (TeamCreate, TeamDelete, SendMessage) and task tools (TaskCreate, TaskGet, TaskUpdate, TaskList).
+You have access to task tools (TaskCreate, TaskGet, TaskUpdate, TaskList).
Do not directly edit files - delegate work to teammate agents.
`,
label: 'Delegate Mode',
diff --git a/src/prompt/builder.ts b/src/prompt/builder.ts
index bf4f9737..188233d3 100644
--- a/src/prompt/builder.ts
+++ b/src/prompt/builder.ts
@@ -17,6 +17,7 @@ import {
SECURITY_RULES,
EXECUTING_WITH_CARE,
PROACTIVE_SKILL_CREATION,
+ PROACTIVE_TOOL_DISCOVERY,
getCodingGuidelines,
getToolGuidelines,
getToneAndStyle,
@@ -176,6 +177,9 @@ You have access to the ${askTool} tool to ask the user questions when you need c
// 9.5 主动创建 Skill 规则 - 检测重复模式和复杂工作流时提议创建 skill
staticParts.push(PROACTIVE_SKILL_CREATION);
+ // 9.6 主动工具发现规则 - 遇到不擅长的任务时自动搜索互联网上的 MCP/Skill
+ staticParts.push(PROACTIVE_TOOL_DISCOVERY);
+
// 10. 安全规则 (BV6)
staticParts.push(SECURITY_RULES);
diff --git a/src/prompt/index.ts b/src/prompt/index.ts
index fe31b2ab..48095213 100644
--- a/src/prompt/index.ts
+++ b/src/prompt/index.ts
@@ -12,6 +12,7 @@ export {
SECURITY_RULES,
EXECUTING_WITH_CARE,
PROACTIVE_SKILL_CREATION,
+ PROACTIVE_TOOL_DISCOVERY,
getSystemSection,
getCodingGuidelines,
getToolGuidelines,
diff --git a/src/prompt/templates.ts b/src/prompt/templates.ts
index 0b918d41..137e0ee3 100644
--- a/src/prompt/templates.ts
+++ b/src/prompt/templates.ts
@@ -96,7 +96,6 @@ export function getToolGuidelines(
hasSkillTool ? `/ (e.g., /commit) is shorthand for users to invoke a user-invocable skill. When executed, the skill gets expanded to a full prompt. Use the ${skill} tool to execute them. IMPORTANT: Only use ${skill} for skills listed in its user-invocable skills section - do not guess or use built-in CLI commands.` : null,
'You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead.',
toolNames.has('Database') ? 'Use the Database tool to directly query databases (postgres/mysql/sqlite/redis/mongo), instead of calling mysql/psql/redis-cli via Bash. Database tool provides structured results, readonly safety mode, and connection management.' : null,
- toolNames.has('Debugger') ? 'Use the Debugger tool to set breakpoints, inspect call stacks and variable values, and step through code. Prefer Debugger over adding console.log statements for debugging.' : null,
];
return ['# Using your tools', ...items.filter(item => item !== null).flatMap(item =>
@@ -297,6 +296,61 @@ A skill is a directory containing a SKILL.md file with YAML frontmatter:
- If the user declines, respect that and do not ask again for the same pattern in the current session`;
+/**
+ * 主动工具发现规则
+ * 遇到不擅长的专业任务时,自动搜索互联网上的 MCP/Skill 而非硬写代码
+ */
+export const PROACTIVE_TOOL_DISCOVERY = `# Proactive Tool Discovery
+
+When you encounter a specialized task that you don't have a dedicated tool for, you should **automatically search for existing MCP servers and Skills** from the internet before attempting to code a solution from scratch.
+
+## Trigger Conditions
+
+Automatically search for tools when the user asks you to:
+- Generate office documents (PPT, Excel, Word, PDF) with professional quality
+- Interact with external services (Slack, Discord, email, calendar, etc.)
+- Perform specialized data processing (OCR, image generation, video generation, video editing, audio processing, etc.)
+- Generate or manipulate media (images, videos, audio, 3D models, etc.)
+- Access databases or APIs you don't have built-in tools for
+- Any task where you know a dedicated tool would produce significantly better results than code generation
+
+## CRITICAL RULE
+
+**NEVER say "I can't do X" or "I don't have this capability" without FIRST searching for available tools.** If a user asks you to do something you don't have a built-in tool for, your FIRST action must be to search Smithery for relevant skills and MCP servers. Only after searching and finding nothing suitable may you inform the user of the limitation. Skipping the search and directly claiming inability is a violation of this rule.
+
+## How to Search
+
+Use the Smithery CLI (pre-installed) to search for tools:
+
+\`\`\`bash
+# Search for skills (Claude Code compatible, preferred)
+npx @smithery/cli skill search ""
+
+# Search for MCP servers
+npx @smithery/cli search ""
+
+# View skill details before recommending
+npx @smithery/cli skill view
+\`\`\`
+
+## Decision Flow
+
+1. **Detect need**: Recognize that the task would benefit from a specialized tool
+2. **Check installed**: First check if a relevant skill/MCP is already installed
+3. **Search internet**: If not installed, search Smithery registry
+4. **Evaluate**: Prefer verified skills (anthropics/ namespace), high quality scores (>0.6), high star counts
+5. **Recommend**: Present top options to the user with install commands
+6. **Install on approval**: \`npx @smithery/cli skill add --agent claude-code --global\`
+7. **Use the tool**: After installation, use the newly available skill/MCP
+
+## Important
+
+- Do NOT silently install tools — always show the user what you found and ask before installing
+- If the user wants a quick solution and doesn't want to install tools, fall back to code generation
+- After installing a skill, the user may need to restart the session for it to take effect
+- This behavior is about being resourceful, not about avoiding work — if code is the right answer, write code`;
+
+
/**
* Scratchpad 目录说明
*/
@@ -951,6 +1005,7 @@ export const PromptTemplates = {
TASK_MANAGEMENT,
EXECUTING_WITH_CARE,
PROACTIVE_SKILL_CREATION,
+ PROACTIVE_TOOL_DISCOVERY,
PERMISSION_MODES,
// Agent 提示词
GENERAL_PURPOSE_AGENT_PROMPT,
diff --git a/src/sandbox/executor.ts b/src/sandbox/executor.ts
index 059c36bc..2d2b7abb 100644
--- a/src/sandbox/executor.ts
+++ b/src/sandbox/executor.ts
@@ -161,6 +161,7 @@ async function executeUnsandboxed(
env: safeEnv,
stdio: ['pipe', 'pipe', 'pipe'],
timeout: config.resourceLimits?.maxExecutionTime,
+ windowsHide: true,
});
let stdout = '';
@@ -225,6 +226,7 @@ async function executeWithUlimit(
stdio: ['pipe', 'pipe', 'pipe'],
timeout: config.resourceLimits?.maxExecutionTime,
shell: true,
+ windowsHide: true,
});
} else {
proc = child_process.spawn('sh', ['-c', fullCommand], {
diff --git a/src/security/skill-scanner.ts b/src/security/skill-scanner.ts
index 98aa896d..b8e9c488 100644
--- a/src/security/skill-scanner.ts
+++ b/src/security/skill-scanner.ts
@@ -68,15 +68,30 @@ const WARN_PATTERNS = [
},
];
+/**
+ * 从 markdown 内容中剥离代码块和行内代码引用
+ * 这些区域中的关键词是文档说明,不是可执行指令
+ */
+function stripMarkdownCode(content: string): string {
+ // 先移除 fenced code blocks (```...```)
+ let stripped = content.replace(/```[\s\S]*?```/g, '');
+ // 再移除 inline code (`...`)
+ stripped = stripped.replace(/`[^`]+`/g, '');
+ return stripped;
+}
+
/**
* 扫描 skill 内容,检测危险模式
*/
export function scanSkillContent(content: string): SkillScanResult {
const warnings: SkillScanWarning[] = [];
- // 检查 critical 级别的模式
+ // 剥离 markdown 代码块后再扫描,避免文档描述中的关键词被误报
+ const strippedContent = stripMarkdownCode(content);
+
+ // 检查 critical 级别的模式(只扫描非代码区域)
for (const { pattern, rule, detail } of CRITICAL_PATTERNS) {
- if (pattern.test(content)) {
+ if (pattern.test(strippedContent)) {
warnings.push({
level: 'critical',
rule,
@@ -85,7 +100,7 @@ export function scanSkillContent(content: string): SkillScanResult {
}
}
- // 检查 warn 级别的模式
+ // 检查 warn 级别的模式(扫描完整内容,因为 warn 不阻断加载)
for (const { pattern, rule, detail } of WARN_PATTERNS) {
if (rule === 'env_harvesting') {
// 特殊处理:检测是否访问了多个环境变量(>=3个)
diff --git a/src/tools/agent.ts b/src/tools/agent.ts
index 376eec83..cff61e18 100644
--- a/src/tools/agent.ts
+++ b/src/tools/agent.ts
@@ -22,7 +22,6 @@ import {
import type { Message } from '../types/index.js';
import { GENERAL_PURPOSE_AGENT_PROMPT, EXPLORE_AGENT_PROMPT, CODE_ANALYZER_PROMPT, BLUEPRINT_WORKER_PROMPT } from '../prompt/templates.js';
import { notificationManager, type AgentCompletionResult } from '../notifications/index.js';
-import { isAgentTeamsEnabled } from '../agents/teammate-context.js';
import { t } from '../i18n/index.js';
// 代理类型定义(参照官方)
@@ -994,7 +993,8 @@ Since the user is greeting, use the greeting-responder agent to respond with a f
assistant: "I'm going to use the Task tool to launch the greeting-responder agent"
-${!isAgentTeamsEnabled() ? `\nNote: The "Agent Teams" feature (TeammateTool, SendMessage, spawnTeam) is not available on this plan. Only mention this if the user explicitly asks for "agent teams" by name, or asks for agents to send messages to each other (peer-to-peer messaging). Do NOT mention this limitation when users ask for parallel subagents, coordinating agents, launching a "team" of agents, or having agents work together — those are all normal Task tool usage and you should proceed normally.` : ''}`;
+
+Note: The "Agent Teams" feature (TeammateTool, SendMessage, spawnTeam) is not available on this plan. Only mention this if the user explicitly asks for "agent teams" by name, or asks for agents to send messages to each other (peer-to-peer messaging). Do NOT mention this limitation when users ask for parallel subagents, coordinating agents, launching a "team" of agents, or having agents work together — those are all normal Task tool usage and you should proceed normally.`;
}
// 父对话上下文(用于 forkContext)
@@ -1258,11 +1258,11 @@ ${!isAgentTeamsEnabled() ? `\nNote: The "Agent Teams" feature (TeammateTool, Sen
.catch((error) => {
// 执行失败 — 记录完整堆栈到日志
agent.status = 'failed';
- const errorMsg = error instanceof Error ? error.message : String(error);
+ const errorMsg = error instanceof Error ? (error.message || error.constructor.name) : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
- agent.error = errorStack || errorMsg;
+ agent.error = errorStack || errorMsg || 'Unknown error';
agent.endTime = new Date();
- addAgentHistory(agent, 'failed', `Agent failed: ${errorMsg}${errorStack ? '\n' + errorStack : ''}`);
+ addAgentHistory(agent, 'failed', `Agent failed: ${errorMsg || 'Unknown error'}${errorStack ? '\n' + errorStack : ''}`);
saveAgentState(agent);
// v2.1.7: 发送代理失败通知
@@ -1298,17 +1298,17 @@ ${!isAgentTeamsEnabled() ? `\nNote: The "Agent Teams" feature (TeammateTool, Sen
return agent.result;
} catch (error) {
agent.status = 'failed';
- const errorMsg = error instanceof Error ? error.message : String(error);
+ const errorMsg = error instanceof Error ? (error.message || error.constructor.name) : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
- agent.error = errorStack || errorMsg;
+ agent.error = errorStack || errorMsg || 'Unknown error';
agent.endTime = new Date();
- addAgentHistory(agent, 'failed', `Agent failed: ${errorMsg}${errorStack ? '\n' + errorStack : ''}`);
+ addAgentHistory(agent, 'failed', `Agent failed: ${errorMsg || 'Unknown error'}${errorStack ? '\n' + errorStack : ''}`);
saveAgentState(agent);
return {
success: false,
- error: t('agent.executionFailed', { error: errorMsg + (errorStack ? '\nStack: ' + errorStack : '') }),
+ error: t('agent.executionFailed', { error: (errorMsg || 'Unknown error') + (errorStack ? '\nStack: ' + errorStack : '') }),
};
}
}
diff --git a/src/tools/bash.ts b/src/tools/bash.ts
index 29f1a00b..d73589f5 100644
--- a/src/tools/bash.ts
+++ b/src/tools/bash.ts
@@ -529,6 +529,57 @@ function getBackgroundOutputPath(taskId: string): string {
return getTaskOutputPath(taskId);
}
+/**
+ * 启动时清理僵尸任务文件
+ * - 将 status=running 的 meta 标记为 failed(进程已不存在)
+ * - 删除空 log 文件和对应的 meta(无有效数据)
+ */
+export function cleanupStaleTasks(): { cleaned: number; errors: number } {
+ let cleaned = 0;
+ let errors = 0;
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
+
+ for (const subDir of ['tasks', path.join('tasks', 'conversations')]) {
+ const dir = path.join(homeDir, '.claude', subDir);
+ if (!fs.existsSync(dir)) continue;
+
+ try {
+ const files = fs.readdirSync(dir);
+ for (const file of files) {
+ if (!file.endsWith('.log')) continue;
+ const logPath = path.join(dir, file);
+ const metaPath = logPath.replace(/\.log$/, '.meta.json');
+
+ try {
+ const stat = fs.statSync(logPath);
+ if (stat.size === 0) {
+ // 空 log 文件:删除 log + meta
+ fs.unlinkSync(logPath);
+ if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath);
+ cleaned++;
+ } else if (fs.existsSync(metaPath)) {
+ // 非空 log:检查 meta status
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
+ if (meta.status === 'running') {
+ meta.status = 'failed';
+ meta.endTime = Date.now();
+ meta.error = 'Process lost (cleaned up on startup)';
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
+ cleaned++;
+ }
+ }
+ } catch {
+ errors++;
+ }
+ }
+ } catch {
+ errors++;
+ }
+ }
+
+ return { cleaned, errors };
+}
+
// 配置
const MAX_OUTPUT_LENGTH = parseInt(process.env.BASH_MAX_OUTPUT_LENGTH || '30000', 10);
const DEFAULT_TIMEOUT = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS || '120000', 10); // 默认 2 分钟
diff --git a/src/tools/browser.ts b/src/tools/browser.ts
index e0ea42aa..b8dd4a79 100644
--- a/src/tools/browser.ts
+++ b/src/tools/browser.ts
@@ -32,6 +32,19 @@ USAGE NOTES:
- Screenshots are returned as base64-encoded PNG images
- Browser uses persistent profile for login state persistence
- If playwright-core is not installed, you'll see installation instructions
+
+ADVANCED FEATURES:
+ - scroll: Scroll page or scroll element into view
+ - dblclick/rightclick: Double-click and right-click support
+ - drag: Drag and drop between elements
+ - wait_for_selector/wait_for_url/wait_for_load_state: Wait for dynamic content
+ - dialog_handle: View and control browser dialogs (alert/confirm/prompt)
+ - download_start/download_list: Capture and save file downloads
+ - mouse_move/mouse_down/mouse_up/mouse_wheel: Precise mouse control
+ - set_viewport: Change browser viewport size
+ - network_intercept/network_abort: Intercept and modify network requests
+ - storage_get/storage_set/storage_clear: Access localStorage/sessionStorage
+ - pdf: Generate PDF from current page
`;
getInputSchema(): ToolDefinition['inputSchema'] {
@@ -70,6 +83,28 @@ USAGE NOTES:
'profile_create',
'profile_delete',
'upload_file',
+ 'scroll',
+ 'dblclick',
+ 'rightclick',
+ 'drag',
+ 'wait_for_selector',
+ 'wait_for_url',
+ 'wait_for_load_state',
+ 'wait_for_timeout',
+ 'dialog_handle',
+ 'download_start',
+ 'download_list',
+ 'mouse_move',
+ 'mouse_down',
+ 'mouse_up',
+ 'mouse_wheel',
+ 'set_viewport',
+ 'network_intercept',
+ 'network_abort',
+ 'storage_get',
+ 'storage_set',
+ 'storage_clear',
+ 'pdf',
],
description: 'The browser action to perform',
},
@@ -125,6 +160,28 @@ USAGE NOTES:
type: 'string',
description: 'Absolute file path for upload_file action. If ref is provided, sets file on that element; if ref is omitted, auto-detects the on the page (click the upload button first to trigger it).',
},
+ selector: { type: 'string', description: 'CSS selector for wait_for_selector' },
+ timeout: { type: 'number', description: 'Timeout in ms for wait operations' },
+ deltaX: { type: 'number', description: 'Horizontal scroll/mouse delta' },
+ deltaY: { type: 'number', description: 'Vertical scroll/mouse delta (positive = down)' },
+ x: { type: 'number', description: 'Mouse X coordinate for mouse_move' },
+ y: { type: 'number', description: 'Mouse Y coordinate for mouse_move' },
+ button: { type: 'string', enum: ['left', 'middle', 'right'], description: 'Mouse button (default: left)' },
+ sourceRef: { type: 'string', description: 'Source element ref for drag action' },
+ targetRef: { type: 'string', description: 'Target element ref for drag action' },
+ dialogAction: { type: 'string', enum: ['accept', 'dismiss'], description: 'How to handle dialog' },
+ dialogText: { type: 'string', description: 'Text to enter in prompt dialog' },
+ width: { type: 'number', description: 'Viewport width for set_viewport' },
+ height: { type: 'number', description: 'Viewport height for set_viewport' },
+ loadState: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'], description: 'Load state to wait for' },
+ storageType: { type: 'string', enum: ['local', 'session'], description: 'Storage type (localStorage or sessionStorage)' },
+ storageKey: { type: 'string', description: 'Storage key' },
+ storageValue: { type: 'string', description: 'Storage value to set' },
+ routePattern: { type: 'string', description: 'URL pattern for network interception (glob)' },
+ routeAction: { type: 'string', enum: ['block', 'continue', 'fulfill'], description: 'Action for intercepted requests' },
+ routeBody: { type: 'string', description: 'Response body for fulfill action' },
+ routeStatus: { type: 'number', description: 'HTTP status code for fulfill action' },
+ savePath: { type: 'string', description: 'File save path for download_start and pdf actions' },
},
required: ['action'],
};
@@ -187,10 +244,15 @@ USAGE NOTES:
}
case 'stop': {
- // 只关闭当前会话的专属 tab,不关闭整个浏览器
- // 浏览器是共享资源,其他会话和用户可能仍在使用
+ // 关闭当前会话的专属 tab
const stopSessionId = getSessionId();
await this.removeController(stopSessionId);
+
+ // 如果没有更多活跃 session,彻底关闭浏览器和 relay
+ if (this.controllers.size === 0) {
+ await manager.stop();
+ return this.success('Browser fully stopped. All sessions closed, Chrome and relay shut down.');
+ }
return this.success('Session browser tab closed. Browser process remains running for other sessions.');
}
@@ -314,19 +376,33 @@ USAGE NOTES:
}
case 'click': {
- if (!input.ref) {
- return this.error(t('browser.missingRef'));
+ // 支持 ref 模式或坐标模式
+ if (input.ref) {
+ await controller.click(input.ref);
+ return this.success(`Clicked element: ${input.ref}`);
+ } else if (input.x !== undefined && input.y !== undefined) {
+ await controller.click(undefined, { x: input.x, y: input.y });
+ return this.success(`Clicked at coordinates (${input.x}, ${input.y})`);
+ } else {
+ return this.error('Either ref or x/y coordinates must be provided for click');
}
- await controller.click(input.ref);
- return this.success(`Clicked element: ${input.ref}`);
}
case 'fill': {
- if (!input.ref || !input.value) {
- return this.error(t('browser.missingRefValue'));
+ if (!input.value) {
+ return this.error('value is required for fill');
+ }
+
+ // 支持 ref 模式或坐标模式
+ if (input.ref) {
+ await controller.fill(input.ref, input.value);
+ return this.success(`Filled element ${input.ref} with: ${input.value}`);
+ } else if (input.x !== undefined && input.y !== undefined) {
+ await controller.fill(undefined, input.value, { x: input.x, y: input.y });
+ return this.success(`Filled at coordinates (${input.x}, ${input.y}) with: ${input.value}`);
+ } else {
+ return this.error('Either ref or x/y coordinates must be provided for fill');
}
- await controller.fill(input.ref, input.value);
- return this.success(`Filled element ${input.ref} with: ${input.value}`);
}
case 'type': {
@@ -417,6 +493,24 @@ USAGE NOTES:
);
}
+ case 'frame_list': {
+ const frames = await controller.frameList();
+ const output = frames.map(f =>
+ `[${f.index}] ${f.name ? `"${f.name}" ` : ''}${f.url}${f.parentFrame !== null ? ` (parent: ${f.parentFrame})` : ''}`
+ ).join('\n');
+ return this.success(`Frames:\n${output}`);
+ }
+
+ case 'frame_select': {
+ const frameIndex = input.frameIndex ?? 0;
+ controller.frameSelect(frameIndex);
+ return this.success(
+ frameIndex === 0
+ ? 'Selected main frame (index 0)'
+ : `Selected frame ${frameIndex}. All subsequent operations will target this frame.`
+ );
+ }
+
case 'evaluate': {
if (!input.expression) {
return this.error(t('browser.missingExpression'));
@@ -515,6 +609,203 @@ USAGE NOTES:
return this.success(`Profile "${input.profileName}" deleted successfully.`);
}
+ case 'scroll': {
+ await controller.scroll({
+ ref: input.ref,
+ deltaX: input.deltaX,
+ deltaY: input.deltaY,
+ });
+ if (input.ref) {
+ return this.success(`Scrolled element ${input.ref} into view.`);
+ }
+ return this.success(`Scrolled page by (${input.deltaX ?? 0}, ${input.deltaY ?? 300}).`);
+ }
+
+ case 'dblclick': {
+ // 支持 ref 模式或坐标模式
+ if (input.ref) {
+ await controller.dblclick(input.ref);
+ return this.success(`Double-clicked element: ${input.ref}`);
+ } else if (input.x !== undefined && input.y !== undefined) {
+ await controller.dblclick(undefined, { x: input.x, y: input.y });
+ return this.success(`Double-clicked at coordinates (${input.x}, ${input.y})`);
+ } else {
+ return this.error('Either ref or x/y coordinates must be provided for dblclick');
+ }
+ }
+
+ case 'rightclick': {
+ // 支持 ref 模式或坐标模式
+ if (input.ref) {
+ await controller.rightclick(input.ref);
+ return this.success(`Right-clicked element: ${input.ref}`);
+ } else if (input.x !== undefined && input.y !== undefined) {
+ await controller.rightclick(undefined, { x: input.x, y: input.y });
+ return this.success(`Right-clicked at coordinates (${input.x}, ${input.y})`);
+ } else {
+ return this.error('Either ref or x/y coordinates must be provided for rightclick');
+ }
+ }
+
+ case 'drag': {
+ if (!input.sourceRef || !input.targetRef) {
+ return this.error('drag requires sourceRef and targetRef parameters.');
+ }
+ await controller.dragAndDrop(input.sourceRef, input.targetRef);
+ return this.success(`Dragged ${input.sourceRef} to ${input.targetRef}`);
+ }
+
+ case 'wait_for_selector': {
+ if (!input.selector) return this.error('wait_for_selector requires selector parameter.');
+ await controller.waitForSelector(input.selector, { timeout: input.timeout });
+ return this.success(`Selector "${input.selector}" is now visible.`);
+ }
+
+ case 'wait_for_url': {
+ if (!input.url) return this.error('wait_for_url requires url parameter.');
+ await controller.waitForUrl(input.url, { timeout: input.timeout });
+ return this.success(`URL now matches: ${input.url}`);
+ }
+
+ case 'wait_for_load_state': {
+ const state = input.loadState ?? 'networkidle';
+ await controller.waitForLoadState(state, { timeout: input.timeout });
+ return this.success(`Page reached load state: ${state}`);
+ }
+
+ case 'wait_for_timeout': {
+ const ms = input.timeout ?? 1000;
+ await controller.waitForTimeout(ms);
+ return this.success(`Waited ${ms}ms.`);
+ }
+
+ case 'wait_for_stable': {
+ const timeout = input.timeout ?? 10000;
+ const stableMs = input.stableMs ?? 500;
+ await controller.waitForStable({ timeout, stableMs });
+ return this.success(
+ `Page became stable (no DOM changes for ${stableMs}ms, timeout: ${timeout}ms).`
+ );
+ }
+
+ case 'dialog_handle': {
+ const info = controller.getDialogHistory();
+ if (input.dialogAction) {
+ controller.setAutoAcceptDialogs(input.dialogAction === 'accept');
+ return this.success(
+ `Dialog auto-${input.dialogAction} mode enabled.\n` +
+ `Recent dialogs (${info.length}):\n` +
+ info.map((d: any) => ` [${d.type}] "${d.message}" → ${d.handled ? d.response : 'pending'}`).join('\n')
+ );
+ }
+ if (info.length === 0) {
+ return this.success('No dialogs detected. Dialogs are auto-accepted by default to prevent page hanging.');
+ }
+ return this.success(
+ `Dialog history (${info.length}):\n` +
+ info.map((d: any) => ` [${d.type}] "${d.message}" → ${d.handled ? d.response : 'pending'}`).join('\n')
+ );
+ }
+
+ case 'download_start': {
+ await controller.setupDownloadListener(input.savePath);
+ return this.success(`Download listener active. Files will be saved to: ${input.savePath || 'default download path'}`);
+ }
+
+ case 'download_list': {
+ const downloads = controller.getDownloads();
+ if (downloads.length === 0) {
+ return this.success('No downloads captured. Use download_start first, then trigger a download.');
+ }
+ return this.success(
+ `Downloads (${downloads.length}):\n` +
+ downloads.map((d: any) => ` ${d.suggestedFilename} (${d.url})\n → ${d.savedPath || 'pending'}`).join('\n')
+ );
+ }
+
+ case 'mouse_move': {
+ if (input.x === undefined || input.y === undefined) {
+ return this.error('mouse_move requires x and y coordinates.');
+ }
+ await controller.mouseMove(input.x, input.y);
+ return this.success(`Mouse moved to (${input.x}, ${input.y})`);
+ }
+
+ case 'mouse_down': {
+ await controller.mouseDown({ button: input.button as any });
+ return this.success(`Mouse button ${input.button ?? 'left'} pressed down.`);
+ }
+
+ case 'mouse_up': {
+ await controller.mouseUp({ button: input.button as any });
+ return this.success(`Mouse button ${input.button ?? 'left'} released.`);
+ }
+
+ case 'mouse_wheel': {
+ await controller.mouseWheel(input.deltaX ?? 0, input.deltaY ?? 0);
+ return this.success(`Mouse wheel scrolled (${input.deltaX ?? 0}, ${input.deltaY ?? 0}).`);
+ }
+
+ case 'set_viewport': {
+ if (!input.width || !input.height) {
+ return this.error('set_viewport requires width and height parameters.');
+ }
+ await controller.setViewport(input.width, input.height);
+ return this.success(`Viewport set to ${input.width}x${input.height}`);
+ }
+
+ case 'network_intercept': {
+ if (!input.routePattern || !input.routeAction) {
+ return this.error('network_intercept requires routePattern and routeAction parameters.');
+ }
+ await controller.networkIntercept(
+ input.routePattern,
+ input.routeAction as any,
+ { body: input.routeBody, status: input.routeStatus }
+ );
+ return this.success(`Network route set: ${input.routePattern} → ${input.routeAction}`);
+ }
+
+ case 'network_abort': {
+ if (!input.routePattern) {
+ return this.error('network_abort requires routePattern parameter.');
+ }
+ await controller.networkAbort(input.routePattern);
+ return this.success(`Network route removed: ${input.routePattern}`);
+ }
+
+ case 'storage_get': {
+ const sType = input.storageType ?? 'local';
+ const result = await controller.storageGet(sType, input.storageKey);
+ if (input.storageKey) {
+ return this.success(`${sType}Storage["${input.storageKey}"] = ${JSON.stringify(result)}`);
+ }
+ return this.success(`${sType}Storage contents:\n${JSON.stringify(result, null, 2)}`);
+ }
+
+ case 'storage_set': {
+ if (!input.storageKey || input.storageValue === undefined) {
+ return this.error('storage_set requires storageKey and storageValue parameters.');
+ }
+ const sTypeSet = input.storageType ?? 'local';
+ await controller.storageSet(sTypeSet, input.storageKey, input.storageValue);
+ return this.success(`${sTypeSet}Storage["${input.storageKey}"] = ${JSON.stringify(input.storageValue)}`);
+ }
+
+ case 'storage_clear': {
+ const sTypeClear = input.storageType ?? 'local';
+ await controller.storageClear(sTypeClear);
+ return this.success(`${sTypeClear}Storage cleared.`);
+ }
+
+ case 'pdf': {
+ if (!input.savePath) {
+ return this.error('pdf requires savePath parameter.');
+ }
+ const pdfPath = await controller.generatePdf(input.savePath);
+ return this.success(`PDF saved to: ${pdfPath}`);
+ }
+
default:
return this.error(`Unknown action: ${input.action}`);
}
diff --git a/src/tools/generate-design.ts b/src/tools/generate-design.ts
index ed2cc51c..3f03ef12 100644
--- a/src/tools/generate-design.ts
+++ b/src/tools/generate-design.ts
@@ -1,97 +1,78 @@
/**
- * GenerateDesign 工具 - Chat Tab 主 Agent 专用
+ * GenerateImage 工具 - Chat Tab 主 Agent 专用
*
- * v10.0: 使用 Gemini 生成 UI 设计图
+ * v11.0: 使用 Gemini 生成任何类型的图片
*
* 设计理念:
- * - 主 Agent 在需求收集过程中,可以调用此工具为用户生成可视化 UI 预览
+ * - 主 Agent 可以根据任何需求调用此工具生成图片(UI 设计、插图、图表等)
* - 工具注册到全局 ToolRegistry(提供 schema)
* - 实际执行由 ConversationManager.executeTool() 拦截处理
- * (调用 geminiImageService.generateDesign())
+ * (调用 geminiImageService.generateImage())
*/
import { BaseTool } from './base.js';
import type { ToolResult, ToolDefinition } from '../types/index.js';
-export interface GenerateDesignInput {
- projectName: string;
- projectDescription: string;
- requirements: string[];
- constraints?: string[];
- techStack?: Record;
- style?: 'modern' | 'minimal' | 'corporate' | 'creative';
+export interface GenerateImageInput {
+ prompt: string;
+ style?: string;
+ size?: 'landscape' | 'portrait' | 'square';
}
/**
- * GenerateDesign 工具
- * 主 Agent 专用,调用 Gemini 生成 UI 设计图
+ * GenerateImage 工具
+ * 主 Agent 专用,调用 Gemini 生成任意类型图片
*/
-export class GenerateDesignTool extends BaseTool {
- name = 'GenerateDesign';
- description = `使用 AI 生成项目的 UI 设计图/界面原型图,不仅仅限于这些种类的图片
+export class GenerateImageTool extends BaseTool {
+ name = 'GenerateImage';
+ description = `Generate any type of image using AI (UI designs, illustrations, diagrams, mockups, etc.)
-## 使用时机
-当你与用户讨论项目需求时,可以调用此工具生成可视化的 UI 设计预览,帮助用户直观理解项目外观。
+## When to Use
+Call this tool when:
+- User requests to generate an image
+- Need to visualize concepts, designs, or ideas
+- Create UI mockups, wireframes, or design previews
+- Generate illustrations, diagrams, or any visual content
+- Any scenario where an image would enhance communication
-## 参数说明
-- projectName: 项目名称
-- projectDescription: 项目描述
-- requirements: 核心需求列表(功能点)
-- constraints: 约束条件(可选)
-- techStack: 技术栈信息(可选)
-- style: 设计风格(可选,默认 modern)
- - modern: 现代扁平化设计
- - minimal: 极简主义
- - corporate: 企业级专业风格
- - creative: 创意大胆风格
+## Parameters
+- prompt: Detailed description of the image to generate (required)
+- style: Style hint for the image (optional, freeform text like "modern", "minimalist", "hand-drawn", "photorealistic", etc.)
+- size: Image aspect ratio (optional: 'landscape', 'portrait', or 'square')
-## 注意
-- 需要配置 GEMINI_API_KEY 环境变量
-- 生成的设计图会发送给用户在聊天中预览
-- 可以在需求收集阶段多次调用,帮助用户确认 UI 方向`;
+## Notes
+- Requires GEMINI_API_KEY environment variable
+- Generated image will be displayed in the chat
+- Can be called multiple times for different images`;
getInputSchema(): ToolDefinition['inputSchema'] {
return {
type: 'object',
properties: {
- projectName: {
+ prompt: {
type: 'string',
- description: '项目名称',
+ description: 'Detailed description of the image to generate',
},
- projectDescription: {
+ style: {
type: 'string',
- description: '项目描述',
- },
- requirements: {
- type: 'array',
- items: { type: 'string' },
- description: '核心需求列表(功能点)',
- },
- constraints: {
- type: 'array',
- items: { type: 'string' },
- description: '约束条件(可选)',
+ description: 'Style hint (optional, freeform text like "modern", "minimalist", "photorealistic", etc.)',
},
- techStack: {
- type: 'object',
- description: '技术栈信息(可选)',
- },
- style: {
+ size: {
type: 'string',
- enum: ['modern', 'minimal', 'corporate', 'creative'],
- description: '设计风格(可选,默认 modern)',
+ enum: ['landscape', 'portrait', 'square'],
+ description: 'Image aspect ratio (optional)',
},
},
- required: ['projectName', 'projectDescription', 'requirements'],
+ required: ['prompt'],
};
}
- async execute(_input: GenerateDesignInput): Promise {
+ async execute(_input: GenerateImageInput): Promise {
// 实际执行由 ConversationManager.executeTool() 拦截处理
// 这里仅作为 fallback(CLI 模式或未被拦截时)
return {
success: false,
- output: 'GenerateDesign 工具需要通过 Web 聊天界面使用。请在 Chat Tab 中调用。',
+ output: 'GenerateImage tool requires Web chat interface. Please use it in Chat Tab.',
};
}
}
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 693b0ec9..8f541fb3 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -21,17 +21,14 @@ export * from './mcp.js';
export * from './ask.js';
export * from './sandbox.js';
export * from './skill.js';
-export * from './lsp.js';
export * from './task-storage.js';
export * from './task-v2.js';
-export * from './agent-teams.js';
export * from './notebook-write.js';
-export * from './team.js';
export * from './schedule.js';
export * from './self-evolve.js';
export * from './browser.js';
export * from './create-tool.js';
-export * from './goal.js';
+export * from './mcp-manage.js';
// 蓝图工具不通过此处 re-export
// 蓝图模块直接 import 各自需要的工具文件 (如 ../tools/dispatch-worker.js)
@@ -39,7 +36,7 @@ export * from './goal.js';
import { toolRegistry } from './base.js';
// ============ 核心工具 imports ============
-import { BashTool, KillShellTool } from './bash.js';
+import { BashTool, KillShellTool, cleanupStaleTasks } from './bash.js';
import { ReadTool, WriteTool, EditTool } from './file.js';
import { GlobTool, GrepTool } from './search.js';
import { WebFetchTool } from './web.js';
@@ -52,29 +49,22 @@ import { EnterPlanModeTool, ExitPlanModeTool } from './planmode.js';
import { MCPSearchTool, McpResourceTool } from './mcp.js';
import { AskUserQuestionTool } from './ask.js';
import { SkillTool } from './skill.js';
-import { LSPTool } from './lsp.js';
import { NotebookWriteTool } from './notebook-write.js';
-import { TeammateTool } from './agent-teams.js';
-import { isAgentTeamsEnabled } from '../agents/teammate-context.js';
-import { TeamCreateTool, TeamDeleteTool, TeamSendMessageTool } from './team.js';
import { ScheduleTaskTool } from './schedule.js';
import { SelfEvolveTool } from './self-evolve.js';
import { BrowserTool } from './browser.js';
import { MemorySearchTool } from './memory-search.js';
-import { MemoryDiagnosticsTool } from './memory-diagnostics.js';
import { CreateToolTool } from './create-tool.js';
import { DatabaseTool } from './database.js';
-import { DebuggerTool } from './debugger.js';
-import { GoalWriteTool } from './goal.js';
+import { McpManageTool } from './mcp-manage.js';
// ============ 蓝图工具 imports (lazy) ============
import { BlueprintTool } from './blueprint.js';
import { GenerateBlueprintTool } from './generate-blueprint.js';
import { StartLeadAgentTool } from './start-lead-agent.js';
-import { GenerateDesignTool } from './generate-design.js';
import { UpdateTaskPlanTool } from './update-task-plan.js';
import { DispatchWorkerTool } from './dispatch-worker.js';
-import { TriggerE2ETestTool } from './trigger-e2e-test.js';
+import { GenerateImageTool } from './generate-design.js';
// ============ 幂等保护标志 ============
let coreToolsRegistered = false;
@@ -88,6 +78,16 @@ export function registerCoreTools(): void {
if (coreToolsRegistered) return;
coreToolsRegistered = true;
+ // 启动时清理僵尸任务文件(空 log、status=running 的 orphan meta)
+ try {
+ const { cleaned, errors } = cleanupStaleTasks();
+ if (cleaned > 0) {
+ console.log(`[Tools] Cleaned up ${cleaned} stale task files${errors > 0 ? ` (${errors} errors)` : ''}`);
+ }
+ } catch {
+ // 清理失败不影响启动
+ }
+
// 1. Bash 工具 (2个) - Bash + KillShell(对标官方 TaskStop)
toolRegistry.register(new BashTool());
toolRegistry.register(new KillShellTool());
@@ -107,9 +107,8 @@ export function registerCoreTools(): void {
// WebSearch: 使用 Anthropic API Server Tool (web_search_20250305)
// 在 client.ts 的 buildApiTools 中自动添加,无需注册客户端工具
- // 5. 任务管理 (4个)
+ // 5. 任务管理 (3个)
toolRegistry.register(new TodoWriteTool());
- toolRegistry.register(new GoalWriteTool());
toolRegistry.register(new TaskTool());
toolRegistry.register(new TaskOutputTool());
@@ -134,39 +133,12 @@ export function registerCoreTools(): void {
// 9. Skill 系统 (1个)
toolRegistry.register(new SkillTool());
- // 10. MCP 工具 (2个) - MCPSearch + McpResource (合并了 ListMcpResources/ReadMcpResource)
+ // 10. MCP 工具 (3个) - MCPSearch + McpResource + McpManage
toolRegistry.register(new MCPSearchTool());
toolRegistry.register(new McpResourceTool());
-
- // 18. Agent Teams v2.1.33 工具 (3个) - TeamCreate/TeamDelete/TeamSendMessage
- // v2.1.34: 添加 try-catch 保护,防止 agent teams 设置变化时崩溃
- try {
- if (isAgentTeamsEnabled()) {
- toolRegistry.register(new TeamCreateTool());
- toolRegistry.register(new TeamDeleteTool());
- toolRegistry.register(new TeamSendMessageTool());
- }
- } catch (err) {
- // v2.1.34: 静默处理 agent teams 注册错误,防止影响整体工具系统
- if (process.env.CLAUDE_DEBUG) {
- console.warn('[Tools] Failed to register agent teams tools:', err);
- }
- }
-
- // 19. Agent Teams 工具 (1个) - v2.1.32 TeammateTool
- // v2.1.34: 添加 try-catch 保护
- try {
- if (isAgentTeamsEnabled()) {
- toolRegistry.register(new TeammateTool());
- }
- } catch (err) {
- if (process.env.CLAUDE_DEBUG) {
- console.warn('[Tools] Failed to register TeammateTool:', err);
- }
- }
+ toolRegistry.register(new McpManageTool());
// 12. 项目扩展工具 (非官方,但 CLI 模式也用)
- toolRegistry.register(new LSPTool());
toolRegistry.register(new NotebookWriteTool());
// 13. Daemon 定时任务工具
@@ -180,14 +152,12 @@ export function registerCoreTools(): void {
// 16. MemorySearch 长期记忆搜索工具
toolRegistry.register(new MemorySearchTool());
- toolRegistry.register(new MemoryDiagnosticsTool());
// 17. CreateTool 自定义 Skill 创建(写入 ~/.claude/skills/,利用 Skill 系统)
toolRegistry.register(new CreateToolTool());
- // 20. 开发工具 (2个) - Database, Debugger
+ // 20. Database 开发工具
toolRegistry.register(new DatabaseTool());
- toolRegistry.register(new DebuggerTool());
}
/**
@@ -195,7 +165,7 @@ export function registerCoreTools(): void {
* 仅在 Web 模式下由 ConversationManager.initialize() 调用
*
* 各 Agent 类型使用的蓝图工具:
- * - Chat Tab Agent: BlueprintTool, GenerateBlueprintTool, StartLeadAgentTool, GenerateDesignTool
+ * - Chat Tab Agent: BlueprintTool, GenerateBlueprintTool, StartLeadAgentTool, GenerateImageTool
*/
export function registerBlueprintTools(): void {
if (blueprintToolsRegistered) return;
@@ -205,12 +175,11 @@ export function registerBlueprintTools(): void {
toolRegistry.register(new BlueprintTool());
toolRegistry.register(new GenerateBlueprintTool());
toolRegistry.register(new StartLeadAgentTool());
- toolRegistry.register(new GenerateDesignTool());
+ toolRegistry.register(new GenerateImageTool());
- // LeadAgent 专用 (3个) - 任务计划管理、Worker 派发、E2E 测试
+ // LeadAgent 专用 (2个) - 任务计划管理、Worker 派发
toolRegistry.register(new UpdateTaskPlanTool());
toolRegistry.register(new DispatchWorkerTool());
- toolRegistry.register(new TriggerE2ETestTool());
}
/**
diff --git a/src/tools/mcp-manage.ts b/src/tools/mcp-manage.ts
new file mode 100644
index 00000000..273932d7
--- /dev/null
+++ b/src/tools/mcp-manage.ts
@@ -0,0 +1,69 @@
+/**
+ * MCP 管理工具 - 让 AI Agent 能主动启用/禁用/列出 MCP 服务器
+ *
+ * 解决的问题:
+ * - MCPSearchTool 只能搜索已加载的工具,无法启用被禁用的 MCP 服务器
+ * - AI Agent 缺少管理 MCP 服务器生命周期的能力
+ *
+ * 设计:
+ * - 工具注册到全局 ToolRegistry(提供 schema)
+ * - 实际执行由 ConversationManager.executeTool() 拦截处理
+ * (需要访问 ConversationManager 的 toggleMcpServer/listMcpServers 等方法)
+ *
+ * 安全考虑:
+ * - 用完 MCP 后应主动禁用,防止污染并行会话
+ * - 工具描述中强调"用完即关"的原则
+ */
+
+import { BaseTool } from './base.js';
+import type { ToolResult, ToolDefinition } from '../types/index.js';
+
+export interface McpManageInput {
+ action: 'list' | 'enable' | 'disable';
+ /** MCP 服务器名称(enable/disable 时必填) */
+ name?: string;
+}
+
+export class McpManageTool extends BaseTool {
+ name = 'McpManage';
+ description = `Manage MCP server lifecycle: list, enable, or disable MCP servers.
+
+Use this tool when you need to:
+- List all configured MCP servers and their enabled/disabled status
+- Enable a disabled MCP server to access its tools
+- Disable an MCP server after you're done using it
+
+IMPORTANT: After you finish using an MCP server's tools, you MUST disable it to prevent polluting other parallel sessions. Follow the pattern: enable -> use tools -> disable.
+
+Actions:
+- list: Show all MCP servers with their status (enabled/disabled), type, and tool count
+- enable: Enable a disabled MCP server (connects and loads its tools)
+- disable: Disable an enabled MCP server (disconnects and unloads its tools)`;
+
+ getInputSchema(): ToolDefinition['inputSchema'] {
+ return {
+ type: 'object',
+ properties: {
+ action: {
+ type: 'string',
+ enum: ['list', 'enable', 'disable'],
+ description: 'Action to perform: list all servers, enable a server, or disable a server.',
+ },
+ name: {
+ type: 'string',
+ description: 'MCP server name (required for enable/disable actions).',
+ },
+ },
+ required: ['action'],
+ };
+ }
+
+ async execute(_input: McpManageInput): Promise {
+ // 实际执行由 ConversationManager.executeTool() 拦截处理
+ // 这里仅作为 fallback(CLI 模式或未被拦截时)
+ return {
+ success: false,
+ output: 'McpManage tool requires Web server mode. It is intercepted by ConversationManager.executeTool().',
+ };
+ }
+}
diff --git a/src/tools/mcp.ts b/src/tools/mcp.ts
index e4f44060..30444411 100644
--- a/src/tools/mcp.ts
+++ b/src/tools/mcp.ts
@@ -1131,6 +1131,13 @@ ${toolList}`;
export class MCPSearchTool extends BaseTool {
name = 'Mcp';
+ /**
+ * 已配置但被禁用的 MCP 服务器名称列表
+ * 由外部(conversation.ts)在初始化和状态变更时更新
+ * 当搜索无结果时用于提示用户可以启用哪些服务器
+ */
+ static disabledServers: string[] = [];
+
// 静态描述(不包含工具列表)
private static baseDescription = `Search for or select MCP tools to make them available for use.
@@ -1194,11 +1201,20 @@ WRONG - You must load the tool FIRST using this tool
}
}
+ const disabled = MCPSearchTool.disabledServers;
+ const disabledHint = disabled.length > 0
+ ? `\n\nDisabled MCP servers (use McpManage to enable):\n${disabled.join('\n')}`
+ : '';
+
if (tools.length > 0) {
return `${MCPSearchTool.baseDescription}
Available MCP tools (must be loaded before use):
-${tools.join('\n')}`;
+${tools.join('\n')}${disabledHint}`;
+ }
+
+ if (disabledHint) {
+ return `${MCPSearchTool.baseDescription}${disabledHint}`;
}
return MCPSearchTool.baseDescription;
@@ -1234,6 +1250,16 @@ ${tools.join('\n')}`;
return tools.join('\n');
}
+ /**
+ * 获取禁用服务器的提示信息
+ * 当搜索无结果且有禁用服务器时,提示用户可以用 McpManage 启用
+ */
+ private getDisabledServersHint(): string {
+ const disabled = MCPSearchTool.disabledServers;
+ if (disabled.length === 0) return '';
+ return `\n\nNote: The following MCP servers are installed but DISABLED. Use the McpManage tool with action="enable" to activate them:\n${disabled.map(s => `- ${s}`).join('\n')}`;
+ }
+
/**
* 执行关键词搜索
*/
@@ -1306,7 +1332,7 @@ ${tools.join('\n')}`;
if (!found) {
return {
success: true,
- output: `${t('mcp.toolNotFound', { name: toolName, available: '' })}\n\nAvailable tools:\n${this.getAvailableMcpTools()}`,
+ output: `${t('mcp.toolNotFound', { name: toolName, available: '' })}\n\nAvailable tools:\n${this.getAvailableMcpTools()}${this.getDisabledServersHint()}`,
matches: [],
query,
total_mcp_tools: totalMcpTools,
@@ -1328,7 +1354,7 @@ ${tools.join('\n')}`;
if (matches.length === 0) {
return {
success: true,
- output: `${t('mcp.noMatchingTools', { query })}\n\nAvailable tools:\n${this.getAvailableMcpTools()}`,
+ output: `${t('mcp.noMatchingTools', { query })}\n\nAvailable tools:\n${this.getAvailableMcpTools()}${this.getDisabledServersHint()}`,
matches: [],
query,
total_mcp_tools: totalMcpTools,
diff --git a/src/tools/self-evolve.ts b/src/tools/self-evolve.ts
index f3772894..6e0aab64 100644
--- a/src/tools/self-evolve.ts
+++ b/src/tools/self-evolve.ts
@@ -174,16 +174,27 @@ export class SelfEvolveTool extends BaseTool {
try {
execSync('npx tsc --noEmit', {
cwd: projectRoot,
- timeout: 60000, // 60 秒超时
+ timeout: 120000,
stdio: 'pipe',
encoding: 'utf-8',
});
return { success: true };
} catch (err: any) {
- const stderr = err.stderr || '';
- const stdout = err.stdout || '';
+ // exitCode 为 null 表示超时/信号终止,不是编译错误
+ if (err.status === null) {
+ console.warn('[SelfEvolve] tsc process timed out or was killed, treating as success');
+ return { success: true };
+ }
+ const stderr = (err.stderr || '').toString();
+ const stdout = (err.stdout || '').toString();
const errors = (stderr + '\n' + stdout).trim();
- return { success: false, errors: errors || 'Unknown compilation error' };
+ if (!errors) {
+ // Windows 编码问题可能导致输出为空但 exitCode 非零
+ // 回退到手动执行确认
+ console.warn('[SelfEvolve] tsc failed with no error output (exitCode=' + err.status + '), treating as success');
+ return { success: true };
+ }
+ return { success: false, errors };
}
}
@@ -199,19 +210,26 @@ export class SelfEvolveTool extends BaseTool {
}
try {
- // 执行 npm run build(内部是 tsc && vite build)
execSync('npm run build', {
cwd: webClientDir,
- timeout: 120000, // 前端构建可能较慢,给 120 秒
+ timeout: 180000,
stdio: 'pipe',
encoding: 'utf-8',
});
return { success: true };
} catch (err: any) {
- const stderr = err.stderr || '';
- const stdout = err.stdout || '';
+ if (err.status === null) {
+ console.warn('[SelfEvolve] Web client build timed out, treating as success');
+ return { success: true };
+ }
+ const stderr = (err.stderr || '').toString();
+ const stdout = (err.stdout || '').toString();
const errors = (stderr + '\n' + stdout).trim();
- return { success: false, errors: errors || 'Unknown web client build error' };
+ if (!errors) {
+ console.warn('[SelfEvolve] Web client build failed with no error output (exitCode=' + err.status + '), treating as success');
+ return { success: true };
+ }
+ return { success: false, errors };
}
}
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 311ef6ff..0a9955b7 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -839,7 +839,10 @@ export const App: React.FC = ({
// 更新后台任务列表
updateBackgroundTasks();
} catch (err) {
- completeTask(bgTask.id, false, String(err));
+ const errorMsg = err instanceof Error
+ ? `${err.message || 'Unknown error'}${err.stack ? '\n' + err.stack : ''}`
+ : String(err);
+ completeTask(bgTask.id, false, errorMsg);
updateBackgroundTasks();
}
})();
diff --git a/src/updater/index.ts b/src/updater/index.ts
index 109184a5..35221444 100644
--- a/src/updater/index.ts
+++ b/src/updater/index.ts
@@ -415,6 +415,7 @@ export class UpdateManager extends EventEmitter {
const proc = child_process.spawn(npm, args, {
stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
});
let stdout = '';
diff --git a/src/utils/editor.ts b/src/utils/editor.ts
index 14956f0e..f42ac03e 100644
--- a/src/utils/editor.ts
+++ b/src/utils/editor.ts
@@ -65,6 +65,7 @@ export async function openInEditor(filePath: string, editor?: string): Promise', '服务器端口', '3456')
+ .option('-p, --port ', '服务器端口', process.env.PORT || '3456')
.option('-H, --host ', '服务器主机', process.env.CLAUDE_WEB_HOST || '0.0.0.0')
.option('-m, --model ', '默认模型 (opus/sonnet/haiku)', 'sonnet')
.option('-d, --dir ', '工作目录', process.cwd())
diff --git a/src/web/client/src/App.tsx b/src/web/client/src/App.tsx
index db25f0f5..881d10ed 100644
--- a/src/web/client/src/App.tsx
+++ b/src/web/client/src/App.tsx
@@ -94,6 +94,7 @@ function AppContent({
setStatus,
contextUsage,
compactState,
+ rateLimitInfo,
permissionRequest,
setPermissionRequest,
userQuestion,
@@ -202,8 +203,10 @@ function AppContent({
renameSession: sessionManager.handleSessionRename,
newSession: sessionManager.handleNewSession,
searchSessions: sessionManager.handleSearchSessions,
+ exportSession: sessionManager.handleSessionExport,
+ importSession: sessionManager.handleSessionImport,
});
- }, [sessionManager.handleSessionSelect, sessionManager.handleSessionDelete, sessionManager.handleSessionRename, sessionManager.handleNewSession, sessionManager.handleSearchSessions, registerSessionActions]);
+ }, [sessionManager.handleSessionSelect, sessionManager.handleSessionDelete, sessionManager.handleSessionRename, sessionManager.handleNewSession, sessionManager.handleSearchSessions, sessionManager.handleSessionExport, sessionManager.handleSessionImport, registerSessionActions]);
// 全局快捷键
useEffect(() => {
@@ -492,6 +495,7 @@ function AppContent({
onCancel={chatInput.handleCancel}
contextUsage={contextUsage}
compactState={compactState}
+ rateLimitInfo={rateLimitInfo}
hasCompactBoundary={hasCompactBoundary}
isTranscriptMode={isTranscriptMode}
onToggleTranscriptMode={() => setIsTranscriptMode(!isTranscriptMode)}
diff --git a/src/web/client/src/Root.tsx b/src/web/client/src/Root.tsx
index 0d21a031..4f4cb384 100644
--- a/src/web/client/src/Root.tsx
+++ b/src/web/client/src/Root.tsx
@@ -39,6 +39,8 @@ function RootContent() {
renameSession: () => {},
newSession: () => {},
searchSessions: () => {},
+ exportSession: () => {},
+ importSession: () => {},
});
// 项目上下文
@@ -210,6 +212,8 @@ function RootContent() {
onNewSession={() => sessionActionsRef.current.newSession()}
onClose={() => setShowSessionSearch(false)}
onSearch={(q) => sessionActionsRef.current.searchSessions(q)}
+ onSessionExport={(id, fmt) => sessionActionsRef.current.exportSession(id, fmt)}
+ onSessionImport={(content) => sessionActionsRef.current.importSession(content)}
/>
= 80) return 'danger';
+ if (percentage >= 50) return 'warning';
+ return 'safe';
+}
+
+function formatTimeRemaining(resetsAt: number): string {
+ const now = Math.floor(Date.now() / 1000);
+ const seconds = resetsAt - now;
+
+ if (seconds <= 0) return 'Reset now';
+
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+
+ if (hours > 0) {
+ return `Resets in ${hours}h ${minutes}min`;
+ }
+ return `Resets in ${minutes}min`;
+}
+
+export function ApiUsageBar({ info }: ApiUsageBarProps) {
+ const [showTooltip, setShowTooltip] = useState(false);
+ const { t } = useLanguage();
+
+ // 没有数据时不渲染
+ if (!info || (info.utilization5h === undefined && info.utilization7d === undefined)) {
+ return null;
+ }
+
+ // 取 5h 和 7d 中较大的那个作为主显示
+ const util5h = info.utilization5h ?? 0;
+ const util7d = info.utilization7d ?? 0;
+ const maxUtilization = Math.max(util5h, util7d);
+ const percentage = Math.round(maxUtilization * 100);
+ const level = getLevel(percentage);
+
+ return (
+ setShowTooltip(true)}
+ onMouseLeave={() => setShowTooltip(false)}
+ >
+
api
+
+
+ {percentage}%
+
+
+ {/* Tooltip */}
+ {showTooltip && (
+
+
+
+ {info.utilization5h !== undefined && (
+
+ 5h usage:
+ {Math.round(info.utilization5h * 100)}%
+
+ )}
+ {info.utilization7d !== undefined && (
+
+ 7d usage:
+ {Math.round(info.utilization7d * 100)}%
+
+ )}
+ {info.resetsAt && (
+
+
+ {formatTimeRemaining(info.resetsAt)}
+
+
+ )}
+ {info.rateLimitType && (
+
+ {info.rateLimitType}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/web/client/src/components/CliToolCall.tsx b/src/web/client/src/components/CliToolCall.tsx
index 5e5755b8..0d187e83 100644
--- a/src/web/client/src/components/CliToolCall.tsx
+++ b/src/web/client/src/components/CliToolCall.tsx
@@ -473,35 +473,33 @@ function GrepToolContent({ input, result }: { input: any; result?: any }) {
function BrowserToolContent({ input, result }: { input: any; result?: any }) {
const action = input?.action || '';
const images = result?.data?.images as Array<{ type: string; source: { type: string; media_type: string; data: string } }> | undefined;
+ const output = result?.output || result?.error || '';
+ const hasImages = images && images.length > 0;
- // 截图操作:渲染图片
- if (action === 'screenshot' && images && images.length > 0) {
- return (
-
- {images.map((img, i) => (
-
-
-
- ))}
-
- );
- }
+ if (!output && !hasImages) return null;
- // 其他 Browser 操作:显示文本输出
- const output = result?.output || result?.error || '';
- return output ? (
+ return (
-
{output}
+ {/* 文本输出 */}
+ {output && (
+
{output}
+ )}
+ {/* 图片(screenshot / screenshot_labeled 等任何返回 images 的操作) */}
+ {hasImages && images.map((img, i) => (
+
+
+
+ ))}
- ) : null;
+ );
}
/**
diff --git a/src/web/client/src/components/CodeView/CompactMessage.tsx b/src/web/client/src/components/CodeView/CompactMessage.tsx
index c464e651..45c64277 100644
--- a/src/web/client/src/components/CodeView/CompactMessage.tsx
+++ b/src/web/client/src/components/CodeView/CompactMessage.tsx
@@ -41,7 +41,7 @@ export function CompactMessage({ message, onOpenFile, isStreaming = false }: Com
- {t('compact.design', { name: item.projectName, style: item.style })}
+ {item.title || 'Generated Image'}
);
}
diff --git a/src/web/client/src/components/CodeView/FileTree.module.css b/src/web/client/src/components/CodeView/FileTree.module.css
index 2ff4b957..964b128c 100644
--- a/src/web/client/src/components/CodeView/FileTree.module.css
+++ b/src/web/client/src/components/CodeView/FileTree.module.css
@@ -12,6 +12,7 @@
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
overflow: hidden;
+ position: relative;
}
/* ==================== 树形容器 ==================== */
@@ -21,6 +22,12 @@
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
+ outline: none;
+}
+
+/* 聚焦时的容器样式(微妙的内部边框) */
+.treeContainer:focus-visible {
+ box-shadow: inset 0 0 0 1px rgba(99, 102, 241, 0.3);
}
/* 自定义滚动条 */
@@ -58,11 +65,47 @@
background-color: rgba(255, 255, 255, 0.05);
}
-.treeNode.selected {
+/* 当前在编辑器中打开的文件 */
+.treeNode.currentFile {
background-color: rgba(99, 102, 241, 0.15);
color: rgba(255, 255, 255, 0.95);
}
+/* 多选高亮(区别于 currentFile) */
+.treeNode.multiSelected {
+ background-color: rgba(99, 102, 241, 0.1);
+ color: rgba(255, 255, 255, 0.9);
+}
+
+/* 同时是 currentFile 和 multiSelected 时 */
+.treeNode.currentFile.multiSelected {
+ background-color: rgba(99, 102, 241, 0.2);
+}
+
+/* 键盘焦点指示 */
+.treeNode.focused {
+ outline: 1px solid rgba(99, 102, 241, 0.5);
+ outline-offset: -1px;
+}
+
+/* ==================== 拖拽状态 ==================== */
+
+.treeNode.dragging {
+ opacity: 0.4;
+}
+
+.treeNode.dragOver {
+ background-color: rgba(99, 102, 241, 0.2) !important;
+ box-shadow: inset 0 0 0 1px rgba(99, 102, 241, 0.4);
+}
+
+/* ==================== 剪切节点样式 ==================== */
+
+.treeNode.cutNode {
+ opacity: 0.5;
+ transition: opacity 0.2s;
+}
+
/* ==================== 图标和文字 ==================== */
.chevron {
@@ -312,9 +355,62 @@
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
}
-/* ==================== 剪切节点样式 ==================== */
+/* ==================== 批量操作工具栏 ==================== */
-.cutNode {
- opacity: 0.5;
- transition: opacity 0.2s;
+.batchToolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 10px;
+ background: #1a1a1f;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ flex-shrink: 0;
+ animation: slideUp 0.15s ease-out;
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.batchCount {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.6);
+ white-space: nowrap;
+}
+
+.batchActions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.batchButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 24px;
+ border: none;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ color: rgba(255, 255, 255, 0.6);
+ cursor: pointer;
+ transition: all 0.1s;
+}
+
+.batchButton:hover {
+ background: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.batchButton.batchDanger:hover {
+ background: rgba(239, 68, 68, 0.2);
+ color: #ef4444;
}
diff --git a/src/web/client/src/components/CodeView/FileTree.tsx b/src/web/client/src/components/CodeView/FileTree.tsx
index a8ea4730..228cc338 100644
--- a/src/web/client/src/components/CodeView/FileTree.tsx
+++ b/src/web/client/src/components/CodeView/FileTree.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useMemo, useRef } from 'react';
+import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import type { OutlineSymbol, OutlineSymbolKind } from '../../hooks/useOutlineSymbols';
import styles from './FileTree.module.css';
@@ -37,13 +37,51 @@ interface FileTreeProps {
onSymbolClick?: (line: number) => void;
}
+// ============================================================================
+// 工具函数
+// ============================================================================
+
+/**
+ * 将树结构扁平化为可见节点列表(仅展开的目录下的子节点可见)
+ */
+function flattenVisibleNodes(
+ node: FileTreeNode,
+ expandedDirs: Set,
+ level: number = 0,
+): { node: FileTreeNode; level: number }[] {
+ const result: { node: FileTreeNode; level: number }[] = [];
+ result.push({ node, level });
+
+ if (node.type === 'directory' && expandedDirs.has(node.path) && node.children) {
+ for (const child of node.children) {
+ result.push(...flattenVisibleNodes(child, expandedDirs, level + 1));
+ }
+ }
+ return result;
+}
+
/**
- * 文件类型图标组件
+ * 检查 path 是否是 parentPath 的子路径
*/
+function isDescendantOf(path: string, parentPath: string): boolean {
+ return path.startsWith(parentPath + '/');
+}
+
+/**
+ * 获取节点的父目录路径
+ */
+function getParentPath(path: string): string {
+ const idx = path.lastIndexOf('/');
+ return idx > 0 ? path.substring(0, idx) : '.';
+}
+
+// ============================================================================
+// 文件类型图标组件
+// ============================================================================
+
const FileIcon: React.FC<{ fileName: string }> = ({ fileName }) => {
const ext = fileName.split('.').pop()?.toLowerCase();
- // TypeScript/TSX
if (ext === 'ts' || ext === 'tsx') {
return (
@@ -53,7 +91,6 @@ const FileIcon: React.FC<{ fileName: string }> = ({ fileName }) => {
);
}
- // JavaScript/JSX
if (ext === 'js' || ext === 'jsx') {
return (
@@ -63,7 +100,6 @@ const FileIcon: React.FC<{ fileName: string }> = ({ fileName }) => {
);
}
- // CSS/SCSS/LESS
if (ext === 'css' || ext === 'scss' || ext === 'less') {
return (
@@ -73,7 +109,6 @@ const FileIcon: React.FC<{ fileName: string }> = ({ fileName }) => {
);
}
- // JSON
if (ext === 'json') {
return (
@@ -83,7 +118,6 @@ const FileIcon: React.FC<{ fileName: string }> = ({ fileName }) => {
);
}
- // Markdown
if (ext === 'md' || ext === 'markdown') {
return (
@@ -93,7 +127,6 @@ const FileIcon: React.FC<{ fileName: string }> = ({ fileName }) => {
);
}
- // 通用文件图标
return (
@@ -154,7 +187,6 @@ const ContextMenu: React.FC<{
}> = ({ x, y, items, onClose }) => {
const menuRef = useRef(null);
- // 关闭菜单的处理
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
@@ -177,7 +209,6 @@ const ContextMenu: React.FC<{
};
}, [onClose]);
- // 调整菜单位置,防止超出视口
const adjustedPosition = useMemo(() => {
const menuWidth = 220;
const menuHeight = items.length * 28 + 8;
@@ -225,7 +256,7 @@ const ContextMenu: React.FC<{
>
{item.label}
{item.shortcut && (
- {item.shortcut}
+ {item.shortcut}
)}
);
@@ -390,34 +421,42 @@ const TreeNode: React.FC<{
onSymbolClick?: (line: number) => void;
// 右键菜单
onContextMenu?: (e: React.MouseEvent, node: FileTreeNode) => void;
- clipboard?: { node: FileTreeNode; operation: 'cut' | 'copy' } | null;
+ clipboard?: { node: FileTreeNode; nodes: FileTreeNode[]; operation: 'cut' | 'copy' } | null;
// 内联编辑
inlineEdit?: { path: string; type: 'rename' | 'newFile' | 'newFolder'; parentPath?: string; initialValue?: string } | null;
onInlineEditSubmit?: (value: string) => void;
onInlineEditCancel?: () => void;
+ // 多选
+ selectedPaths: Set;
+ focusedPath: string | null;
+ onNodeClick: (e: React.MouseEvent, node: FileTreeNode) => void;
+ // 拖拽
+ dragState: { dragging: boolean; sourcePaths: Set; overPath: string | null };
+ onDragStart: (e: React.DragEvent, node: FileTreeNode) => void;
+ onDragOver: (e: React.DragEvent, node: FileTreeNode) => void;
+ onDragLeave: (e: React.DragEvent) => void;
+ onDrop: (e: React.DragEvent, node: FileTreeNode) => void;
+ onDragEnd: () => void;
}> = ({
node, level, currentFile, onFileSelect, expandedDirs, onToggleDir,
outlineSymbols, symbolsExpanded, onToggleSymbols, activeSymbolKey,
expandedSymbols, onToggleSymbol, onSymbolClick, onContextMenu, clipboard,
inlineEdit, onInlineEditSubmit, onInlineEditCancel,
+ selectedPaths, focusedPath, onNodeClick,
+ dragState, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd,
}) => {
const isDirectory = node.type === 'directory';
const isExpanded = expandedDirs.has(node.path);
- const isSelected = currentFile === node.path;
const isCurrentFile = currentFile === node.path;
+ const isSelected = selectedPaths.has(node.path);
+ const isFocused = focusedPath === node.path;
const hasSymbols = isCurrentFile && outlineSymbols && outlineSymbols.length > 0;
- const isCut = clipboard?.node.path === node.path && clipboard.operation === 'cut';
+ const isCut = clipboard?.operation === 'cut' && clipboard.nodes.some(n => n.path === node.path);
+ const isDragOver = dragState.overPath === node.path && isDirectory;
+ const isDragSource = dragState.dragging && dragState.sourcePaths.has(node.path);
- const handleClick = () => {
- if (isDirectory) {
- onToggleDir(node.path);
- } else {
- onFileSelect(node.path);
- // 如果已经是当前文件,切换符号展开状态
- if (isCurrentFile && hasSymbols) {
- onToggleSymbols();
- }
- }
+ const handleClick = (e: React.MouseEvent) => {
+ onNodeClick(e, node);
};
const handleChevronClick = (e: React.MouseEvent) => {
@@ -437,13 +476,46 @@ const TreeNode: React.FC<{
}
};
+ const handleDragStart = (e: React.DragEvent) => {
+ onDragStart(e, node);
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ onDragOver(e, node);
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ onDragLeave(e);
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ onDrop(e, node);
+ };
+
+ const nodeClasses = [
+ styles.treeNode,
+ isCurrentFile ? styles.currentFile : '',
+ isSelected ? styles.multiSelected : '',
+ isFocused ? styles.focused : '',
+ isCut ? styles.cutNode : '',
+ isDragOver ? styles.dragOver : '',
+ isDragSource ? styles.dragging : '',
+ ].filter(Boolean).join(' ');
+
return (
<>
{(isDirectory || hasSymbols) ? (
@@ -509,6 +581,15 @@ const TreeNode: React.FC<{
inlineEdit={inlineEdit}
onInlineEditSubmit={onInlineEditSubmit}
onInlineEditCancel={onInlineEditCancel}
+ selectedPaths={selectedPaths}
+ focusedPath={focusedPath}
+ onNodeClick={onNodeClick}
+ dragState={dragState}
+ onDragStart={onDragStart}
+ onDragOver={onDragOver}
+ onDragLeave={onDragLeave}
+ onDrop={onDrop}
+ onDragEnd={onDragEnd}
/>
))}
@@ -536,8 +617,7 @@ const TreeNode: React.FC<{
/**
* FileTree 组件
- * 显示项目文件树,支持展开/折叠目录,点击选择文件
- * 当前文件支持展开显示代码符号(函数、类、接口等)
+ * 支持多选、键盘导航、拖拽移动、批量操作
*/
export const FileTree: React.FC = ({
projectPath,
@@ -567,9 +647,10 @@ export const FileTree: React.FC = ({
targetType: 'file' | 'directory' | 'blank';
} | null>(null);
- // 剪贴板状态
+ // 剪贴板状态(支持多文件)
const [clipboard, setClipboard] = useState<{
node: FileTreeNode;
+ nodes: FileTreeNode[];
operation: 'cut' | 'copy';
} | null>(null);
@@ -581,6 +662,37 @@ export const FileTree: React.FC = ({
initialValue?: string;
} | null>(null);
+ // ==================== 多选状态 ====================
+ const [selectedPaths, setSelectedPaths] = useState>(new Set());
+ const [focusedPath, setFocusedPath] = useState(null);
+ const [lastClickedPath, setLastClickedPath] = useState(null);
+
+ // ==================== 拖拽状态 ====================
+ const [dragState, setDragState] = useState<{
+ dragging: boolean;
+ sourcePaths: Set;
+ overPath: string | null;
+ }>({ dragging: false, sourcePaths: new Set(), overPath: null });
+ const dragExpandTimerRef = useRef | null>(null);
+
+ // Container ref(用于焦点和键盘事件)
+ const treeContainerRef = useRef(null);
+
+ // 扁平化的可见节点列表
+ const flatNodes = useMemo(() => {
+ if (!tree) return [];
+ return flattenVisibleNodes(tree, expandedDirs);
+ }, [tree, expandedDirs]);
+
+ // 所有可见节点的 path 到 FileTreeNode 映射
+ const nodeByPath = useMemo(() => {
+ const map = new Map();
+ for (const { node } of flatNodes) {
+ map.set(node.path, node);
+ }
+ return map;
+ }, [flatNodes]);
+
// 当 symbols 变化时,重置默认展开状态
useEffect(() => {
if (outlineSymbols && outlineSymbols !== prevSymbolsRef.current) {
@@ -618,48 +730,9 @@ export const FileTree: React.FC = ({
});
};
- // 加载文件树
- useEffect(() => {
- const fetchTree = async () => {
- setLoading(true);
- setError(null);
-
- try {
- const response = await fetch(`/api/files/tree?root=${encodeURIComponent(projectPath)}&path=.&depth=3`);
+ // ==================== 加载文件树 ====================
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.error || '加载文件树失败');
- }
-
- const data = await response.json();
- setTree(data);
- } catch (err) {
- console.error('[FileTree] 加载失败:', err);
- setError(err instanceof Error ? err.message : '未知错误');
- } finally {
- setLoading(false);
- }
- };
-
- fetchTree();
- }, [projectPath]);
-
- // 切换目录展开/折叠
- const handleToggleDir = (path: string) => {
- setExpandedDirs((prev) => {
- const next = new Set(prev);
- if (next.has(path)) {
- next.delete(path);
- } else {
- next.add(path);
- }
- return next;
- });
- };
-
- // 刷新文件树
- const refreshTree = async () => {
+ const fetchTree = useCallback(async () => {
setLoading(true);
setError(null);
try {
@@ -676,80 +749,355 @@ export const FileTree: React.FC = ({
} finally {
setLoading(false);
}
- };
-
- // ==================== 右键菜单操作函数 ====================
-
- const handleCut = (node: FileTreeNode) => {
- setClipboard({ node, operation: 'cut' });
- };
+ }, [projectPath]);
- const handleCopy = (node: FileTreeNode) => {
- setClipboard({ node, operation: 'copy' });
- };
+ useEffect(() => {
+ fetchTree();
+ }, [fetchTree]);
- const handlePaste = async (targetNode: FileTreeNode) => {
- if (!clipboard) return;
-
- const destDir = targetNode.type === 'directory' ? targetNode.path : targetNode.path.substring(0, targetNode.path.lastIndexOf('/'));
- const destPath = `${destDir}/${clipboard.node.name}`;
-
- try {
- if (clipboard.operation === 'cut') {
- const response = await fetch('/api/files/move', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ sourcePath: clipboard.node.path, destPath, root: projectPath }),
- });
-
- if (!response.ok) {
- const error = await response.json();
- alert(`移动失败: ${error.error}`);
- return;
+ // 切换目录展开/折叠
+ const handleToggleDir = useCallback((path: string) => {
+ setExpandedDirs((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ }, []);
+
+ // ==================== 节点点击处理(多选逻辑) ====================
+
+ const handleNodeClick = useCallback((e: React.MouseEvent, node: FileTreeNode) => {
+ const isCtrl = e.ctrlKey || e.metaKey;
+ const isShift = e.shiftKey;
+
+ if (isCtrl) {
+ // Ctrl+Click: 切换选中状态
+ setSelectedPaths(prev => {
+ const next = new Set(prev);
+ if (next.has(node.path)) {
+ next.delete(node.path);
+ } else {
+ next.add(node.path);
+ }
+ return next;
+ });
+ setFocusedPath(node.path);
+ setLastClickedPath(node.path);
+ } else if (isShift && lastClickedPath) {
+ // Shift+Click: 范围选择
+ const startIdx = flatNodes.findIndex(n => n.node.path === lastClickedPath);
+ const endIdx = flatNodes.findIndex(n => n.node.path === node.path);
+
+ if (startIdx !== -1 && endIdx !== -1) {
+ const minIdx = Math.min(startIdx, endIdx);
+ const maxIdx = Math.max(startIdx, endIdx);
+ const rangePaths = new Set();
+ for (let i = minIdx; i <= maxIdx; i++) {
+ rangePaths.add(flatNodes[i].node.path);
}
-
- setClipboard(null);
+ setSelectedPaths(rangePaths);
+ }
+ setFocusedPath(node.path);
+ } else {
+ // 普通点击:清除多选,选中当前
+ setSelectedPaths(new Set([node.path]));
+ setFocusedPath(node.path);
+ setLastClickedPath(node.path);
+
+ // 原有的文件打开/目录切换逻辑
+ if (node.type === 'directory') {
+ handleToggleDir(node.path);
} else {
- const response = await fetch('/api/files/copy', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ sourcePath: clipboard.node.path, destPath, root: projectPath }),
- });
-
- if (!response.ok) {
- const error = await response.json();
- alert(`复制失败: ${error.error}`);
- return;
+ onFileSelect(node.path);
+ // 如果已经是当前文件,切换符号展开状态
+ if (currentFile === node.path && outlineSymbols && outlineSymbols.length > 0) {
+ handleToggleSymbols();
}
}
-
- await refreshTree();
- } catch (err) {
- console.error('[FileTree] 粘贴操作失败:', err);
- alert('粘贴失败');
}
- };
+ }, [flatNodes, lastClickedPath, currentFile, outlineSymbols, onFileSelect, handleToggleDir]);
+
+ // ==================== 键盘导航 ====================
+
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ // 如果正在内联编辑,不处理快捷键
+ if (inlineEdit) return;
+
+ const currentIdx = focusedPath ? flatNodes.findIndex(n => n.node.path === focusedPath) : -1;
+
+ switch (e.key) {
+ case 'ArrowDown': {
+ e.preventDefault();
+ const nextIdx = currentIdx < flatNodes.length - 1 ? currentIdx + 1 : 0;
+ const nextNode = flatNodes[nextIdx];
+ if (nextNode) {
+ setFocusedPath(nextNode.node.path);
+ if (!e.shiftKey) {
+ setSelectedPaths(new Set([nextNode.node.path]));
+ setLastClickedPath(nextNode.node.path);
+ } else {
+ // Shift+Arrow: 扩展选择
+ setSelectedPaths(prev => {
+ const next = new Set(prev);
+ next.add(nextNode.node.path);
+ return next;
+ });
+ }
+ // 滚动到可见区域
+ const el = treeContainerRef.current?.querySelector(`[data-path="${CSS.escape(nextNode.node.path)}"]`);
+ el?.scrollIntoView({ block: 'nearest' });
+ }
+ break;
+ }
+ case 'ArrowUp': {
+ e.preventDefault();
+ const prevIdx = currentIdx > 0 ? currentIdx - 1 : flatNodes.length - 1;
+ const prevNode = flatNodes[prevIdx];
+ if (prevNode) {
+ setFocusedPath(prevNode.node.path);
+ if (!e.shiftKey) {
+ setSelectedPaths(new Set([prevNode.node.path]));
+ setLastClickedPath(prevNode.node.path);
+ } else {
+ setSelectedPaths(prev => {
+ const next = new Set(prev);
+ next.add(prevNode.node.path);
+ return next;
+ });
+ }
+ const el = treeContainerRef.current?.querySelector(`[data-path="${CSS.escape(prevNode.node.path)}"]`);
+ el?.scrollIntoView({ block: 'nearest' });
+ }
+ break;
+ }
+ case 'ArrowRight': {
+ e.preventDefault();
+ if (focusedPath) {
+ const node = nodeByPath.get(focusedPath);
+ if (node?.type === 'directory') {
+ if (!expandedDirs.has(node.path)) {
+ handleToggleDir(node.path);
+ } else if (node.children && node.children.length > 0) {
+ // 已展开:焦点移到第一个子节点
+ const childIdx = flatNodes.findIndex(n => n.node.path === node.children![0].path);
+ if (childIdx !== -1) {
+ setFocusedPath(flatNodes[childIdx].node.path);
+ setSelectedPaths(new Set([flatNodes[childIdx].node.path]));
+ setLastClickedPath(flatNodes[childIdx].node.path);
+ }
+ }
+ }
+ }
+ break;
+ }
+ case 'ArrowLeft': {
+ e.preventDefault();
+ if (focusedPath) {
+ const node = nodeByPath.get(focusedPath);
+ if (node?.type === 'directory' && expandedDirs.has(node.path)) {
+ // 折叠当前目录
+ handleToggleDir(node.path);
+ } else {
+ // 跳到父目录
+ const parentPath = getParentPath(focusedPath);
+ if (parentPath && nodeByPath.has(parentPath)) {
+ setFocusedPath(parentPath);
+ setSelectedPaths(new Set([parentPath]));
+ setLastClickedPath(parentPath);
+ }
+ }
+ }
+ break;
+ }
+ case 'Enter': {
+ e.preventDefault();
+ if (focusedPath) {
+ const node = nodeByPath.get(focusedPath);
+ if (node) {
+ if (node.type === 'directory') {
+ handleToggleDir(node.path);
+ } else {
+ onFileSelect(node.path);
+ }
+ }
+ }
+ break;
+ }
+ case 'Delete':
+ case 'Backspace': {
+ if (e.key === 'Backspace' && !e.ctrlKey && !e.metaKey) break; // Backspace 需要 Ctrl
+ e.preventDefault();
+ const pathsToDelete = selectedPaths.size > 0 ? selectedPaths : (focusedPath ? new Set([focusedPath]) : new Set());
+ if (pathsToDelete.size > 0) {
+ handleBatchDelete(pathsToDelete);
+ }
+ break;
+ }
+ case 'F2': {
+ e.preventDefault();
+ if (focusedPath) {
+ const node = nodeByPath.get(focusedPath);
+ if (node) {
+ handleRename(node);
+ }
+ }
+ break;
+ }
+ case 'Escape': {
+ e.preventDefault();
+ if (inlineEdit) {
+ setInlineEdit(null);
+ } else {
+ setSelectedPaths(new Set());
+ }
+ break;
+ }
+ case 'a':
+ case 'A': {
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ // 全选当前可见节点
+ const allPaths = new Set(flatNodes.map(n => n.node.path));
+ setSelectedPaths(allPaths);
+ }
+ break;
+ }
+ case 'c':
+ case 'C': {
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ handleCopySelected();
+ }
+ break;
+ }
+ case 'x':
+ case 'X': {
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ handleCutSelected();
+ }
+ break;
+ }
+ case 'v':
+ case 'V': {
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ if (focusedPath) {
+ const node = nodeByPath.get(focusedPath);
+ if (node) {
+ handlePaste(node);
+ }
+ }
+ }
+ break;
+ }
+ }
+ }, [flatNodes, focusedPath, expandedDirs, nodeByPath, inlineEdit, selectedPaths, onFileSelect, handleToggleDir]);
+
+ // ==================== 文件操作函数 ====================
+
+ const handleCut = useCallback((node: FileTreeNode) => {
+ const nodes = selectedPaths.has(node.path) && selectedPaths.size > 1
+ ? Array.from(selectedPaths).map(p => nodeByPath.get(p)).filter((n): n is FileTreeNode => !!n)
+ : [node];
+ setClipboard({ node, nodes, operation: 'cut' });
+ }, [selectedPaths, nodeByPath]);
+
+ const handleCopy = useCallback((node: FileTreeNode) => {
+ const nodes = selectedPaths.has(node.path) && selectedPaths.size > 1
+ ? Array.from(selectedPaths).map(p => nodeByPath.get(p)).filter((n): n is FileTreeNode => !!n)
+ : [node];
+ setClipboard({ node, nodes, operation: 'copy' });
+ }, [selectedPaths, nodeByPath]);
+
+ const handleCutSelected = useCallback(() => {
+ const paths = selectedPaths.size > 0 ? selectedPaths : (focusedPath ? new Set([focusedPath]) : new Set());
+ if (paths.size === 0) return;
+ const nodes = Array.from(paths).map(p => nodeByPath.get(p)).filter((n): n is FileTreeNode => !!n);
+ if (nodes.length > 0) {
+ setClipboard({ node: nodes[0], nodes, operation: 'cut' });
+ }
+ }, [selectedPaths, focusedPath, nodeByPath]);
+
+ const handleCopySelected = useCallback(() => {
+ const paths = selectedPaths.size > 0 ? selectedPaths : (focusedPath ? new Set([focusedPath]) : new Set());
+ if (paths.size === 0) return;
+ const nodes = Array.from(paths).map(p => nodeByPath.get(p)).filter((n): n is FileTreeNode => !!n);
+ if (nodes.length > 0) {
+ setClipboard({ node: nodes[0], nodes, operation: 'copy' });
+ }
+ }, [selectedPaths, focusedPath, nodeByPath]);
+
+ const handlePaste = useCallback(async (targetNode: FileTreeNode) => {
+ if (!clipboard || clipboard.nodes.length === 0) return;
+
+ const destDir = targetNode.type === 'directory' ? targetNode.path : getParentPath(targetNode.path);
+ const errors: string[] = [];
+
+ for (const srcNode of clipboard.nodes) {
+ const destPath = `${destDir}/${srcNode.name}`;
+ try {
+ if (clipboard.operation === 'cut') {
+ const response = await fetch('/api/files/move', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sourcePath: srcNode.path, destPath, root: projectPath }),
+ });
+ if (!response.ok) {
+ const error = await response.json();
+ errors.push(`${srcNode.name}: ${error.error}`);
+ }
+ } else {
+ const response = await fetch('/api/files/copy', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sourcePath: srcNode.path, destPath, root: projectPath }),
+ });
+ if (!response.ok) {
+ const error = await response.json();
+ errors.push(`${srcNode.name}: ${error.error}`);
+ }
+ }
+ } catch (err) {
+ errors.push(`${srcNode.name}: 操作失败`);
+ }
+ }
+
+ if (clipboard.operation === 'cut') {
+ setClipboard(null);
+ }
+
+ if (errors.length > 0) {
+ alert(`部分操作失败:\n${errors.join('\n')}`);
+ }
- const handleRename = (node: FileTreeNode) => {
+ await fetchTree();
+ }, [clipboard, projectPath, fetchTree]);
+
+ const handleRename = useCallback((node: FileTreeNode) => {
setInlineEdit({
path: node.path,
type: 'rename',
initialValue: node.name,
});
- };
+ }, []);
- const handleDelete = async (node: FileTreeNode) => {
+ const handleDelete = useCallback(async (node: FileTreeNode) => {
if (!window.confirm(`确定删除 "${node.name}"?`)) return;
-
+
try {
const response = await fetch('/api/files/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: node.path, root: projectPath }),
});
-
+
if (response.ok) {
- await refreshTree();
+ await fetchTree();
} else {
const error = await response.json();
alert(`删除失败: ${error.error}`);
@@ -758,28 +1106,66 @@ export const FileTree: React.FC = ({
console.error('[FileTree] 删除失败:', err);
alert('删除失败');
}
- };
+ }, [projectPath, fetchTree]);
+
+ const handleBatchDelete = useCallback(async (paths: Set) => {
+ const nodeNames = Array.from(paths)
+ .map(p => nodeByPath.get(p)?.name || p)
+ .slice(0, 10);
+ const suffix = paths.size > 10 ? `\n...等 ${paths.size} 个文件` : '';
+ const msg = `确定删除以下 ${paths.size} 个文件/文件夹?\n\n${nodeNames.join('\n')}${suffix}`;
+
+ if (!window.confirm(msg)) return;
+
+ const errors: string[] = [];
+ for (const path of paths) {
+ try {
+ const response = await fetch('/api/files/delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path, root: projectPath }),
+ });
+ if (!response.ok) {
+ const error = await response.json();
+ errors.push(`${path}: ${error.error}`);
+ }
+ } catch (err) {
+ errors.push(`${path}: 操作失败`);
+ }
+ }
+
+ if (errors.length > 0) {
+ alert(`部分删除失败:\n${errors.join('\n')}`);
+ }
- const handleNewFile = (node: FileTreeNode) => {
- const parentPath = node.type === 'directory' ? node.path : node.path.substring(0, node.path.lastIndexOf('/'));
+ setSelectedPaths(new Set());
+ await fetchTree();
+ }, [projectPath, nodeByPath, fetchTree]);
+
+ const handleNewFile = useCallback((node: FileTreeNode) => {
+ const parentPath = node.type === 'directory' ? node.path : getParentPath(node.path);
setInlineEdit({ path: '', type: 'newFile', parentPath });
- };
+ }, []);
- const handleNewFolder = (node: FileTreeNode) => {
- const parentPath = node.type === 'directory' ? node.path : node.path.substring(0, node.path.lastIndexOf('/'));
+ const handleNewFolder = useCallback((node: FileTreeNode) => {
+ const parentPath = node.type === 'directory' ? node.path : getParentPath(node.path);
setInlineEdit({ path: '', type: 'newFolder', parentPath });
- };
-
- const handleCopyPath = async (node: FileTreeNode, relative: boolean) => {
- const pathStr = relative ? node.path : `${projectPath}/${node.path}`;
+ }, []);
+
+ const handleCopyPath = useCallback(async (node: FileTreeNode, relative: boolean) => {
+ // 多选时复制所有路径
+ const paths = selectedPaths.has(node.path) && selectedPaths.size > 1
+ ? Array.from(selectedPaths)
+ : [node.path];
+ const result = paths.map(p => relative ? p : `${projectPath}/${p}`).join('\n');
try {
- await navigator.clipboard.writeText(pathStr);
+ await navigator.clipboard.writeText(result);
} catch (err) {
console.error('[FileTree] 复制路径失败:', err);
}
- };
+ }, [selectedPaths, projectPath]);
- const handleReveal = async (node: FileTreeNode) => {
+ const handleReveal = useCallback(async (node: FileTreeNode) => {
try {
await fetch('/api/files/reveal', {
method: 'POST',
@@ -789,9 +1175,9 @@ export const FileTree: React.FC = ({
} catch (err) {
console.error('[FileTree] Reveal失败:', err);
}
- };
+ }, [projectPath]);
- const handleInlineEditSubmit = async (value: string) => {
+ const handleInlineEditSubmit = useCallback(async (value: string) => {
if (!inlineEdit || !value.trim()) {
setInlineEdit(null);
return;
@@ -801,13 +1187,13 @@ export const FileTree: React.FC = ({
if (inlineEdit.type === 'rename') {
const oldPath = inlineEdit.path;
const newPath = oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + value;
-
+
const response = await fetch('/api/files/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPath, newPath, root: projectPath }),
});
-
+
if (!response.ok) {
const error = await response.json();
alert(`重命名失败: ${error.error}`);
@@ -815,13 +1201,13 @@ export const FileTree: React.FC = ({
}
} else if (inlineEdit.type === 'newFile') {
const filePath = `${inlineEdit.parentPath}/${value}`;
-
+
const response = await fetch('/api/files/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: filePath, content: '', root: projectPath }),
});
-
+
if (!response.ok) {
const error = await response.json();
alert(`创建文件失败: ${error.error}`);
@@ -829,82 +1215,207 @@ export const FileTree: React.FC = ({
}
} else if (inlineEdit.type === 'newFolder') {
const dirPath = `${inlineEdit.parentPath}/${value}`;
-
+
const response = await fetch('/api/files/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: dirPath, root: projectPath }),
});
-
+
if (!response.ok) {
const error = await response.json();
alert(`创建文件夹失败: ${error.error}`);
return;
}
}
-
+
setInlineEdit(null);
- await refreshTree();
+ await fetchTree();
} catch (err) {
console.error('[FileTree] 内联编辑失败:', err);
alert('操作失败');
}
- };
+ }, [inlineEdit, projectPath, fetchTree]);
+
+ // ==================== 拖拽处理 ====================
+
+ const handleDragStart = useCallback((e: React.DragEvent, node: FileTreeNode) => {
+ // 确定要拖的文件集合
+ const sourcePaths = selectedPaths.has(node.path) && selectedPaths.size > 1
+ ? new Set(selectedPaths)
+ : new Set([node.path]);
+
+ setDragState({ dragging: true, sourcePaths, overPath: null });
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', Array.from(sourcePaths).join('\n'));
+ }, [selectedPaths]);
+
+ const handleDragOver = useCallback((e: React.DragEvent, node: FileTreeNode) => {
+ if (!dragState.dragging) return;
+
+ // 不能拖到自身或自身的子目录
+ for (const srcPath of dragState.sourcePaths) {
+ if (node.path === srcPath || isDescendantOf(node.path, srcPath)) {
+ e.dataTransfer.dropEffect = 'none';
+ return;
+ }
+ }
+
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+
+ const targetDir = node.type === 'directory' ? node.path : getParentPath(node.path);
+
+ if (dragState.overPath !== targetDir) {
+ setDragState(prev => ({ ...prev, overPath: targetDir }));
+
+ // 清除之前的定时器
+ if (dragExpandTimerRef.current) {
+ clearTimeout(dragExpandTimerRef.current);
+ }
- // 获取右键菜单项
- const getContextMenuItems = (
+ // 500ms 后自动展开目标目录
+ if (node.type === 'directory' && !expandedDirs.has(node.path)) {
+ dragExpandTimerRef.current = setTimeout(() => {
+ handleToggleDir(node.path);
+ }, 500);
+ }
+ }
+ }, [dragState.dragging, dragState.sourcePaths, dragState.overPath, expandedDirs, handleToggleDir]);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ // 清除自动展开定时器
+ if (dragExpandTimerRef.current) {
+ clearTimeout(dragExpandTimerRef.current);
+ dragExpandTimerRef.current = null;
+ }
+ }, []);
+
+ const handleDrop = useCallback(async (e: React.DragEvent, node: FileTreeNode) => {
+ e.preventDefault();
+ if (dragExpandTimerRef.current) {
+ clearTimeout(dragExpandTimerRef.current);
+ dragExpandTimerRef.current = null;
+ }
+
+ if (!dragState.dragging || dragState.sourcePaths.size === 0) {
+ setDragState({ dragging: false, sourcePaths: new Set(), overPath: null });
+ return;
+ }
+
+ const destDir = node.type === 'directory' ? node.path : getParentPath(node.path);
+ const errors: string[] = [];
+
+ for (const srcPath of dragState.sourcePaths) {
+ // 不能拖到自身目录
+ if (destDir === srcPath || isDescendantOf(destDir, srcPath)) continue;
+
+ const srcName = srcPath.split('/').pop() || '';
+ const destPath = `${destDir}/${srcName}`;
+
+ // 跳过已在目标目录的文件
+ if (getParentPath(srcPath) === destDir) continue;
+
+ try {
+ const response = await fetch('/api/files/move', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sourcePath: srcPath, destPath, root: projectPath }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ errors.push(`${srcName}: ${error.error}`);
+ }
+ } catch (err) {
+ errors.push(`${srcName}: 移动失败`);
+ }
+ }
+
+ setDragState({ dragging: false, sourcePaths: new Set(), overPath: null });
+
+ if (errors.length > 0) {
+ alert(`部分移动失败:\n${errors.join('\n')}`);
+ }
+
+ await fetchTree();
+ }, [dragState, projectPath, fetchTree]);
+
+ const handleDragEnd = useCallback(() => {
+ if (dragExpandTimerRef.current) {
+ clearTimeout(dragExpandTimerRef.current);
+ dragExpandTimerRef.current = null;
+ }
+ setDragState({ dragging: false, sourcePaths: new Set(), overPath: null });
+ }, []);
+
+ // ==================== 右键菜单 ====================
+
+ const getContextMenuItems = useCallback((
targetNode: FileTreeNode | null,
targetType: 'file' | 'directory' | 'blank'
): ContextMenuItem[] => {
- if (targetType === 'file' && targetNode) {
+ // 计算当前操作的节点集合
+ const isMultiSelection = targetNode && selectedPaths.has(targetNode.path) && selectedPaths.size > 1;
+ const selCount = isMultiSelection ? selectedPaths.size : 0;
+
+ if (targetType === 'blank') {
return [
- { label: 'Cut', shortcut: 'Ctrl+X', onClick: () => handleCut(targetNode) },
- { label: 'Copy', shortcut: 'Ctrl+C', onClick: () => handleCopy(targetNode) },
- { label: 'Paste', shortcut: 'Ctrl+V', disabled: !clipboard, onClick: () => handlePaste(targetNode) },
- { divider: true },
- { label: 'Copy Path', onClick: () => handleCopyPath(targetNode, false) },
- { label: 'Copy Relative Path', onClick: () => handleCopyPath(targetNode, true) },
- { divider: true },
- { label: 'Rename', shortcut: 'F2', onClick: () => handleRename(targetNode) },
- { label: 'Delete', shortcut: 'Delete', onClick: () => handleDelete(targetNode) },
- { divider: true },
- { label: 'New File...', onClick: () => handleNewFile(targetNode) },
- { label: 'New Folder...', onClick: () => handleNewFolder(targetNode) },
- { divider: true },
- { label: 'Reveal in File Explorer', onClick: () => handleReveal(targetNode) },
+ { label: '新建文件...', onClick: () => handleNewFile({ name: '', path: '.', type: 'directory' }) },
+ { label: '新建文件夹...', onClick: () => handleNewFolder({ name: '', path: '.', type: 'directory' }) },
+ { divider: true } as ContextMenuItem,
+ { label: '粘贴', shortcut: 'Ctrl+V', disabled: !clipboard, onClick: () => handlePaste({ name: '', path: '.', type: 'directory' }) },
];
}
-
- if (targetType === 'directory' && targetNode) {
+
+ if (isMultiSelection && targetNode) {
+ // 多选右键菜单
return [
- { label: 'Cut', shortcut: 'Ctrl+X', onClick: () => handleCut(targetNode) },
- { label: 'Copy', shortcut: 'Ctrl+C', onClick: () => handleCopy(targetNode) },
- { label: 'Paste', shortcut: 'Ctrl+V', disabled: !clipboard, onClick: () => handlePaste(targetNode) },
- { divider: true },
- { label: 'Copy Path', onClick: () => handleCopyPath(targetNode, false) },
- { label: 'Copy Relative Path', onClick: () => handleCopyPath(targetNode, true) },
- { divider: true },
- { label: 'New File...', onClick: () => handleNewFile(targetNode) },
- { label: 'New Folder...', onClick: () => handleNewFolder(targetNode) },
- { divider: true },
- { label: 'Rename', shortcut: 'F2', onClick: () => handleRename(targetNode) },
- { label: 'Delete', shortcut: 'Delete', onClick: () => handleDelete(targetNode) },
- { divider: true },
- { label: 'Reveal in File Explorer', onClick: () => handleReveal(targetNode) },
+ { label: `剪切 ${selCount} 个项目`, shortcut: 'Ctrl+X', onClick: () => handleCut(targetNode) },
+ { label: `复制 ${selCount} 个项目`, shortcut: 'Ctrl+C', onClick: () => handleCopy(targetNode) },
+ { label: '粘贴', shortcut: 'Ctrl+V', disabled: !clipboard, onClick: () => handlePaste(targetNode) },
+ { divider: true } as ContextMenuItem,
+ { label: `复制 ${selCount} 个路径`, onClick: () => handleCopyPath(targetNode, false) },
+ { label: `复制 ${selCount} 个相对路径`, onClick: () => handleCopyPath(targetNode, true) },
+ { divider: true } as ContextMenuItem,
+ { label: `删除 ${selCount} 个项目`, shortcut: 'Delete', onClick: () => handleBatchDelete(selectedPaths) },
];
}
-
- // 空白区域右键菜单
- return [
- { label: 'New File...', onClick: () => handleNewFile({ name: '', path: '.', type: 'directory' }) },
- { label: 'New Folder...', onClick: () => handleNewFolder({ name: '', path: '.', type: 'directory' }) },
- { divider: true },
- { label: 'Paste', shortcut: 'Ctrl+V', disabled: !clipboard, onClick: () => handlePaste({ name: '', path: '.', type: 'directory' }) },
- ];
- };
- // TreeNode 右键菜单处理
- const handleTreeNodeContextMenu = (e: React.MouseEvent, node: FileTreeNode) => {
+ if (targetNode) {
+ const isFile = targetType === 'file';
+ return [
+ { label: '剪切', shortcut: 'Ctrl+X', onClick: () => handleCut(targetNode) },
+ { label: '复制', shortcut: 'Ctrl+C', onClick: () => handleCopy(targetNode) },
+ { label: '粘贴', shortcut: 'Ctrl+V', disabled: !clipboard, onClick: () => handlePaste(targetNode) },
+ { divider: true } as ContextMenuItem,
+ { label: '复制路径', onClick: () => handleCopyPath(targetNode, false) },
+ { label: '复制相对路径', onClick: () => handleCopyPath(targetNode, true) },
+ { divider: true } as ContextMenuItem,
+ ...(isFile ? [] : [
+ { label: '新建文件...', onClick: () => handleNewFile(targetNode) } as ContextMenuItem,
+ { label: '新建文件夹...', onClick: () => handleNewFolder(targetNode) } as ContextMenuItem,
+ { divider: true } as ContextMenuItem,
+ ]),
+ { label: '重命名', shortcut: 'F2', onClick: () => handleRename(targetNode) },
+ { label: '删除', shortcut: 'Delete', onClick: () => handleDelete(targetNode) },
+ { divider: true } as ContextMenuItem,
+ { label: '在文件管理器中显示', onClick: () => handleReveal(targetNode) },
+ ];
+ }
+
+ return [];
+ }, [selectedPaths, clipboard, handleCut, handleCopy, handlePaste, handleCopyPath, handleRename, handleDelete, handleBatchDelete, handleNewFile, handleNewFolder, handleReveal]);
+
+ // 右键菜单处理
+ const handleTreeNodeContextMenu = useCallback((e: React.MouseEvent, node: FileTreeNode) => {
+ // 如果右键的节点不在已选中列表中,先选中它
+ if (!selectedPaths.has(node.path)) {
+ setSelectedPaths(new Set([node.path]));
+ setFocusedPath(node.path);
+ setLastClickedPath(node.path);
+ }
+
setContextMenu({
visible: true,
x: e.clientX,
@@ -912,10 +1423,9 @@ export const FileTree: React.FC = ({
targetNode: node,
targetType: node.type,
});
- };
+ }, [selectedPaths]);
- // 空白区域右键菜单处理
- const handleContainerContextMenu = (e: React.MouseEvent) => {
+ const handleContainerContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({
visible: true,
@@ -924,9 +1434,25 @@ export const FileTree: React.FC = ({
targetNode: null,
targetType: 'blank',
});
- };
+ }, []);
+
+ // ==================== 批量操作工具栏 ====================
+
+ const handleClearSelection = useCallback(() => {
+ setSelectedPaths(new Set());
+ }, []);
+
+ const handleBatchCopyPaths = useCallback(async () => {
+ const paths = Array.from(selectedPaths).join('\n');
+ try {
+ await navigator.clipboard.writeText(paths);
+ } catch (err) {
+ console.error('[FileTree] 复制路径失败:', err);
+ }
+ }, [selectedPaths]);
+
+ // ==================== 渲染 ====================
- // 加载中
if (loading) {
return (
@@ -941,7 +1467,6 @@ export const FileTree: React.FC
= ({
);
}
- // 错误状态
if (error) {
return (
@@ -959,12 +1484,14 @@ export const FileTree: React.FC
= ({
);
}
- // 正常显示
return (
-
{tree && (
= ({
inlineEdit={inlineEdit}
onInlineEditSubmit={handleInlineEditSubmit}
onInlineEditCancel={() => setInlineEdit(null)}
+ selectedPaths={selectedPaths}
+ focusedPath={focusedPath}
+ onNodeClick={handleNodeClick}
+ dragState={dragState}
+ onDragStart={handleDragStart}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ onDragEnd={handleDragEnd}
/>
)}
+ {/* 多选批量操作工具栏 */}
+ {selectedPaths.size > 1 && (
+
+
+ 已选 {selectedPaths.size} 项
+
+
+
+
+
+
+
+
+
handleBatchDelete(selectedPaths)}
+ title="删除选中项"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
{/* 右键菜单 */}
{contextMenu && contextMenu.visible && (
{`📋 ${t('input.permPlan')}`}
+
fileInputRef.current?.click()} title={t('input.attach')}>
diff --git a/src/web/client/src/components/Message.tsx b/src/web/client/src/components/Message.tsx
index 748691e0..b892dd2f 100644
--- a/src/web/client/src/components/Message.tsx
+++ b/src/web/client/src/components/Message.tsx
@@ -336,7 +336,7 @@ export function Message({
gap: '8px',
}}>
🎨
- {t('message.designImage', { name: item.projectName })}
+ {item.title || 'Generated Image'}
void;
+}) {
+ const { t } = useLanguage();
+ return (
+
+
e.stopPropagation()}>
+
+
{t('plugins.skills.viewContent')}: {skillName}
+
+ {t('plugins.skills.closeModal')}
+
+
+
+
+
+ );
+}
+
+/**
+ * Skills Tab 组件
+ */
+function SkillsTab({
+ skills,
+ onViewContent,
+ onDelete,
+ onToggle,
+}: {
+ skills: SkillInfo[];
+ onViewContent: (name: string) => void;
+ onDelete: (name: string, source: string) => void;
+ onToggle: (name: string, enabled: boolean) => void;
+}) {
+ const { t } = useLanguage();
+ const [searchQuery, setSearchQuery] = useState('');
+ const [sourceFilter, setSourceFilter] = useState('all');
+ const [expandedSkill, setExpandedSkill] = useState(null);
+
+ // 过滤 skills
+ const filteredSkills = useMemo(() => {
+ let result = skills;
+
+ // 按来源过滤
+ if (sourceFilter !== 'all') {
+ result = result.filter((s) => s.source === sourceFilter);
+ }
+
+ // 按搜索关键词过滤
+ if (searchQuery) {
+ const q = searchQuery.toLowerCase();
+ result = result.filter(
+ (s) =>
+ s.displayName.toLowerCase().includes(q) ||
+ s.name.toLowerCase().includes(q) ||
+ s.description.toLowerCase().includes(q)
+ );
+ }
+
+ return result;
+ }, [skills, sourceFilter, searchQuery]);
+
+ if (skills.length === 0) {
+ return (
+
+
{t('plugins.skills.title')}
+
+
{t('plugins.skills.empty')}
+
+
+ );
+ }
+
+ return (
+
+
{t('plugins.skills.title')}
+
+ {/* 搜索框和筛选器 */}
+
+
+ setSearchQuery(e.target.value)}
+ placeholder={t('plugins.skills.search')}
+ className="plugins-search-input"
+ />
+
+
setSourceFilter(e.target.value as SourceFilter)}
+ >
+ {t('plugins.skills.filter.all')}
+ {t('plugins.skills.filter.plugin')}
+ {t('plugins.skills.filter.smithery')}
+ {t('plugins.skills.filter.manual')}
+
+
+
+ {/* 无搜索结果 */}
+ {filteredSkills.length === 0 && (searchQuery || sourceFilter !== 'all') && (
+
{t('plugins.skills.noResults')}
+ )}
+
+ {/* Skills 列表 */}
+
+ {filteredSkills.map((skill) => {
+ const isExpanded = expandedSkill === skill.name;
+
+ return (
+
+ {/* 卡片头部:名称 + badge + 操作按钮 */}
+
+
{skill.displayName}
+
+ {t(`plugins.skills.source.${skill.source}`)}
+
+ {skill.version && (
+
v{skill.version}
+ )}
+ {!skill.enabled && (
+
{t('plugins.skills.detail.disabled')}
+ )}
+
+ {/* 右侧操作按钮 */}
+
+ {
+ e.stopPropagation();
+ onViewContent(skill.name);
+ }}
+ >
+ {ICONS.eye}
+
+ {skill.source !== 'plugin' && (
+ {
+ e.stopPropagation();
+ if (confirm(t('plugins.skills.actions.confirmDelete'))) {
+ onDelete(skill.name, skill.source);
+ }
+ }}
+ >
+ {ICONS.cross}
+
+ )}
+ setExpandedSkill(isExpanded ? null : skill.name)}
+ >
+ {isExpanded ? ICONS.arrowUp : ICONS.arrowDown}
+
+
+
+
+ {/* 描述(2行截断) */}
+ {skill.description && (
+
{skill.description}
+ )}
+
+ {/* 展开的详细信息 */}
+ {isExpanded && (
+
+ {skill.sourceName && (
+
+ {t('plugins.skills.detail.source')}:
+ {skill.sourceName}
+
+ )}
+ {skill.model && (
+
+ {t('plugins.skills.detail.model')}:
+ {skill.model}
+
+ )}
+ {skill.allowedTools && skill.allowedTools.length > 0 && (
+
+ {t('plugins.skills.detail.tools')}:
+ {skill.allowedTools.join(', ')}
+
+ )}
+ {skill.argumentHint && (
+
+ {t('plugins.skills.detail.argumentHint')}:
+ {skill.argumentHint}
+
+ )}
+ {skill.author && (
+
+ {t('plugins.skills.detail.author')}:
+ {skill.author}
+
+ )}
+
+ {t('plugins.skills.detail.path')}:
+ {skill.path}
+
+
+ {/* 展开区域的操作按钮 */}
+
+ {
+ e.stopPropagation();
+ onToggle(skill.name, !skill.enabled);
+ }}
+ >
+ {skill.enabled ? t('plugins.skills.actions.disable') : t('plugins.skills.actions.enable')}
+
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
/**
* Discover Tab 组件
*/
@@ -291,9 +544,9 @@ function DiscoverTab({
}
/**
- * Installed Tab 组件
+ * Plugins Tab 组件 (原 InstalledTab)
*/
-function InstalledTab({
+function PluginsTab({
plugins,
onUninstall,
onViewDetails,
@@ -670,19 +923,23 @@ function AddMarketplace({
}
/**
- * 插件管理主面板
+ * Skills & Plugins 管理主面板
*/
export function PluginsPanel({ onClose, onSendMessage, addMessageHandler }: PluginsPanelProps) {
const { t } = useLanguage();
- const [currentTab, setCurrentTab] = useState('discover');
+ const [currentTab, setCurrentTab] = useState('skills');
const [viewMode, setViewMode] = useState('tabs');
- // 数据状态
+ // Plugin 数据状态
const [discoverPlugins, setDiscoverPlugins] = useState([]);
const [installedPlugins, setInstalledPlugins] = useState([]);
const [marketplaces, setMarketplaces] = useState([]);
const [errors, setErrors] = useState([]);
+ // Skills 数据状态
+ const [skills, setSkills] = useState([]);
+ const [skillContentModal, setSkillContentModal] = useState<{ name: string; content: string } | null>(null);
+
// UI 状态
const [selectedPlugins, setSelectedPlugins] = useState>(new Set());
const [loadingPlugins, setLoadingPlugins] = useState>(new Set());
@@ -700,10 +957,12 @@ export function PluginsPanel({ onClose, onSendMessage, addMessageHandler }: Plug
onSendMessage({ type: 'plugin_list' });
// 请求 marketplace 和可发现插件
onSendMessage({ type: 'plugin_discover' });
+ // 请求 skills 列表
+ onSendMessage({ type: 'skill_list' });
}
}, [onSendMessage]);
- // 监听 WebSocket 消息:plugin_list_response + plugin_discover_response
+ // 监听 WebSocket 消息
useEffect(() => {
if (!addMessageHandler) {
setLoading(false);
@@ -740,6 +999,37 @@ export function PluginsPanel({ onClose, onSendMessage, addMessageHandler }: Plug
})));
setLoading(false);
}
+
+ // Skills 列表
+ if (msg.type === 'skill_list_response') {
+ const { skills: skillList } = msg.payload;
+ setSkills(skillList || []);
+ setLoading(false);
+ }
+
+ // Skill 内容查看
+ if (msg.type === 'skill_view_response') {
+ const { name, content } = msg.payload;
+ setSkillContentModal({ name, content });
+ }
+
+ // Skill 删除
+ if (msg.type === 'skill_deleted') {
+ const { name, success } = msg.payload;
+ if (success) {
+ setSkills((prev) => prev.filter((s) => s.name !== name));
+ }
+ }
+
+ // Skill 切换启用/禁用
+ if (msg.type === 'skill_toggled') {
+ const { name, enabled, success } = msg.payload;
+ if (success) {
+ setSkills((prev) =>
+ prev.map((s) => (s.name === name ? { ...s, enabled } : s))
+ );
+ }
+ }
});
return unsubscribe;
@@ -988,11 +1278,44 @@ export function PluginsPanel({ onClose, onSendMessage, addMessageHandler }: Plug
setSelectedPlugin(null);
};
+ // Skills 操作
+ const handleViewSkillContent = useCallback((name: string) => {
+ if (onSendMessage) {
+ onSendMessage({
+ type: 'skill_view',
+ payload: { name },
+ });
+ }
+ }, [onSendMessage]);
+
+ const handleDeleteSkill = useCallback((name: string, source: string) => {
+ if (onSendMessage) {
+ onSendMessage({
+ type: 'skill_delete',
+ payload: { name, source },
+ });
+ }
+ }, [onSendMessage]);
+
+ const handleToggleSkill = useCallback((name: string, enabled: boolean) => {
+ // 乐观更新
+ setSkills((prev) =>
+ prev.map((s) => (s.name === name ? { ...s, enabled } : s))
+ );
+
+ if (onSendMessage) {
+ onSendMessage({
+ type: 'skill_toggle',
+ payload: { name, enabled },
+ });
+ }
+ }, [onSendMessage]);
+
if (loading) {
return (
-
/plugins
+ {t('plugins.title')}
{t('common.loading')}
@@ -1043,13 +1366,31 @@ export function PluginsPanel({ onClose, onSendMessage, addMessageHandler }: Plug
return (
-
/plugins
+ {t('plugins.title')}
{/* Tab 栏 */}
{/* Tab 内容 */}
+ {currentTab === 'skills' && (
+
+ )}
+
+ {currentTab === 'plugins' && (
+
+ )}
+
{currentTab === 'discover' && (
)}
- {currentTab === 'installed' && (
-
- )}
-
{currentTab === 'marketplaces' && (
)}
+
+ {/* Skill 内容查看模态框 */}
+ {skillContentModal && (
+
setSkillContentModal(null)}
+ />
+ )}
);
}
diff --git a/src/web/client/src/components/SessionSearchModal/SessionSearchModal.module.css b/src/web/client/src/components/SessionSearchModal/SessionSearchModal.module.css
index a45f082c..0d353a84 100644
--- a/src/web/client/src/components/SessionSearchModal/SessionSearchModal.module.css
+++ b/src/web/client/src/components/SessionSearchModal/SessionSearchModal.module.css
@@ -272,6 +272,12 @@
justify-content: space-between;
}
+.footerLeft {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
.newSessionBtn {
display: flex;
align-items: center;
@@ -291,6 +297,26 @@
background: var(--accent-hover);
}
+.importBtn {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 6px 12px;
+ background: transparent;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.importBtn:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border-color: var(--text-muted);
+}
+
.footerHints {
display: flex;
align-items: center;
diff --git a/src/web/client/src/components/SessionSearchModal/SessionSearchModal.tsx b/src/web/client/src/components/SessionSearchModal/SessionSearchModal.tsx
index 34e18d25..45d8ecef 100644
--- a/src/web/client/src/components/SessionSearchModal/SessionSearchModal.tsx
+++ b/src/web/client/src/components/SessionSearchModal/SessionSearchModal.tsx
@@ -31,6 +31,18 @@ const PlusIcon = () => (
);
+const DownloadIcon = () => (
+
+
+
+);
+
+const UploadIcon = () => (
+
+
+
+);
+
const ChatBubbleIcon = () => (
@@ -84,6 +96,10 @@ interface SessionSearchModalProps {
onClose: () => void;
/** 服务端搜索:输入关键词时调用,让父组件发 WebSocket 请求 */
onSearch?: (query: string) => void;
+ /** 导出会话 */
+ onSessionExport?: (id: string, format?: 'json' | 'md') => void;
+ /** 导入会话(JSON 字符串) */
+ onSessionImport?: (content: string) => void;
}
// ─── Component ───────────────────────────────────────────
@@ -98,6 +114,8 @@ export function SessionSearchModal({
onNewSession,
onClose,
onSearch,
+ onSessionExport,
+ onSessionImport,
}: SessionSearchModalProps) {
const { t } = useLanguage();
const [query, setQuery] = useState('');
@@ -107,6 +125,7 @@ export function SessionSearchModal({
const inputRef = useRef(null);
const renameRef = useRef(null);
const listRef = useRef(null);
+ const fileInputRef = useRef(null);
const searchTimerRef = useRef | null>(null);
// Reset state when opening
@@ -219,6 +238,28 @@ export function SessionSearchModal({
onClose();
}, [onNewSession, onClose]);
+ const handleExport = useCallback((e: React.MouseEvent, session: Session) => {
+ e.stopPropagation();
+ onSessionExport?.(session.id, 'json');
+ }, [onSessionExport]);
+
+ const handleImport = useCallback(() => {
+ fileInputRef.current?.click();
+ }, []);
+
+ const handleFileChange = useCallback((e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ const content = reader.result as string;
+ onSessionImport?.(content);
+ };
+ reader.readAsText(file);
+ // 重置 input 以便再次选择同一文件
+ e.target.value = '';
+ }, [onSessionImport]);
+
// Keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (editingId) return; // Let rename input handle keys
@@ -332,6 +373,13 @@ export function SessionSearchModal({
{editingId !== session.id && (
+
handleExport(e, session)}
+ title={t('sessionSearch.export')}
+ >
+
+
handleStartRename(e, session)}
@@ -359,10 +407,23 @@ export function SessionSearchModal({
{/* Footer */}
-
-
- {t('sessionSearch.newSession')}
-
+
+
+
+ {t('sessionSearch.newSession')}
+
+
+
+ {t('sessionSearch.import')}
+
+
+
↑↓ navigate
↵ select
diff --git a/src/web/client/src/contexts/ProjectContext.tsx b/src/web/client/src/contexts/ProjectContext.tsx
index d040925c..07aefa11 100644
--- a/src/web/client/src/contexts/ProjectContext.tsx
+++ b/src/web/client/src/contexts/ProjectContext.tsx
@@ -514,6 +514,7 @@ export function ProjectProvider({ children }: ProjectProviderProps) {
// 2. 尝试恢复上次选中的项目
const savedProject = loadSavedProject();
+ let projectRestored = false;
if (savedProject) {
// 验证保存的项目是否仍在列表中
const exists = projects.some(p => p && p.id === savedProject.id);
@@ -526,9 +527,11 @@ export function ProjectProvider({ children }: ProjectProviderProps) {
payload: { project, blueprint },
});
emitProjectChangeEvent(project, blueprint);
+ projectRestored = true;
} catch (error) {
console.warn('[ProjectContext] 恢复保存的项目失败,使用缓存数据:', error);
dispatch({ type: 'SET_CURRENT_PROJECT', payload: savedProject });
+ projectRestored = true;
}
} else {
// 保存的项目已不存在,清除
@@ -536,6 +539,22 @@ export function ProjectProvider({ children }: ProjectProviderProps) {
}
}
+ // 3. 如果没有恢复任何项目,使用 server 的工作目录作为默认项目
+ if (!projectRestored) {
+ try {
+ const defaultProject = await fetchCurrentProject();
+ if (defaultProject) {
+ dispatch({ type: 'SET_CURRENT_PROJECT', payload: defaultProject });
+ dispatch({ type: 'ADD_PROJECT', payload: defaultProject });
+ saveProjectToStorage(defaultProject);
+ emitProjectChangeEvent(defaultProject, null);
+ console.log('[ProjectContext] 使用默认工作目录:', defaultProject.path);
+ }
+ } catch (error) {
+ console.warn('[ProjectContext] 获取默认工作目录失败:', error);
+ }
+ }
+
dispatch({ type: 'SET_INITIALIZED', payload: true });
} catch (error: any) {
console.error('[ProjectContext] 初始化失败:', error);
@@ -546,7 +565,7 @@ export function ProjectProvider({ children }: ProjectProviderProps) {
};
initialize();
- }, [fetchRecentProjects, openProjectApi]);
+ }, [fetchRecentProjects, openProjectApi, fetchCurrentProject]);
// ========================================
// Context 值
diff --git a/src/web/client/src/hooks/useMessageHandler.ts b/src/web/client/src/hooks/useMessageHandler.ts
index 88d75069..13a291e7 100644
--- a/src/web/client/src/hooks/useMessageHandler.ts
+++ b/src/web/client/src/hooks/useMessageHandler.ts
@@ -18,6 +18,17 @@ import type { SlashCommandResult } from '../components/SlashCommandDialog';
export type Status = 'idle' | 'thinking' | 'streaming' | 'tool_executing';
export type PermissionMode = 'default' | 'bypassPermissions' | 'acceptEdits' | 'plan';
+/**
+ * API 速率限制信息
+ */
+export interface RateLimitInfo {
+ status: string;
+ utilization5h?: number;
+ utilization7d?: number;
+ resetsAt?: number;
+ rateLimitType?: string;
+}
+
/**
* 跨会话通知:当其他会话有弹窗等待时,通知当前用户
*/
@@ -45,6 +56,7 @@ interface UseMessageHandlerReturn {
setStatus: React.Dispatch
>;
contextUsage: ContextUsage | null;
compactState: CompactState;
+ rateLimitInfo: RateLimitInfo | null;
permissionRequest: PermissionRequest | null;
setPermissionRequest: React.Dispatch>;
userQuestion: UserQuestion | null;
@@ -74,6 +86,7 @@ export function useMessageHandler({
const [status, setStatus] = useState('idle');
const [contextUsage, setContextUsage] = useState(null);
const [compactState, setCompactState] = useState({ phase: 'idle' });
+ const [rateLimitInfo, setRateLimitInfo] = useState(null);
const [permissionRequest, setPermissionRequest] = useState(null);
const [userQuestion, setUserQuestion] = useState(null);
const [permissionMode, setPermissionMode] = useState('default');
@@ -334,6 +347,10 @@ export function useMessageHandler({
setContextUsage(payload as unknown as ContextUsage);
break;
+ case 'rate_limit_update':
+ setRateLimitInfo(payload as unknown as RateLimitInfo);
+ break;
+
case 'context_compact': {
const compactPayload = payload as { phase: string; savedTokens?: number; message?: string; summaryText?: string };
if (compactPayload.phase === 'start') {
@@ -949,13 +966,13 @@ export function useMessageHandler({
break;
case 'design_image_generated': {
- const designPayload = payload as { imageUrl: string; projectName: string; style: string; generatedText?: string };
+ const designPayload = payload as { imageUrl: string; title?: string; style?: string; generatedText?: string };
if (designPayload.imageUrl) {
const designContent: ChatContent = {
type: 'design_image',
imageUrl: designPayload.imageUrl,
- projectName: designPayload.projectName || '',
- style: designPayload.style || 'modern',
+ title: designPayload.title,
+ style: designPayload.style,
generatedText: designPayload.generatedText,
};
@@ -1027,6 +1044,7 @@ export function useMessageHandler({
setStatus,
contextUsage,
compactState,
+ rateLimitInfo,
permissionRequest,
setPermissionRequest,
userQuestion,
diff --git a/src/web/client/src/hooks/useSessionManager.ts b/src/web/client/src/hooks/useSessionManager.ts
index d3309085..84c762d9 100644
--- a/src/web/client/src/hooks/useSessionManager.ts
+++ b/src/web/client/src/hooks/useSessionManager.ts
@@ -46,6 +46,8 @@ interface UseSessionManagerReturn {
handleSessionRename: (id: string, name: string) => void;
handleNewSession: () => void;
handleSearchSessions: (query: string) => void;
+ handleSessionExport: (id: string, format?: 'json' | 'md') => void;
+ handleSessionImport: (content: string) => void;
}
export function useSessionManager({
@@ -131,6 +133,28 @@ export function useSessionManager({
}
break;
+ case 'session_exported': {
+ const content = payload.content as string;
+ const fmt = payload.format as string;
+ const ext = fmt === 'md' ? 'md' : 'json';
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `session-${(payload.sessionId as string).slice(0, 8)}.${ext}`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ break;
+ }
+
+ case 'session_imported':
+ if (payload.success) {
+ refreshSessions();
+ }
+ break;
+
case 'message_complete':
// 对话完成后刷新列表,更新 messageCount 和 updatedAt
refreshSessions();
@@ -219,6 +243,16 @@ export function useSessionManager({
}
}, [connected, send, currentProjectPath]);
+ // 导出会话
+ const handleSessionExport = useCallback((id: string, format: 'json' | 'md' = 'json') => {
+ send({ type: 'session_export', payload: { sessionId: id, format } });
+ }, [send]);
+
+ // 导入会话(接收 JSON 字符串)
+ const handleSessionImport = useCallback((content: string) => {
+ send({ type: 'session_import', payload: { content } });
+ }, [send]);
+
return {
sessions,
refreshSessions,
@@ -227,5 +261,7 @@ export function useSessionManager({
handleSessionRename,
handleNewSession,
handleSearchSessions,
+ handleSessionExport,
+ handleSessionImport,
};
}
diff --git a/src/web/client/src/i18n/locales.ts b/src/web/client/src/i18n/locales.ts
index 4afed5a7..65b0b005 100644
--- a/src/web/client/src/i18n/locales.ts
+++ b/src/web/client/src/i18n/locales.ts
@@ -22,7 +22,7 @@ const en: Translations = {
'settings.tab.system': 'System',
'settings.tab.importExport': 'Import/Export',
'settings.tab.mcp': 'MCP',
- 'settings.tab.plugins': 'Plugins',
+ 'settings.tab.plugins': 'Skills & Plugins',
'settings.tab.prompts': 'Prompts',
'settings.tab.about': 'About',
@@ -128,7 +128,7 @@ const en: Translations = {
'message.compactBoundaryHint': '(Ctrl+O for history)',
'message.compactSummary': 'Compact summary',
'message.compactSummaryHint': '(Ctrl+O to expand)',
- 'message.designImage': 'UI Design - {{name}}',
+ 'message.designImage': 'Generated Image',
'message.showMore': 'Show more',
'message.showLess': 'Show less',
@@ -479,31 +479,72 @@ const en: Translations = {
'importExport.close': 'Close',
// PluginsPanel
- 'plugins.title': 'Plugin Manager',
+ 'plugins.title': 'Skills & Plugins Manager',
+ 'plugins.tab.skills': 'Skills',
+ 'plugins.tab.plugins': 'Plugins',
'plugins.tab.discover': 'Discover',
- 'plugins.tab.installed': 'Installed',
'plugins.tab.marketplaces': 'Marketplaces',
'plugins.tab.errors': 'Errors',
- 'plugins.discover.title': 'Discover Plugins',
- 'plugins.discover.search': 'Search plugins...',
- 'plugins.discover.noMarketplaces': 'No marketplaces configured.',
- 'plugins.discover.addMarketplaceHint': 'Add a marketplace first with:',
- 'plugins.discover.noPlugins': 'No plugins found',
- 'plugins.discover.install': 'Install',
- 'plugins.discover.installing': 'Installing...',
+
+ // Skills Tab
+ 'plugins.skills.title': 'Manage Skills',
+ 'plugins.skills.search': 'Search skills...',
+ 'plugins.skills.filter.all': 'All',
+ 'plugins.skills.filter.plugin': 'Plugin',
+ 'plugins.skills.filter.smithery': 'Smithery',
+ 'plugins.skills.filter.manual': 'Manual',
+ 'plugins.skills.source.plugin': 'Plugin',
+ 'plugins.skills.source.smithery': 'Smithery',
+ 'plugins.skills.source.manual': 'Manual',
+ 'plugins.skills.actions.view': 'View Details',
+ 'plugins.skills.actions.delete': 'Delete',
+ 'plugins.skills.actions.enable': 'Enable',
+ 'plugins.skills.actions.disable': 'Disable',
+ 'plugins.skills.actions.confirmDelete': 'Are you sure you want to delete this skill?',
+ 'plugins.skills.empty': 'No skills available',
+ 'plugins.skills.noResults': 'No skills found matching your search',
+ 'plugins.skills.detail.model': 'Model',
+ 'plugins.skills.detail.tools': 'Allowed Tools',
+ 'plugins.skills.detail.version': 'Version',
+ 'plugins.skills.detail.path': 'Path',
+ 'plugins.skills.detail.author': 'Author',
+ 'plugins.skills.detail.source': 'Source',
+ 'plugins.skills.detail.enabled': 'Enabled',
+ 'plugins.skills.detail.disabled': 'Disabled',
+ 'plugins.skills.detail.argumentHint': 'Argument Hint',
+ 'plugins.skills.viewContent': 'Skill Content',
+ 'plugins.skills.closeModal': 'Close',
+
+ // Plugins Tab
'plugins.installed.title': 'Installed Plugins',
'plugins.installed.noPlugins': 'No plugins installed',
'plugins.installed.uninstall': 'Uninstall',
'plugins.installed.enable': 'Enable',
'plugins.installed.disable': 'Disable',
+
+ // Discover Tab
+ 'plugins.discover.title': 'Discover Skills & Plugins',
+ 'plugins.discover.search': 'Search plugins or skills...',
+ 'plugins.discover.searchSmithery': 'Search Smithery skills...',
+ 'plugins.discover.noMarketplaces': 'No marketplaces configured.',
+ 'plugins.discover.addMarketplaceHint': 'Add a marketplace first with:',
+ 'plugins.discover.noPlugins': 'No plugins found',
+ 'plugins.discover.install': 'Install',
+ 'plugins.discover.installing': 'Installing...',
+ 'plugins.discover.installed': 'Installed',
+
+ // Marketplaces Tab
'plugins.marketplaces.title': 'Marketplaces',
'plugins.marketplaces.add': 'Add Marketplace',
'plugins.marketplaces.remove': 'Remove',
'plugins.marketplaces.update': 'Update',
'plugins.marketplaces.noMarketplaces': 'No marketplaces configured',
+
+ // Errors Tab
'plugins.errors.title': 'Plugin Errors',
'plugins.errors.noErrors': 'No errors',
'plugins.errors.clear': 'Clear Errors',
+
'plugins.back': 'Back',
// OAuth Login
@@ -916,7 +957,7 @@ const en: Translations = {
// CompactMessage
'compact.blueprint': 'Blueprint: {{name}} ({{count}} modules)',
- 'compact.design': 'Design: {{name}} ({{style}})',
+ 'compact.design': 'Generated Image',
'compact.impact': 'Impact Analysis ({{level}} risk)',
'compact.progress': 'Progress: {{phase}} ({{percent}}%)',
'compact.regressionPassed': 'Regression: Passed',
@@ -942,6 +983,8 @@ const en: Translations = {
'sessionSearch.deleteConfirm': 'Delete session "{{name}}"?',
'sessionSearch.messages': '{{count}} msgs',
'sessionSearch.shortcut': 'Ctrl+K',
+ 'sessionSearch.export': 'Export',
+ 'sessionSearch.import': 'Import',
// SchedulePage
'schedule.title': 'Scheduled Tasks',
@@ -973,6 +1016,10 @@ const en: Translations = {
'schedule.timeout': 'Timeout',
'schedule.duration': 'Duration',
'schedule.refresh': 'Refresh',
+ 'schedule.save': 'Save',
+ 'schedule.cancel': 'Cancel',
+ 'schedule.runNow': 'Run Now',
+ 'schedule.taskType': 'Type',
// LogsView
'logs.autoScroll': 'Auto Scroll',
@@ -1133,7 +1180,7 @@ const zh: Translations = {
'settings.tab.system': '系统',
'settings.tab.importExport': '导入/导出',
'settings.tab.mcp': 'MCP',
- 'settings.tab.plugins': '插件',
+ 'settings.tab.plugins': '技能 & 插件',
'settings.tab.prompts': '提示词',
'settings.tab.about': '关于',
// SettingsPanel - General tab
@@ -1232,7 +1279,7 @@ const zh: Translations = {
'message.compactBoundaryHint': '(Ctrl+O for history)',
'message.compactSummary': 'Compact summary',
'message.compactSummaryHint': '(Ctrl+O to expand)',
- 'message.designImage': 'UI Design - {{name}}',
+ 'message.designImage': 'Generated Image',
'message.showMore': 'Show more',
'message.showLess': 'Show less',
// UserQuestionDialog
@@ -1565,32 +1612,73 @@ const zh: Translations = {
'importExport.reset.failed': 'Failed to reset configuration',
'importExport.close': 'Close',
// PluginsPanel
- 'plugins.title': '插件管理',
- 'plugins.tab.discover': 'Discover',
- 'plugins.tab.installed': 'Installed',
- 'plugins.tab.marketplaces': 'Marketplaces',
- 'plugins.tab.errors': 'Errors',
- 'plugins.discover.title': 'Discover Plugins',
- 'plugins.discover.search': 'Search plugins...',
- 'plugins.discover.noMarketplaces': 'No marketplaces configured.',
- 'plugins.discover.addMarketplaceHint': 'Add a marketplace first with:',
- 'plugins.discover.noPlugins': 'No plugins found',
- 'plugins.discover.install': 'Install',
- 'plugins.discover.installing': 'Installing...',
- 'plugins.installed.title': 'Installed Plugins',
- 'plugins.installed.noPlugins': 'No plugins installed',
- 'plugins.installed.uninstall': 'Uninstall',
- 'plugins.installed.enable': 'Enable',
- 'plugins.installed.disable': 'Disable',
- 'plugins.marketplaces.title': 'Marketplaces',
- 'plugins.marketplaces.add': 'Add Marketplace',
- 'plugins.marketplaces.remove': 'Remove',
- 'plugins.marketplaces.update': 'Update',
- 'plugins.marketplaces.noMarketplaces': 'No marketplaces configured',
- 'plugins.errors.title': 'Plugin Errors',
- 'plugins.errors.noErrors': 'No errors',
- 'plugins.errors.clear': 'Clear Errors',
- 'plugins.back': 'Back',
+ 'plugins.title': '技能 & 插件管理',
+ 'plugins.tab.skills': '技能',
+ 'plugins.tab.plugins': '插件',
+ 'plugins.tab.discover': '发现',
+ 'plugins.tab.marketplaces': '市场源',
+ 'plugins.tab.errors': '错误',
+
+ // Skills Tab
+ 'plugins.skills.title': '管理技能',
+ 'plugins.skills.search': '搜索技能...',
+ 'plugins.skills.filter.all': '全部',
+ 'plugins.skills.filter.plugin': '插件',
+ 'plugins.skills.filter.smithery': 'Smithery',
+ 'plugins.skills.filter.manual': '手动',
+ 'plugins.skills.source.plugin': '插件',
+ 'plugins.skills.source.smithery': 'Smithery',
+ 'plugins.skills.source.manual': '手动',
+ 'plugins.skills.actions.view': '查看详情',
+ 'plugins.skills.actions.delete': '删除',
+ 'plugins.skills.actions.enable': '启用',
+ 'plugins.skills.actions.disable': '禁用',
+ 'plugins.skills.actions.confirmDelete': '确定要删除这个技能吗?',
+ 'plugins.skills.empty': '暂无可用技能',
+ 'plugins.skills.noResults': '未找到匹配的技能',
+ 'plugins.skills.detail.model': '模型',
+ 'plugins.skills.detail.tools': '允许的工具',
+ 'plugins.skills.detail.version': '版本',
+ 'plugins.skills.detail.path': '路径',
+ 'plugins.skills.detail.author': '作者',
+ 'plugins.skills.detail.source': '来源',
+ 'plugins.skills.detail.enabled': '已启用',
+ 'plugins.skills.detail.disabled': '已禁用',
+ 'plugins.skills.detail.argumentHint': '参数提示',
+ 'plugins.skills.viewContent': '技能内容',
+ 'plugins.skills.closeModal': '关闭',
+
+ // Plugins Tab
+ 'plugins.installed.title': '已安装插件',
+ 'plugins.installed.noPlugins': '暂无已安装插件',
+ 'plugins.installed.uninstall': '卸载',
+ 'plugins.installed.enable': '启用',
+ 'plugins.installed.disable': '禁用',
+
+ // Discover Tab
+ 'plugins.discover.title': '发现技能 & 插件',
+ 'plugins.discover.search': '搜索插件或技能...',
+ 'plugins.discover.searchSmithery': '搜索 Smithery 技能...',
+ 'plugins.discover.noMarketplaces': '未配置市场源',
+ 'plugins.discover.addMarketplaceHint': '请先添加市场源:',
+ 'plugins.discover.noPlugins': '未找到插件',
+ 'plugins.discover.install': '安装',
+ 'plugins.discover.installing': '安装中...',
+ 'plugins.discover.installed': '已安装',
+
+ // Marketplaces Tab
+ 'plugins.marketplaces.title': '市场源',
+ 'plugins.marketplaces.add': '添加市场源',
+ 'plugins.marketplaces.remove': '移除',
+ 'plugins.marketplaces.update': '更新',
+ 'plugins.marketplaces.noMarketplaces': '未配置市场源',
+
+ // Errors Tab
+ 'plugins.errors.title': '插件错误',
+ 'plugins.errors.noErrors': '暂无错误',
+ 'plugins.errors.clear': '清除错误',
+
+ 'plugins.back': '返回',
// OAuth Login
'auth.oauth.title': 'OAuth 登录',
'auth.oauth.selectMethod': 'Choose your authentication method',
@@ -1984,7 +2072,7 @@ const zh: Translations = {
'thinking.expandHint': '(点击展开)',
// CompactMessage
'compact.blueprint': '蓝图: {{name}} ({{count}} 个模块)',
- 'compact.design': '设计: {{name}} ({{style}})',
+ 'compact.design': '生成的图片',
'compact.impact': '影响分析 ({{level}} 风险)',
'compact.progress': '进度: {{phase}} ({{percent}}%)',
'compact.regressionPassed': '回归测试: 通过',
@@ -2009,6 +2097,8 @@ const zh: Translations = {
'sessionSearch.deleteConfirm': '确认删除会话 "{{name}}"?',
'sessionSearch.messages': '{{count}} 条',
'sessionSearch.shortcut': 'Ctrl+K',
+ 'sessionSearch.export': '导出',
+ 'sessionSearch.import': '导入',
// SchedulePage
'schedule.title': '定时任务',
@@ -2040,6 +2130,10 @@ const zh: Translations = {
'schedule.timeout': '超时',
'schedule.duration': '耗时',
'schedule.refresh': '刷新',
+ 'schedule.save': '保存',
+ 'schedule.cancel': '取消',
+ 'schedule.runNow': '立即执行',
+ 'schedule.taskType': '类型',
// LogsView
'logs.autoScroll': '自动滚动',
'logs.clearLogs': '清除日志',
diff --git a/src/web/client/src/pages/SchedulePage/SchedulePage.module.css b/src/web/client/src/pages/SchedulePage/SchedulePage.module.css
index de7ce6f1..c7dc2f8a 100644
--- a/src/web/client/src/pages/SchedulePage/SchedulePage.module.css
+++ b/src/web/client/src/pages/SchedulePage/SchedulePage.module.css
@@ -802,6 +802,49 @@
outline: none;
}
+.editTextareaWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.editTextareaActions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.editCancelBtn {
+ padding: 5px 14px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.editCancelBtn:hover {
+ background: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+.editSaveBtn {
+ padding: 5px 14px;
+ background: var(--accent-primary);
+ border: 1px solid var(--accent-primary);
+ border-radius: 4px;
+ font-size: 13px;
+ color: white;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.editSaveBtn:hover {
+ opacity: 0.9;
+}
+
.editSelect {
width: 100%;
padding: 8px 10px;
diff --git a/src/web/client/src/pages/SchedulePage/index.tsx b/src/web/client/src/pages/SchedulePage/index.tsx
index fcff99c1..ffc464a9 100644
--- a/src/web/client/src/pages/SchedulePage/index.tsx
+++ b/src/web/client/src/pages/SchedulePage/index.tsx
@@ -807,19 +807,39 @@ const SchedulePage: React.FC = () => {
}
}}>
{editingField === 'prompt' ? (
-