diff --git a/.gitignore b/.gitignore index 8d2ffe0f..5c6e4eef 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ next-env.d.ts apps/site/.next/ apps/site/.source/ apps/site/node_modules/ + +# claude code session data +.claude/ diff --git a/.mcp.json b/.mcp.json index ec1ba23f..d82ae698 100644 --- a/.mcp.json +++ b/.mcp.json @@ -5,7 +5,8 @@ "command": "npx", "args": [ "-y", - "chrome-devtools-mcp@latest" + "chrome-devtools-mcp@0.20.3", + "--headless" ], "env": {} } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3557d25e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# AGENTS.md + +CodePilot — Codex 的桌面 GUI 客户端,基于 Electron + Next.js。 + +> 架构细节见 [ARCHITECTURE.md](./ARCHITECTURE.md),本文件只包含规则和流程。 + +## 开发规则 + +**提交前必须详尽测试:** +- 每次提交代码前,必须在开发环境中充分测试所有改动的功能,确认无回归 +- 涉及前端 UI 的改动需要实际启动应用验证(`npm run dev` 或 `npm run electron:dev`) +- 涉及构建/打包的改动需要完整执行一次打包流程验证产物可用 +- 涉及多平台的改动需要考虑各平台的差异性 + +**UI 改动必须用 CDP 验证(chrome-devtools MCP):** +- 修改组件、样式、布局后,必须通过 chrome-devtools MCP 实际验证效果 +- 验证流程:`npm run dev` 启动应用 → 用 CDP 打开 `http://localhost:3000` 对应页面 → 截图确认渲染正确 → 检查 console 无报错 +- 涉及交互的改动(按钮、表单、导航)需通过 CDP 模拟点击/输入并截图验证 +- 修改响应式布局时,用 CDP 的 device emulation 分别验证桌面和移动端视口 + +**新增功能前必须详尽调研:** +- 新增功能前必须充分调研相关技术方案、API 兼容性、社区最佳实践 +- 涉及 Electron API 需确认目标版本支持情况 +- 涉及第三方库需确认与现有依赖的兼容性 +- 涉及 Codex SDK 需确认 SDK 实际支持的功能和调用方式 +- 对不确定的技术点先做 POC 验证,不要直接在主代码中试错 + +**Worktree 隔离规则:** +- 如果任务设置了 Worktree,所有代码改动只能在该 Worktree 内进行 +- 严格禁止跨 Worktree 提交(不得在主目录提交 Worktree 的改动,反之亦然) +- 严格禁止 `git push`,除非用户主动提出 +- 启动测试服务(`npm run dev` 等)只从当前 Worktree 启动,不得在其他目录启动 +- 合并回主分支必须由用户主动发起,不得自动合并 +- **端口隔离**:Worktree 启动 dev server 时使用非默认端口(如 `PORT=3001`),避免与主目录冲突 +- **禁止跨目录编辑**:属于 Worktree 任务范围的文件,只在该 Worktree 内编辑,不得在主目录修改 +- **合并前检查 untracked 文件**:合并回主分支前先 `git status` 确认无调试残留、临时文件等 + +**Commit 信息规范:** +- 标题行使用 conventional commits 格式(feat/fix/refactor/chore 等) +- body 中按文件或功能分组,说明改了什么、为什么改、影响范围 +- 修复 bug 需说明根因;架构决策需简要说明理由 + +## 自检命令 + +**自检命令(pre-commit hook 会自动执行前三项):** +- `npm run test` — typecheck + 单元测试(~4s,无需 dev server) +- `npm run test:smoke` — 冒烟测试(~15s,需要 dev server) +- `npm run test:e2e` — 完整 E2E(~60s+,需要 dev server) + +修改代码后,commit 前至少确保 `npm run test` 通过。 +涉及 UI 改动时额外运行 `npm run test:smoke`。 + +## 改动自查 + +完成代码修改后,在提交前确认: +1. 改动是否涉及 i18n — 是否需要同步 `src/i18n/en.ts` 和 `zh.ts` +2. 改动是否涉及数据库 — 是否需要在 `src/lib/db.ts` 更新 schema 迁移 +3. 改动是否涉及类型 — 是否需要更新 `src/types/index.ts` +4. 改动是否涉及已有文档 — 是否需要更新 `docs/handover/` 中的交接文档 + +## 发版 + +**发版流程:** 更新 package.json version → `npm install` 同步 lock → 提交推送 → `git tag v{版本号} && git push origin v{版本号}` → CI 自动构建发布。不要手动创建 GitHub Release。 + +**发版纪律:** 禁止自动发版。`git push` + `git tag` 必须等用户明确指示后才执行。commit 可以正常进行。 + +**Release Notes 格式:** 标题 `CodePilot v{版本号}`,正文包含:更新内容、Downloads、Installation、Requirements、Changelog。 + +**构建:** macOS 产出 DMG(arm64 + x64),Windows 产出 NSIS 安装包。`scripts/after-pack.js` 重编译 better-sqlite3 为 Electron ABI。构建前清理 `rm -rf release/ .next/`。 + +## 执行计划 + +**中大型功能(跨 3+ 模块、涉及 schema 变更、需分阶段交付)必须先写执行计划再开工。** +- 活跃计划放 `docs/exec-plans/active/`,完成后移至 `completed/` +- 纯调研/可行性分析放 `docs/research/` +- 发现技术债务时记录到 `docs/exec-plans/tech-debt-tracker.md` +- 模板和规范见 `docs/exec-plans/README.md` + +## 文档 + +- [ARCHITECTURE.md](./ARCHITECTURE.md) — 项目架构、目录结构、数据流、新功能触及点 +- `docs/exec-plans/` — 执行计划(进度状态 + 决策日志 + 技术债务) +- `docs/handover/` — 交接文档(架构、数据流、设计决策) +- `docs/research/` — 调研文档(技术方案、可行性分析) + +**检索前先读对应目录的 README.md;增删文件后更新索引。** diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..197f3b11 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## [0.38.4] - 2026-03-20 + +### Bug Fixes +- **#341**: Fixed Claude Code CLI not being recognized as a valid provider — `/api/setup` now checks `findClaudeBinary()` so the chat page no longer shows "no provider configured" when Claude Code CLI is available +- **#343/#346**: Fixed session crash when switching provider/model — server-side PATCH handler now auto-clears stale `sdk_session_id` when provider or model changes, preventing resume failures +- **#347**: Fixed default model always reverting to first model in list — introduced global default model setting that persists across sessions + +### New Features +- **Global Default Model**: New setting in the provider page to choose a default model across all providers. New conversations automatically use this model. Existing conversations are not affected. +- **Default Model Indicator**: The model selector in the chat input now shows a "Default" tag next to the configured default model + +### Improvements +- **Error Classification**: Session state errors (stale session, resume failed) are now correctly classified instead of being reported as provider configuration problems +- **Select Component**: Fixed Radix Select dropdown growing unbounded on scroll — now capped at 16rem with proper overflow scrolling diff --git a/apps/site/package.json b/apps/site/package.json index 420ae93a..c42dd34c 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -20,7 +20,7 @@ "fumadocs-mdx": "^11", "fumadocs-ui": "^15", "lucide-react": "^0.563.0", - "next": "15.3.6", + "next": "15.5.14", "react": "^19", "react-dom": "^19", "shadcn": "^4.0.2", diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md index 740669d1..43305b36 100644 --- a/docs/exec-plans/README.md +++ b/docs/exec-plans/README.md @@ -44,8 +44,11 @@ | 文件 | 主题 | 状态 | |------|------|------| +| active/chat-latency-remediation.md | 聊天链路提速 + 模式入口收敛 + MCP 持久开关 | Phase 0 完成,Phase 1-4 待开始 | | active/context-storage-migration.md | 上下文共享与存储迁移 | Phase 0 部分完成,Phase 1-3 待开始 | | active/site-and-docs.md | 官网 + 文档站(apps/site) | Phase 0-1 进行中 | +| active/weixin-bridge-channel.md | 微信 Bridge 通道一次性交付方案 | One Shot 待开始 | +| active/unified-context-layer.md | 统一上下文层 + 浮窗助理 + 产品架构演进 | Phase 1-3 已完成,Phase 4-5 待开始 | ### Completed diff --git a/docs/exec-plans/active/chat-latency-remediation.md b/docs/exec-plans/active/chat-latency-remediation.md new file mode 100644 index 00000000..e00f0efb --- /dev/null +++ b/docs/exec-plans/active/chat-latency-remediation.md @@ -0,0 +1,176 @@ +# Chat Latency Remediation + +> 创建时间:2026-03-21 +> 最后更新:2026-03-21 + +## 状态 + +| Phase | 内容 | 状态 | 备注 | +|-------|------|------|------| +| Phase 0 | 范围确认与基线 | ✅ 已完成 | 用户已确认删模式入口、MCP 持久开关、resume 可见状态、capability 延后 | +| Phase 1 | 聊天模式入口收敛 | 📋 待开始 | 先删前台入口并统一走 `code`,后台兼容先保留 | +| Phase 2 | MCP 持久启停开关 | 📋 待开始 | 默认开启,用户可在 MCP 页面关闭单个 server | +| Phase 3 | 首包延迟优化 | 📋 待开始 | effort/thinking 默认值、resume 可见状态、能力抓取延后 | +| Phase 4 | 观测与验证 | 📋 待开始 | 时序埋点、回归验证、CDP 检查 | + +## 决策日志 + +- 2026-03-21: 聊天界面的模式选择(如 CodePilot / Ask)可以删除,计划模式后续重做时再引入。 +- 2026-03-21: 本轮不做 `mode` 字段和桥接 `/mode` 命令的彻底清理,先删除桌面主聊天入口并把桌面聊天统一收敛到 `code`,以控制范围和回归风险。 +- 2026-03-21: MCP 需要做成持久启停开关,默认开启,用户可以在 MCP 页面关闭某个 server。 +- 2026-03-21: 恢复旧会话时允许增加可见状态提示,优先解决“空白等待像卡死”的体感问题。 +- 2026-03-21: `supportedModels` / `supportedCommands` / `accountInfo` / `mcpServerStatus` 可以移出首包关键路径。 +- 2026-03-21: 不采用“全局关闭 MCP”或“强制所有模型 low effort”这类以阉割功能换速度的方案。 + +## 目标 + +- 降低普通聊天的首个可见事件时间和首 token 时间。 +- 删除当前已经失去产品意义的模式入口,避免无效交互。 +- 保留工具、MCP、技能、恢复旧会话等能力,不靠砍功能提速。 +- 让 MCP 的启停变成用户可控且持久生效的配置。 + +## 非目标 + +- 不在本轮重做计划模式。 +- 不在本轮彻底移除数据库 `mode` 字段、桥接 `/mode` 命令和所有旧兼容分支。 +- 不在本轮重构 provider 体系或 assistant workspace。 +- 不通过全局禁用用户本机 Claude 配置来一次性解决所有延迟问题。 + +## 交互与功能变化 + +- 删除主聊天界面的模式选择入口;桌面聊天统一按 `code` 路径执行。 +- MCP 页面增加“持久启用/停用”开关;默认开启,关闭后该 server 不再注入新的聊天会话和桥接会话。 +- 恢复旧会话时显示显式状态文案,如“正在恢复上下文…”或“正在连接工具…”。 +- 除以上三点外,不新增额外交互,不减少模型、附件、工具和 MCP 的现有能力。 + +## 详细设计 + +### Phase 1: 聊天模式入口收敛 + +**目标** + +- 去掉无效的 UI 模式切换,减少用户误解和无意义状态分支。 + +**实现** + +- `src/components/chat/ChatView.tsx` + - 删除主聊天的 mode 状态、切换 UI、相关请求。 + - 发消息时不再从前端传 `ask` / `plan`。 +- `src/app/chat/[id]/page.tsx` + - 页面加载时不再把 `session.mode` 作为主聊天 UI 状态源。 +- `src/components/layout/SplitColumn.tsx` + - 移除传递给聊天视图的 mode UI 状态,避免顶部状态和实际执行路径不一致。 +- `src/app/api/chat/route.ts` + - 主聊天路径把 `effectiveMode` 收敛到 `code`。 + - 保留兼容注释,说明这是桌面主聊天的产品决策,不等于全系统彻底移除 `mode`。 +- `src/app/api/chat/mode/route.ts` + - 桌面 UI 不再调用。 + - 可先保留接口以兼容旧版本客户端和桥接路径,后续再统一清理。 +- `src/i18n/en.ts` 与 `src/i18n/zh.ts` + - 清理主聊天里不再使用的模式文案。 + +**风险控制** + +- 不建议本轮直接删除 DB schema、TypeScript union、bridge `/mode` 命令,否则会把“延迟治理”扩成“跨桌面 + 桥接 + 数据兼容”的大重构。 +- 旧 session 若仍带 `ask` / `plan`,桌面主聊天读取时统一按 `code` 执行;桥接路径暂不动。 + +### Phase 2: MCP 持久启停开关 + +**目标** + +- 把 MCP 开关从“当前活动会话里的临时 runtime toggle”升级为“配置层的持久开关”。 + +**现状问题** + +- 现有 `src/app/api/plugins/mcp/toggle/route.ts` 只对当前活动 conversation 做 runtime toggle。 +- 主聊天和桥接加载 MCP 时,`src/app/api/chat/route.ts` 与 `src/lib/bridge/conversation-engine.ts` 会直接读取配置文件并全部注入,没有“持久禁用”的过滤层。 + +**实现** + +- `src/types/index.ts` + - 给 `MCPServerConfig` 增加 `enabled?: boolean`,默认按 `true` 解释。 +- `src/app/api/plugins/mcp/route.ts` + - GET/PUT/POST 保留并透传 `enabled` 字段。 + - 读取 `~/.claude.json` 和 `~/.claude/settings.json` 时合并 `_source`,但不丢失 `enabled`。 +- `src/components/plugins/McpServerList.tsx` + - 把“仅在 runtime disabled 时显示 Enable 按钮”的逻辑改成常驻开关。 + - 配置禁用状态要和 runtime status 分开展示,避免“配置关掉”和“运行中失败”混为一谈。 +- `src/components/plugins/McpManager.tsx` + - 保存开关改为走配置更新,而不是仅调用 runtime toggle。 +- `src/app/api/chat/route.ts` + - `loadMcpServers()` 过滤 `enabled === false` 的 server。 +- `src/lib/bridge/conversation-engine.ts` + - 同步过滤 `enabled === false` 的 server,保证桌面和桥接行为一致。 +- `src/app/api/plugins/mcp/toggle/route.ts` + - 保留给“活动会话临时 reconnect / runtime toggle”使用,或者标记为仅 runtime 语义;不要拿它承载持久配置。 + +**用户可见行为** + +- 默认不变,所有 server 仍然开启。 +- 用户手动关闭某个 server 后,它不会参与新的聊天初始化;这属于明确的用户控制,不是功能被系统静默阉割。 + +### Phase 3: 首包延迟优化 + +**目标** + +- 优先解决“所有模型都慢”和“恢复旧会话时长时间空白”这两个体感问题。 + +**实现 A:runtime profile 显式默认值** + +- `src/lib/claude-client.ts` + - 当请求没有显式传 `thinking/effort` 时,由 CodePilot 统一设默认值,而不是继续继承用户本机 Claude 的高强度偏好。 + - 推荐默认 `effort: 'medium'`;是否显式关闭 thinking 取决于当前 UI 有没有独立的 thinking 控制,避免引入额外行为分叉。 +- `src/app/api/chat/route.ts` + - 明确桌面主聊天的默认 runtime profile。 + - 若未来需要区分“显式用户选择”和“应用默认值”,在请求体里增加来源标记,避免统计混淆。 + +**实现 B:resume 可见状态** + +- `src/lib/claude-client.ts` + - 在执行 resume 校验前,先发一条前端可见的 status 事件。 + - resume 失败时继续保留 fresh fallback,但不要让用户先经历整段静默等待。 +- `src/hooks/useSSEStream.ts` + - 接受新的可见状态消息并展示,不再把这类初始化状态都当作 internal-only 噪音过滤掉。 + +**实现 C:能力抓取延后** + +- `src/lib/agent-sdk-capabilities.ts` + - 增加 TTL / freshness 判断,已有缓存时不在每轮 query 初始化后立即抓全套。 +- `src/lib/claude-client.ts` + - `captureCapabilities()` 改到首个 `system init` 之后、或首个文本事件之后执行,避免与首轮初始化竞争资源。 + +**实现 D:项目自带 MCP 冷启动减负** + +- 项目级 [`.mcp.json`](/Users/guohao/Documents/code/codepilot/CodePilot/.mcp.json) + - 对项目自带的重型 server 避免使用 `npx -y ...@latest` 这类每次解析版本的冷启动方式。 + - 优先固定版本;若条件允许,改为本地已安装命令或更轻的启动路径。 +- 保留现有 generative UI 的按需挂载策略,不把 widget MCP 重新拉回所有会话的默认初始化路径。 + +## 验收标准 + +- 主聊天界面不再出现 CodePilot / Ask 模式切换。 +- 新建或继续聊天时,桌面主聊天统一按 `code` 权限路径工作。 +- MCP 页面可以对单个 server 做持久启停,刷新页面后状态仍然正确。 +- 被关闭的 MCP server 不会出现在新聊天的初始化注入列表中。 +- 恢复旧会话时,用户能在模型首 token 前看到明确状态提示。 +- 普通文本聊天的首个可见状态时间明显早于当前版本,且不依赖关闭功能来实现。 + +## 验证计划 + +- `npm run test` + - 覆盖类型、单元测试、基础回归。 +- `npm run test:smoke` + - 验证主聊天发送、MCP 设置页、持久开关基本流程。 +- UI 变更必须跑开发环境并用 CDP 验证 + - 启动 `npm run dev` + - 验证主聊天无模式切换入口 + - 验证 MCP 页面开关可切换且刷新后保持 + - 验证恢复旧会话时状态文案可见 + - 检查 console 无新增报错 + +## 建议实施顺序 + +1. 先做 Phase 1,去掉无效模式入口并把桌面主聊天统一到 `code`。 +2. 再做 Phase 2,把 MCP 开关改成持久配置,并同步过滤聊天/桥接注入。 +3. 然后做 Phase 3 的三项低风险优化:显式 runtime 默认值、resume 可见状态、capability 延后。 +4. 最后补齐时序日志和回归验证,确认延迟改善是否达到预期。 diff --git a/docs/exec-plans/active/unified-context-layer.md b/docs/exec-plans/active/unified-context-layer.md new file mode 100644 index 00000000..8ed66e7f --- /dev/null +++ b/docs/exec-plans/active/unified-context-layer.md @@ -0,0 +1,255 @@ +# 统一上下文层 + 浮窗助理 + 产品架构演进 + +> 创建时间:2026-03-24 +> 最后更新:2026-03-24 + +## 状态 + +| Phase | 内容 | 状态 | 备注 | +|-------|------|------|------| +| Phase 1 | 统一上下文层(Context Assembler + MCP Loader) | ✅ 已完成 | 已合入 worktree | +| Phase 2 | Plan Mode 补全 | ✅ 已完成 | UI + 状态恢复 + 安全修复 | +| Phase 3 | Bridge 能力升级 | ✅ 已完成 | CLI 工具上下文注入 | +| Phase 4 | 浮窗助理(Electron 常驻 + 快捷键 + 语音 + 剪贴板) | 📋 待开始 | 需单独规划 | +| Phase 5 | 代码任务通知(系统通知推送) | 📋 待开始 | 依赖 Phase 4 的交互范式确认 | + +--- + +## 一、起因:项目架构散装问题 + +### 问题描述 + +CodePilot 发展至今,积累了 6 套独立的"连接器"系统,各有自己的发现机制、注入方式、调用链路和 UI 入口: + +| 系统 | 发现方式 | 注入方式 | UI 入口 | +|------|---------|---------|---------| +| Skills | 文件系统扫描 `.claude/skills/` | 展开为纯文本插入 prompt | `/` 弹出框 | +| MCP | 读 3 个配置文件合并 | SDK 原生加载 | 设置页管理 | +| CLI Tools | `which` + `--version` 探测 | system prompt XML 块 | 工具栏弹出框 | +| Plugins | `~/.claude/plugins/` 目录扫描 | SDK 自动加载 | 设置页管理 | +| Generative UI | 模型输出 code fence 检测 | system prompt 指令 | 消息流内 iframe | +| Bridge | 适配器自注册 | 独立消息循环 | CLI skill 启停 | + +这些系统之间几乎没有共享抽象,导致三个结构性问题: + +1. **上下文组装碎片化**:`route.ts` 有 150 行内联组装逻辑(5 层 prompt),Bridge 只用裸 `session.system_prompt`。同一个用户装了 ffmpeg、配了 MCP、开了 widget,但从 Bridge 发消息时 Claude 不知道这些能力存在。 +2. **扩展的"身份"没有统一**:Skills、CLI Tools、MCP tools、Plugins 本质上都是"Claude 可以调用的能力",但注册方式、生命周期、UI、作用域全部不同。 +3. **各入口能力不对称**:Browser chat 有全功能,Bridge 缺 CLI/Widget,Assistant 又是另一套。 + +### 参考:Moxt 的 AI-Native Workspace + +Moxt([moxt.ai](https://moxt.ai))的核心洞察: + +- **Less Content is More Context** — 重要的不是工具数量,而是 AI 能无摩擦调用。 +- 所有能力统一在"工作空间"概念下:文件系统、记忆、技能、AI 同事不是独立功能,而是同一空间的不同维度。 +- 用户不需要知道"这是 MCP 工具还是 Skill",只需说"帮我做这件事"。 + +映射到 CodePilot:应有一个统一的能力层(Capability Layer),所有扩展注册进去,Claude 按需调用。 + +--- + +## 二、架构方向 + +### 目标架构 + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ 主窗口 │ │ 浮窗助理 │ │ Bridge │ │ 未来入口 │ +│ (完整UI) │ │ (快捷键 │ │ (IM远程) │ │ │ +│ │ │ +剪贴板 │ │ │ │ │ +│ │ │ +语音) │ │ │ │ │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ │ + └─────────────┴─────────────┴──────────────┘ + │ + ┌─────────▼──────────┐ + │ Context Assembler │ + │ 按入口类型决定 │ + │ 注入哪些能力层 │ + └─────────┬──────────┘ + │ + ┌─────────▼──────────┐ + │ SDK Gateway │ + │ (streamClaude) │ + │ 所有入口共享 │ + └────────────────────┘ +``` + +四个入口形态不同,但底层共享: +- **主窗口**:完整开发体验,生成式 UI、文件树、全功能 +- **浮窗助理**:轻量、快进快出、剪贴板 + 语音驱动 +- **Bridge**:IM 远程,文字驱动 +- **助理空间**:长期记忆、个性化 + +### 核心设计原则 + +1. **按 mode 决定注入什么** — 助理模式不注入 CLI/widget(省上下文),代码模式不注入 workspace prompt +2. **不重实现 SDK 已有功能** — plan mode、settingSources、MCP 加载全用 SDK 原生 +3. **入口增加不需要重写上下文逻辑** — 新增浮窗助理只需调 `assembleContext({ entryPoint: 'floating' })` + +--- + +## 三、Phase 1-3 已完成:统一上下文层 + +### 3.1 Context Assembler(`src/lib/context-assembler.ts`) + +从 `route.ts` 提取 150 行内联组装逻辑为纯 async 函数。按 `entryPoint` 条件注入 5 层 prompt: + +| 层 | Desktop | Bridge | 说明 | +|----|---------|--------|------| +| Workspace prompt | ✅ 如果 isAssistantProject | ✅ 如果 isAssistantProject | soul.md, user.md, memory.md 等 | +| session.system_prompt + append | ✅ | ✅ | 会话自定义 prompt + 技能注入 | +| Assistant project instructions | ✅ 如果 isAssistantProject | ✅ 如果 isAssistantProject | onboarding / daily check-in | +| CLI tools context | ✅ | ✅ **(新增)** | 检测到的系统工具 XML 块 | +| Widget system prompt | ✅ 如果 generative_ui_enabled | ❌ 永不注入 | IM 渲染不了 iframe | + +复用现有函数(loadWorkspaceFiles、assembleWorkspacePrompt、buildCliToolsContext 等),不重写。 + +### 3.2 MCP Loader(`src/lib/mcp-loader.ts`) + +**问题**:`loadMcpServers()` 在 route.ts 和 conversation-engine.ts 中完全重复。且 SDK 通过 `settingSources: ['user', 'project', 'local']` 已自动加载所有 MCP 服务器,手动再传一遍是冗余的。 + +**方案**:Delta 模式。 +- `loadCodePilotMcpServers()`:只返回有 `${...}` env placeholder 的服务器(需从 CodePilot DB 解析)。当前实际配置中无此类服务器,返回 undefined。 +- `loadAllMcpServers()`:全量合并,供 MCP Manager UI 展示。 +- 30 秒 TTL 缓存 + 配置变更时主动失效。 + +**延迟影响**:大多数用户场景下不再传任何手动 MCP 配置给 SDK,消除冗余配置解析。 + +### 3.3 Plan Mode 补全 + +**之前的问题**: +- `permissionMode` 硬编码为 `'acceptEdits'`,切到 Plan 不生效 +- mode 状态刷新即丢(hardcoded `useState('code')`) +- 无 UI 切换入口 + +**修复**: +- 从请求体 `mode` 字段读取(优先)或从 DB `session.mode` 读取(fallback),消除竞态 +- Plan mode 优先于 full_access:`bypassPermissions = full_access && mode !== 'plan'` +- `initialMode` prop 从 DB 恢复 +- ModeIndicator 组件(Tabs),Code/Plan 切换 + +### 3.4 Bridge 升级 + +- `conversation-engine.ts` 调用 `assembleContext({ entryPoint: 'bridge' })`,获得 CLI 工具上下文 +- 补齐 5 个缺失 SDK 选项:thinking(disabled)、effort(medium)、generativeUI(false)、fileCheckpointing(false)、context1m(false) + +### 3.5 UI 统一 + +Action bar 三个组件(ModeIndicator、ImageGenToggle、ChatPermissionSelector)统一为: +- 圆角矩形(`rounded-md`) +- 相同高度(`h-7` = 28px) +- 相同字号(`text-xs`) +- 各自带语义图标(Code / NotePencil / PaintBrush / Lock) + +--- + +## 四、Phase 4 待开始:浮窗助理 + +### 4.1 产品定位 + +参考 Perplexity 桌面版的"常驻后台 + 快捷键拉起"模式,CodePilot 的浮窗助理定位为: + +**始终在身边的助理,不需要打开主窗口就能执行任务。** + +``` +用户在任何 app 里工作 + → 复制了一段文字/截了一张图(进剪贴板) + → 按全局快捷键(如 Cmd+Shift+Space) + → 浮窗从右上角弹出 + → 自动读取剪贴板内容,显示在输入区作为上下文 + → 用户语音说指令(或打字) + → Claude 执行,结果语音播报 + → 用户关掉浮窗,继续工作 +``` + +全程不需要切换到 CodePilot 主窗口。 + +### 4.2 核心决策 + +| 决策 | 结论 | 理由 | +|------|------|------| +| 浮窗用哪个 workspace? | 固定在助理 workspace | 助理任务不需要项目上下文,避免上下文爆炸 | +| 新建还是继续对话? | 用户选择 | 可以新建,也可以在助理已有对话上继续 | +| 代码任务怎么办? | 回主窗口 | 代码需要审查,通过系统通知告知 | +| 语音方案? | 双轨 | 本地 Whisper(愿意下模型的用户)+ API(配置不够的用户) | +| TTS 回复? | 仅浮窗助理 | 代码任务不需要语音播报 | + +### 4.3 技术方案 + +**块 1:Electron 常驻 + 全局快捷键 + 浮窗** +- `Tray` 做常驻(菜单栏图标) +- `globalShortcut.register()` 注册全局快捷键 +- 独立 `BrowserWindow`,小尺寸,置顶,圆角 +- Esc 或失焦自动收起(隐藏不关闭) + +**块 2:剪贴板感知** +- 弹出时 `clipboard.readText()` + `clipboard.readImage()` +- 有文字:显示为上下文预览卡片 +- 有图片:显示缩略图,作为 vision 输入 +- 空:直接显示输入框 + +**块 3:语音输入/输出** +- 抽象接口: + ```ts + interface SpeechProvider { + transcribe(audio: AudioBuffer): Promise // STT + speak(text: string): Promise // TTS + readonly type: 'local' | 'api' + readonly ready: boolean + } + ``` +- 本地:Whisper + 系统 TTS +- API:OpenAI Whisper API + TTS API +- 用户设置里选,或自动降级(本地模型没下载就 fallback 到 API) + +### 4.4 上下文隔离 + +浮窗助理始终在助理模式: +- Context Assembler 按 `entryPoint: 'floating'` 组装,只注入 workspace prompt + session prompt +- 不注入 CLI tools、widget prompt、项目文件上下文 +- 上下文小 → 响应快 → 适合快进快出 + +--- + +## 五、Phase 5 待开始:代码任务通知 + +代码任务留在主窗口,但通过系统通知推送关键事件: + +``` +助理浮窗: 语音输入 → AI 处理 → 语音/文字回复(快进快出) +代码任务: 主窗口操作 → 需要注意力时推系统通知 → 用户点通知回到主窗口 +``` + +通知场景: +- "任务完成:已创建 3 个文件" +- "需要审批:Claude 想执行 `rm -rf dist/`" +- 点击通知 → 跳转到主窗口对应会话 + +技术:Electron `Notification` API,简单直接。 + +--- + +## 六、技术债务 & 后续优化 + +| 项目 | 说明 | 优先级 | +|------|------|--------| +| Skills/Commands 双轨发现 | SDK 自动发现 + CodePilot 手动扫描不同步,UI 显示的能力 ≠ Claude 实际知道的 | P1 | +| Widget prompt 始终注入 | ~150 tokens,不大但无关对话也在。已做 keyword-gated MCP,prompt 本身可进一步按需注入 | P2 | +| Bridge 缺 thinking/effort UI | 目前 Bridge 固定 thinking=disabled, effort=medium,未来可加 binding 级别配置 | P3 | +| 统一能力注册表 | 长期目标:`AgentCapabilityRegistry` 让所有扩展统一注册,UI 有统一"能力面板" | P3 | +| ConversationContext 一等实体 | 封装 provider + model + tools + skills + files + cwd,消除重复解析逻辑 | P3 | + +--- + +## 七、相关文件 + +| 文件 | 用途 | +|------|------| +| `src/lib/context-assembler.ts` | 统一上下文组装 | +| `src/lib/mcp-loader.ts` | 智能 MCP 加载器 | +| `src/components/chat/ModeIndicator.tsx` | Plan/Code 模式切换 UI | +| `src/app/api/chat/route.ts` | 主聊天端点(已重构) | +| `src/lib/bridge/conversation-engine.ts` | Bridge SDK 调用(已升级) | +| `src/__tests__/unit/context-assembler.test.ts` | 上下文组装测试 | +| `src/__tests__/unit/mcp-loader.test.ts` | MCP 加载器测试 | diff --git a/docs/exec-plans/active/weixin-bridge-channel.md b/docs/exec-plans/active/weixin-bridge-channel.md new file mode 100644 index 00000000..e4df452a --- /dev/null +++ b/docs/exec-plans/active/weixin-bridge-channel.md @@ -0,0 +1,599 @@ +# Weixin Bridge Channel Integration + +> 创建时间:2026-03-22 +> 最后更新:2026-03-22 + +## 状态 + +| Phase | 内容 | 状态 | 备注 | +|-------|------|------|------| +| One Shot | 微信 Bridge 通道端到端实现 | 📋 待开始 | 按本文一次性交付,不拆分成多轮功能阶段 | + +## 决策日志 + +- 2026-03-22: 不直接依赖 `@tencent-weixin/openclaw-weixin` 运行时;只把它当协议样本和参考实现,因为它深度绑定 `openclaw/plugin-sdk` 与 OpenClaw runtime。 +- 2026-03-22: 微信通道按 CodePilot 现有 `BaseChannelAdapter` 原生实现,而不是走当前 CodePilot `ChannelPlugin` 接口;后者接口能力不足以承载微信的多账号登录、长轮询和 per-account 状态管理。 +- 2026-03-22: 多账号路由不修改 `channel_bindings` schema,改用合成 `chatId` 解决隔离问题,格式固定为 `weixin::::`。 +- 2026-03-22: `channel_offsets` 继续复用,但 offset key 不再等于单纯 channel name,而是 `weixin:`,其 `offset_value` 保存 `get_updates_buf` 原文。 +- 2026-03-22: `context_token` 不能照搬 OpenClaw 插件的内存 `Map`,必须持久化进 SQLite,否则 Bridge 自动启动、冷启动回复和多账号轮询都会不稳定。 +- 2026-03-22: 本轮不做群聊、不做 OpenClaw 飞书插件里的平台工具集、不做 OpenClaw command/tool runtime 复刻;聚焦 CodePilot Bridge 通道能力本身。 +- 2026-03-22: 权限审批走文本命令降级路径 `/perm allow|allow_session|deny `,不做微信内联按钮。 + +## 目标 + +- 在 CodePilot 中新增可真实使用的微信 Bridge 通道。 +- 支持二维码登录、多账号在线、私聊消息长轮询、文本收发、typing 指示、入站媒体解析。 +- 让微信通道完整接入现有 `Bridge -> Router -> ConversationEngine -> Delivery` 主链路。 +- 提供桌面设置页、账号列表、连接/断开和运行状态展示。 +- 让 Claude Code 可以基于本计划和本地参考代码,一次性完成可交付实现,而不是先做半成品 POC。 + +## 非目标 + +- 不直接把 OpenClaw 微信 npm 包作为 CodePilot 运行时依赖。 +- 不实现群聊、群策略、线程会话、@mention 触发。 +- 不移植 OpenClaw 飞书插件的 doc/wiki/drive/task/calendar 工具族。 +- 不在本轮做 AI 主动发送媒体的完整产品交互;若实现者顺手补齐底层能力可以接受,但不能因此拖慢主链路交付。 + +## 先读这些上下文 + +Claude Code 在开工前,必须先通读以下本地资料,再写代码: + +### 1. CodePilot 现有架构 + +- `AGENTS.md` +- `ARCHITECTURE.md` +- `docs/handover/bridge-system.md` +- `docs/research/mobile-remote-control-overall-plan.md` +- `src/lib/bridge/channel-adapter.ts` +- `src/lib/bridge/bridge-manager.ts` +- `src/lib/bridge/channel-router.ts` +- `src/lib/bridge/conversation-engine.ts` +- `src/lib/db.ts` +- `src/app/api/bridge/settings/route.ts` +- `src/components/bridge/BridgeSection.tsx` +- `src/components/bridge/BridgeLayout.tsx` + +### 2. OpenClaw 微信插件参考 + +- `docs/research/weixin-openclaw-plugin-review-2026-03-22.md` +- `资料/weixin-openclaw-cli/package/cli.mjs` +- `资料/weixin-openclaw-package/package/index.ts` +- `资料/weixin-openclaw-package/package/openclaw.plugin.json` +- `资料/weixin-openclaw-package/package/src/channel.ts` +- `资料/weixin-openclaw-package/package/src/api/api.ts` +- `资料/weixin-openclaw-package/package/src/api/types.ts` +- `资料/weixin-openclaw-package/package/src/auth/login-qr.ts` +- `资料/weixin-openclaw-package/package/src/auth/accounts.ts` +- `资料/weixin-openclaw-package/package/src/monitor/monitor.ts` +- `资料/weixin-openclaw-package/package/src/messaging/inbound.ts` +- `资料/weixin-openclaw-package/package/src/messaging/process-message.ts` +- `资料/weixin-openclaw-package/package/src/messaging/send.ts` +- `资料/weixin-openclaw-package/package/src/messaging/send-media.ts` +- `资料/weixin-openclaw-package/package/src/cdn/upload.ts` +- `资料/weixin-openclaw-package/package/src/media/media-download.ts` +- `资料/weixin-openclaw-package/package/README.zh_CN.md` + +### 3. OpenClaw 飞书插件参考 + +- `资料/feishu-openclaw-plugin/package/index.js` +- `资料/feishu-openclaw-plugin/package/openclaw.plugin.json` +- `资料/feishu-openclaw-plugin/package/src/commands/index.js` + +飞书插件在本任务中的作用不是“代码复用”,而是“组织方式参考”:诊断命令、onboarding、插件命令入口、能力分层。不要试图把其 OpenClaw runtime 逻辑直接复制进 CodePilot。 + +## 单次交付约束 + +- 这次交付必须一次性打通:数据层、适配器层、设置 API、Bridge UI、基础测试、文档。 +- 不允许停在“只接协议 helper”或“只做设置页”。 +- 代码写完后必须执行至少: + - `npm run test` + - `npm run test:smoke` + - 启动 `PORT=3001 npm run dev` + - 用 CDP 打开 Bridge 页面验证微信设置 UI、账号列表和连接流程界面 +- 若因缺少真实微信账号无法做真人扫码联调,必须在结果里明确说明“已完成代码、自测和模拟验证,但真实扫码登录未实测”,不能假装已经验证过。 + +## 总体设计 + +### 1. 总体实现形态 + +采用 **CodePilot 原生 `BaseChannelAdapter` 实现**: + +- 新建微信协议 helper、登录 helper、媒体 helper。 +- 新建 `WeixinAdapter` 负责多账号长轮询、消息标准化、文本出站、typing 指示和状态跟踪。 +- 不引入 `openclaw/plugin-sdk`,不在运行时调用 OpenClaw 包导出的任何插件接口。 + +### 2. 多账号模型 + +微信与 Telegram/QQ 最大不同在于: + +- 一个 CodePilot 实例可能同时登录多个微信 bot 账号。 +- 同一个 `peerUserId` 在不同 bot 账号下必须拥有不同上下文和不同 binding。 + +因此必须采用合成路由键: + +- `syntheticChatId = "weixin::::"` +- `address.channelType = "weixin"` +- `address.chatId = syntheticChatId` +- `address.userId = peerUserId` +- `address.displayName = peerUserId` + +这样做的好处: + +- 复用现有 `channel_bindings` 唯一键 `(channel_type, chat_id)`,无需改表。 +- 同一 `peerUserId` 在不同 bot 账号下会自然落到不同 `chat_session`。 +- `bridge-manager` 和 `channel-router` 无需感知“多账号”概念,只处理普通地址。 + +必须新增一个 helper,例如: + +- `encodeWeixinChatId(accountId, peerUserId): string` +- `decodeWeixinChatId(chatId): { accountId: string; peerUserId: string }` + +所有微信 adapter、API、UI、permission fallback、日志都必须统一使用这套 helper,禁止散落字符串拼接。 + +### 3. 数据持久化设计 + +#### 3.1 继续复用的表 + +- `channel_bindings` + 继续保存微信会话绑定,key 为合成 `chatId` +- `channel_offsets` + 继续保存 `get_updates_buf` + key 格式:`weixin:` +- `channel_audit_logs` + 继续记录入站/出站消息摘要 + +#### 3.2 必须新增的表 + +新增 `weixin_accounts`: + +- `account_id TEXT PRIMARY KEY` +- `user_id TEXT NOT NULL DEFAULT ''` +- `base_url TEXT NOT NULL DEFAULT ''` +- `cdn_base_url TEXT NOT NULL DEFAULT ''` +- `token TEXT NOT NULL DEFAULT ''` +- `name TEXT NOT NULL DEFAULT ''` +- `enabled INTEGER NOT NULL DEFAULT 1` +- `last_login_at TEXT` +- `created_at TEXT NOT NULL DEFAULT (datetime('now'))` +- `updated_at TEXT NOT NULL DEFAULT (datetime('now'))` + +用途: + +- 保存二维码登录后的 bot token 与账号配置 +- 支撑账号列表 UI +- 支撑多账号轮询启动/停止 + +新增 `weixin_context_tokens`: + +- `account_id TEXT NOT NULL` +- `peer_user_id TEXT NOT NULL` +- `context_token TEXT NOT NULL` +- `updated_at TEXT NOT NULL DEFAULT (datetime('now'))` +- `PRIMARY KEY(account_id, peer_user_id)` + +用途: + +- 持久化最近一次可用的 `context_token` +- 供出站回复、Bridge 自动恢复、账号重启后续答使用 + +不新增 sync buf 专用表,原因: + +- `channel_offsets.offset_value` 已经是 `TEXT` +- `get_updates_buf` 本身就是字符串 +- 只要 offset key 改成 per-account 即可 + +#### 3.3 DB 层 helper + +在 `src/lib/db.ts` 中新增 helper: + +- `listWeixinAccounts()` +- `getWeixinAccount(accountId)` +- `upsertWeixinAccount(...)` +- `deleteWeixinAccount(accountId)` +- `setWeixinAccountEnabled(accountId, enabled)` +- `getWeixinContextToken(accountId, peerUserId)` +- `upsertWeixinContextToken(accountId, peerUserId, token)` +- `deleteWeixinContextTokensByAccount(accountId)` + +同时补类型: + +- `src/types/index.ts` + - `WeixinAccount` + - `WeixinContextTokenRecord` + +### 4. 微信协议层设计 + +创建独立 helper,不把协议逻辑塞进 adapter 主文件: + +- `src/lib/bridge/adapters/weixin/weixin-types.ts` +- `src/lib/bridge/adapters/weixin/weixin-api.ts` +- `src/lib/bridge/adapters/weixin/weixin-auth.ts` +- `src/lib/bridge/adapters/weixin/weixin-media.ts` +- `src/lib/bridge/adapters/weixin/weixin-ids.ts` +- `src/lib/bridge/adapters/weixin/weixin-session-guard.ts` + +#### 4.1 API helper + +`weixin-api.ts` 直接按 OpenClaw 插件协议实现: + +- `getUpdates` +- `sendMessage` +- `getUploadUrl` +- `getConfig` +- `sendTyping` +- `startLoginQr` +- `pollLoginQrStatus` + +必须复刻的协议细节: + +- `AuthorizationType: ilink_bot_token` +- `Authorization: Bearer ` +- `X-WECHAT-UIN` 随机 uint32 base64 +- 请求 body 携带 `base_info.channel_version` +- `getUpdates` 的客户端超时视为正常空轮询,不应报错终止 +- `errcode = -14` 进入 account 级 pause 状态 + +#### 4.2 登录 helper + +`weixin-auth.ts` 负责: + +- 生成二维码登录会话 +- 长轮询扫码状态 +- 登录成功后写入 `weixin_accounts` +- 提供 API route 所需的 in-memory active session store + +实现要求: + +- 参考 OpenClaw 的 `activeLogins` 思路,但状态放在 CodePilot 服务端的 `globalThis` 挂载,避免 Next dev HMR 丢失 +- session 有 TTL +- 支持二维码过期后刷新 +- `accountId` 正规化使用安全字符串,不在 DB key 中保留危险字符 + +### 5. 媒体处理设计 + +#### 5.1 入站媒体 + +必须支持: + +- 图片 +- 文件 +- 视频 +- 语音 + +处理流程参考 OpenClaw 微信插件: + +1. 从 `item_list` 找可下载媒体 +2. 从 CDN 拉密文 +3. AES-128-ECB 解密 +4. 生成 `FileAttachment` +5. 交给现有 `conversation-engine.ts` + +建议实现方式: + +- 下载后的本地落盘可以先走临时文件,再转成 CodePilot 现有 `FileAttachment { data(base64), name, type }` +- 如果有现成统一媒体保存 helper,可复用;不要引入额外 native 依赖 +- 语音优先尝试转 WAV;若无稳定转码链路,允许先按原始音频附件传入,但要明确 MIME + +#### 5.2 出站媒体 + +本轮可选两种实现方式,优先采用 A: + +- A. 先只实现文本出站,把媒体 helper 写好但不接到 `OutboundMessage` +- B. 若实现者有余力,可为微信 adapter 补齐 `sendImage/sendFile/sendVideo` 底层能力,并预留接口给未来 Bridge message tool 使用 + +无论选 A 还是 B,都必须把以下基础层写好: + +- AES-128-ECB 加密/解密 +- `getUploadUrl` +- CDN PUT 上传 +- download param 回填 + +原因: + +- 入站媒体已经需要解密 +- 将来补出站媒体时不应再重新拆协议 + +### 6. Adapter 设计 + +创建 `src/lib/bridge/adapters/weixin-adapter.ts`,并在 `src/lib/bridge/adapters/index.ts` 注册。 + +#### 6.1 生命周期 + +`WeixinAdapter` 是单实例、多 worker 模型: + +- `start()` + - 读取 `bridge_weixin_enabled` + - 拉取 DB 里所有 `enabled = true` 的微信账号 + - 每个账号启动一个长轮询 worker +- `stop()` + - abort 所有 worker + - 清理 waiters、queue、typing timers、pause 状态 +- `isRunning()` + - 只要至少一个 account worker 正在运行就返回 true + +#### 6.2 worker 模型 + +每个账号一个 polling loop: + +1. 从 `channel_offsets` 取 `weixin:` 的 `get_updates_buf` +2. 循环调用 `getUpdates` +3. 成功时持久化新 `get_updates_buf` +4. 逐条标准化消息并入队 +5. `errcode=-14` 时暂停该账号 1 小时 + +#### 6.3 标准化为 `InboundMessage` + +对于每条微信消息: + +- `messageId` + 优先 `message_id`,没有则退化为 `seq` 或生成值 +- `address.channelType = "weixin"` +- `address.chatId = encodeWeixinChatId(accountId, from_user_id)` +- `address.userId = from_user_id` +- `text` + 从 `item_list` 提取文本,引用消息按 OpenClaw 的 quoted-text 思路展开 +- `attachments` + 若有媒体则转成 `FileAttachment[]` +- `raw` + 保留原始消息和 accountId,便于调试 + +同时必须: + +- 把 `context_token` 写入 `weixin_context_tokens` +- 写审计日志 +- 对 message_id 做 account 级 dedupe,避免重复入队 + +#### 6.4 出站发送 + +`send(message: OutboundMessage)` 必须: + +1. 从 `message.address.chatId` decode 出 `accountId` 和 `peerUserId` +2. 读取该 peer 最近一次 `context_token` +3. 若没有 token,返回明确错误,不要静默失败 +4. 调 `sendMessage` + +文本内容处理: + +- 微信不支持我们当前桥接里的 HTML/Markdown 交互格式 +- 统一走 plain text,必要时做轻量 markdown strip,参考 OpenClaw `markdownToPlainText` + +#### 6.5 typing 指示 + +实现 `onMessageStart` / `onMessageEnd`: + +- `onMessageStart(chatId)` + - decode account + peer + - 读取或缓存 `typing_ticket` + - 调 `sendTyping(status=1)` +- `onMessageEnd(chatId)` + - `sendTyping(status=2)` + +typing ticket 获取逻辑: + +- 按 `accountId + peerUserId` 或至少按 `peerUserId` 缓存 +- 带退避策略,失败后不要阻塞主消息链路 + +#### 6.6 preview / permission + +- 不实现 `getPreviewCapabilities` +- 不实现 callback query +- `permission-broker.ts` 必须把 `weixin` 归类到“无按钮渠道”,与 `qq` 同类 +- `/perm` 文本审批链路保持可用 + +### 7. API 路由设计 + +新增: + +- `src/app/api/settings/weixin/route.ts` + - GET/PUT 全局微信配置 +- `src/app/api/settings/weixin/accounts/route.ts` + - GET 账号列表 +- `src/app/api/settings/weixin/accounts/[accountId]/route.ts` + - DELETE 断开账号 + - PATCH 启停账号 +- `src/app/api/settings/weixin/login/start/route.ts` + - 生成二维码 +- `src/app/api/settings/weixin/login/wait/route.ts` + - 轮询登录结果 + +建议保留在 settings 表中的全局 key: + +- `bridge_weixin_enabled` +- `bridge_weixin_base_url` +- `bridge_weixin_cdn_base_url` +- `bridge_weixin_image_enabled` +- `bridge_weixin_media_enabled` +- `bridge_weixin_log_upload_url` + +说明: + +- 多账号 token 不要进 `settings` 表 +- token 必须只进 `weixin_accounts` + +同时更新: + +- `src/app/api/bridge/settings/route.ts` + - 把上述全局 key 加入白名单 + +### 8. Bridge UI 设计 + +新增: + +- `src/components/bridge/WeixinBridgeSection.tsx` + +修改: + +- `src/components/bridge/BridgeLayout.tsx` + - 增加 `weixin` section +- `src/components/bridge/BridgeSection.tsx` + - 增加 channel 总开关 +- `src/i18n/en.ts` +- `src/i18n/zh.ts` + +UI 必须包含: + +- 微信总开关 +- base URL / CDN base URL 配置 +- “连接微信账号”按钮 +- 当前二维码展示区或轮询状态展示 +- 已登录账号列表 +- 每账号 enabled 开关 +- 每账号断开按钮 +- 基础状态提示:已连接 / 轮询中 / session paused / 最后错误 + +不要求: + +- 花哨动效 +- 复杂诊断页 + +但必须满足: + +- 信息完整 +- 状态可见 +- 刷新后与 DB 一致 + +### 9. 文本审批降级 + +修改 `src/lib/bridge/permission-broker.ts`: + +- 当前 `supportsButtons = adapter.channelType !== 'qq'` +- 必须改成明确把 `weixin` 也归到“无按钮渠道” + +例如: + +- `const supportsButtons = !['qq', 'weixin'].includes(adapter.channelType)` + +否则微信收到的权限消息会被当作按钮卡片发送,但 adapter 根本不会处理回调。 + +### 10. Bridge 帮助与可见状态 + +建议同步更新: + +- `src/lib/bridge/bridge-manager.ts` + - `/help` 文案中增加 `weixin` + - 若有 channel-specific 帮助入口,可增加简单说明 + +不是必须新增 `/weixin` 命令组,但至少总帮助里要让用户知道微信已接入。 + +### 11. 文档更新 + +实现完成后必须更新: + +- `docs/handover/bridge-system.md` + - 增加 Weixin 架构和数据流说明 +- `docs/handover/README.md` + - 如 handover 文档新增内容需要索引则同步更新 +- `docs/research/weixin-openclaw-plugin-review-2026-03-22.md` + - 如实现过程中发现协议差异或实测补充,可追加结论 + +## 建议修改文件清单 + +### 必改 + +- `src/lib/db.ts` +- `src/types/index.ts` +- `src/lib/bridge/adapters/index.ts` +- `src/lib/bridge/adapters/weixin-adapter.ts` +- `src/lib/bridge/permission-broker.ts` +- `src/app/api/bridge/settings/route.ts` +- `src/components/bridge/BridgeLayout.tsx` +- `src/components/bridge/BridgeSection.tsx` +- `src/components/bridge/WeixinBridgeSection.tsx` +- `src/i18n/en.ts` +- `src/i18n/zh.ts` + +### 强烈建议新建 + +- `src/lib/bridge/adapters/weixin/weixin-types.ts` +- `src/lib/bridge/adapters/weixin/weixin-ids.ts` +- `src/lib/bridge/adapters/weixin/weixin-api.ts` +- `src/lib/bridge/adapters/weixin/weixin-auth.ts` +- `src/lib/bridge/adapters/weixin/weixin-media.ts` +- `src/lib/bridge/adapters/weixin/weixin-session-guard.ts` +- `src/app/api/settings/weixin/route.ts` +- `src/app/api/settings/weixin/accounts/route.ts` +- `src/app/api/settings/weixin/accounts/[accountId]/route.ts` +- `src/app/api/settings/weixin/login/start/route.ts` +- `src/app/api/settings/weixin/login/wait/route.ts` +- `src/__tests__/unit/weixin-*.test.ts` + +## 推荐编码顺序 + +下面是单次交付内部的编码顺序,不是阶段拆分: + +1. 先写 DB 迁移和 helper,确保账户、token、context token 有可靠存储。 +2. 再写协议 helper 和 ID helper,把登录、轮询、headers、加密逻辑固定下来。 +3. 然后实现 adapter 并注册,先打通文本收发和多账号轮询。 +4. 接着补入站媒体解密和 `FileAttachment` 转换。 +5. 然后加 settings API 和 Weixin Bridge UI。 +6. 最后补 permission fallback、i18n、文档和测试。 + +## 验收标准 + +- Bridge 首页可见微信通道开关。 +- Bridge 侧边栏可进入独立微信设置页。 +- 用户可以发起二维码登录,并看到二维码或二维码链接。 +- 登录成功后账号写入 DB,并在设置页列表中可见。 +- 启动 Bridge 后,已启用的微信账号会开始长轮询。 +- 私聊文本消息能创建或命中正确的 CodePilot session,并收到回复。 +- 同一个 `peerUserId` 在不同微信账号下不会串 session。 +- 入站图片至少能作为 `FileAttachment` 进入 `conversation-engine.ts`。 +- `context_token` 在应用重启后仍可用于对同一 peer 的正常回复。 +- `permission_request` 在微信中能走 `/perm` 文本审批,不会卡死。 +- 停用账号后对应 worker 停止,不再收消息。 +- 删除账号后 token、context token 和其 offset 能被清理。 + +## 必跑验证 + +### 自动化 + +- `npm run test` +- `npm run test:smoke` + +至少补以下单元测试: + +- `encodeWeixinChatId/decodeWeixinChatId` +- `weixin-api` 头部和 timeout 行为 +- `weixin-session-guard` +- `weixin_accounts` / `weixin_context_tokens` DB helper +- `permission-broker` 对 `weixin` 的无按钮分支 +- `context_token` 持久化读取 + +### 手动 + +- `PORT=3001 npm run dev` +- 打开 Bridge 页面 +- 验证微信 section 可进入 +- 验证设置保存、刷新后回显 +- 验证“连接账号”按钮打开二维码区域 +- 验证账号列表启停/删除交互 +- 检查浏览器 console 无报错 + +### CDP + +UI 改动必须用 CDP: + +- 打开 `http://localhost:3001/bridge` +- 截图 Bridge 首页微信开关状态 +- 截图微信设置页 +- 若二维码区域能显示,截图登录面板 +- 检查 console 无错误 + +## 实现时禁止事项 + +- 不要把 `@tencent-weixin/openclaw-weixin` 直接加到生产依赖后强行调用其插件入口。 +- 不要把 token 继续存在 `settings` 表的平铺 key 中。 +- 不要把 `context_token` 只存在内存里。 +- 不要为多账号去破坏现有 `channel_bindings` 唯一键模型;优先使用合成 `chatId`。 +- 不要因为微信无按钮就跳过权限审批;必须接 `/perm` 降级路径。 +- 不要只做静态 UI,不接入真实 adapter 生命周期。 + +## Claude Code 输出要求 + +Claude Code 实施完成后,输出里必须明确说明: + +- 改了哪些核心文件 +- 是否完成真实扫码联调 +- 跑了哪些测试 +- CDP 验证覆盖了哪些页面 +- 尚未验证的风险点是什么 diff --git a/docs/exec-plans/tech-debt-tracker.md b/docs/exec-plans/tech-debt-tracker.md index ee938020..c27977a2 100644 --- a/docs/exec-plans/tech-debt-tracker.md +++ b/docs/exec-plans/tech-debt-tracker.md @@ -12,6 +12,7 @@ | 2 | `conversation-registry` / `permission-registry` 运行态依赖内存 Map,重启后丢失 | 中 | 长时间运行的会话、Bridge 模式 | 2026-02-26 | | 3 | 消息 fallback 上下文固定最近 50 条,无动态 token 预算截断 | 低 | 长会话上下文质量 | 2026-02-26 | | 4 | `context-storage-migration` Phase 0 剩余:`projects` 表未建、`canUpdateSdkCwd` 未实现 | 低 | 多项目隔离 | 2026-03-04 | +| 5 | Bridge 的 `/mode plan` 在会话权限档位为 `full_access` 时仍会被 `bypassPermissions` 覆盖,导致 Plan 语义失效;需让 bridge 与桌面聊天一致,显式以 Plan 优先于 full_access | 中 | Bridge 远程会话的权限/安全语义 | 2026-03-25 | ## 已解决 diff --git a/docs/handover/bridge-system.md b/docs/handover/bridge-system.md index 8520396d..b48f4fc2 100644 --- a/docs/handover/bridge-system.md +++ b/docs/handover/bridge-system.md @@ -2,7 +2,7 @@ ## 核心思路 -让用户通过 Telegram(后续可扩展 Discord/飞书等)远程操控 CodePilot 中的 Claude 会话。复用现有 `streamClaude()` 管线,在服务端消费 SSE 流,而非通过浏览器。 +让用户通过 Telegram、Discord、飞书、微信等 IM 通道远程操控 CodePilot 中的 Claude 会话。Bridge 复用现有 `streamClaude()` 管线,在服务端直接消费 SSE 流,而不是依赖浏览器标签页。 ## 目录结构 @@ -24,6 +24,13 @@ src/lib/bridge/ │ ├── telegram-adapter.ts # Telegram 长轮询 + offset 安全水位 + 图片/相册处理 + 自注册 │ ├── telegram-media.ts # Telegram 图片下载、尺寸选择、base64 转换 │ ├── telegram-utils.ts # callTelegramApi / sendMessageDraft / escapeHtml / splitMessage +│ ├── weixin-adapter.ts # 微信多账号长轮询 + batch ack + 纯文本出站 + 自注册 +│ ├── weixin/ +│ │ ├── weixin-api.ts # 微信 ilink 协议客户端(getupdates/sendmessage/sendtyping/getconfig) +│ │ ├── weixin-auth.ts # 二维码登录 + HMR 安全会话存储 +│ │ ├── weixin-media.ts # AES-128-ECB 媒体解密/上传 +│ │ ├── weixin-ids.ts # synthetic chatId 编解码(weixin::::) +│ │ └── weixin-session-guard.ts # errcode -14 暂停保护 │ ├── feishu-adapter.ts # 薄代理 → ChannelPluginAdapter(FeishuChannelPlugin) │ └── discord-adapter.ts # Discord.js Client + Gateway intents + 按钮交互 + 流式预览 + 自注册 ├── markdown/ @@ -110,6 +117,77 @@ Discord 消息 → discord.js Client (Gateway WebSocket) - **授权默认拒绝**:空白允许列表 = 拒绝所有(安全优先,同飞书模式) - **`!` 命令别名**:在 adapter 层规范化为 `/` 命令后入队——bridge-manager 命令处理器无需改动 +### 微信(Native BaseChannelAdapter + 多账号长轮询) + +``` +微信消息 → WeixinAdapter.runPollLoop(account) + → getupdates(long-poll, get_updates_buf) + → context_token 落库(weixin_context_tokens) + → 媒体解密(AES-128-ECB,可按设置关闭) + → synthetic chatId = weixin:::: + → enqueue(InboundMessage, updateId=batchId) + → BridgeManager.runAdapterLoop() → handleMessage() + → channel-router.resolve() 自愈坏 cwd / stale sdkSessionId + → conversation-engine.processMessage() 用有效 cwd 调 streamClaude() + → permission_request → `/perm allow|allow_session|deny ` 文本降级 + → deliverResponse() 纯文本分片(4096 chars, 最多 5 段) + → sendmessage({ msg, base_info }) + → handleMessage() finally + → adapter.acknowledgeUpdate(batchId) + → batch sealed + remaining=0 + → channel_offsets["weixin:"] = get_updates_buf +``` + +**关键文件** +- `src/lib/bridge/adapters/weixin-adapter.ts`:微信主 adapter。每个启用账号一个 poll worker,负责入站标准化、batch ack、typing、纯文本出站。 +- `src/lib/bridge/adapters/weixin/weixin-api.ts`:协议客户端。对齐 OpenClaw 微信插件协议,但不把其 npm 包作为运行时依赖。 +- `src/lib/bridge/adapters/weixin/weixin-auth.ts`:二维码登录,使用 `globalThis` 保存活跃登录会话以穿过 Next.js HMR。 +- `src/lib/bridge/adapters/weixin/weixin-media.ts`:微信 CDN 媒体下载/上传的 AES-128-ECB 加解密。 +- `src/lib/bridge/adapters/weixin/weixin-ids.ts`:`weixin::::` synthetic chatId 编解码。 +- `src/lib/bridge/adapters/weixin/weixin-session-guard.ts`:`errcode = -14` 会话失效时暂停账号 60 分钟,避免无限重试。 + +**为什么用 synthetic chatId** +- 微信 bridge 需要多账号并存,但 `channel_bindings` 表没有单独的 account 维度。 +- 方案是把账号隔离编码进 chatId:`weixin::::`。 +- 这样 `channel-router`、`permission-broker`、`delivery-layer` 和审计日志都可以继续复用原来的单 chat 抽象,不需要额外改 schema。 + +**数据持久化** +- `weixin_accounts`:账号凭据、bot token、base URL、启用状态、最后登录时间。 +- `weixin_context_tokens`:按 `(account_id, peer_user_id)` 持久化 `context_token`。这是微信主动回消息的硬前置条件,不能只放内存。 +- `channel_offsets`:使用 key `weixin:` 保存每个账号各自的 `get_updates_buf`。 + +**二维码登录与运行时刷新** +- `/api/settings/weixin/login/start` 生成二维码;服务端读取微信返回的 `qrcode_img_content` URL,再用 `qrcode` 渲染为 data URL,前端无需额外跳转或依赖外链图片。 +- `/api/settings/weixin/login/wait` 轮询扫码状态。状态变成 `confirmed` 后,账号会落库到 `weixin_accounts`,并在 bridge 正在运行时自动调用 `bridge-manager.restart()`,让新账号立即加入 worker 池。 +- 账号启用/停用/删除也会走同样的 restart 流程;如果 DB 改动成功但 runtime 重启失败,API 会显式返回错误,前端 toast 告知“已保存但运行态未切换”。 + +**出站协议与成功判定** +- `sendmessage` 请求体必须是 `{ msg, base_info }`,其中 `msg` 包含 `to_user_id`、`client_id`、`message_type`、`message_state`、`item_list`、`context_token`。 +- 不能依赖服务端返回 `message_id` 判成功。当前实现本地生成 `client_id`,HTTP 成功即视为投递成功,并把 `client_id` 作为 bridge 层的 `messageId`。 +- 微信只支持纯文本出站,所以 `bridge-manager.deliverResponse()` 会先做纯文本分片。Markdown / HTML 不走专门渲染器。 + +**cursor / ack 语义** +- 微信 worker 读到 `get_updates_buf` 后不会立即写库,而是先给本批消息分配 `batchId`。 +- 每条消息处理完成后,`bridge-manager.handleMessage()` 在 `finally` 中调用 `adapter.acknowledgeUpdate(batchId)`。 +- 只有当该 batch 被 `sealed` 且 `remaining = 0` 时,才真正把 cursor 提交到 `channel_offsets`。 +- 这样即使 Claude 处理、权限审批或微信出站在中途失败,也不会把上游 cursor 提前推进,避免静默丢消息。 + +**cwd 自愈与 resume 清理** +- 微信实现过程中补齐了 bridge 的 cwd 自愈链:`session.sdk_cwd` → `binding.workingDirectory` → `session.working_directory` → `bridge_default_work_dir` → `HOME/process cwd`。 +- `channel-router.resolve()` 会在每次消息到来时校验目录是否存在,并在回退到默认目录/Home 时清空 binding/session 上的 `sdk_session_id`,避免拿坏会话强行 resume。 +- `conversation-engine` 与 `claude-client` 也会再做一层防线:如果 cwd 已回退,不再尝试 resume 旧 Claude session。 + +**typing / 媒体 / 权限** +- typing 是 best-effort:先用 `getconfig(ilink_user_id, context_token)` 取 `typing_ticket`,再调用 `sendtyping`。失败不影响主流程。 +- 入站媒体可按 `bridge_weixin_media_enabled` 开关控制。开启时会把图片/文件/视频/语音下载、解密并转换成 `FileAttachment`,复用现有 vision / 文件上下文管线。 +- 微信没有按钮和消息编辑能力,权限审批统一降级为文本命令:`/perm allow|allow_session|deny `。 + +**当前限制** +- 仅支持私聊,不支持群聊语义。 +- 不支持流式预览;微信端无法像 Telegram/飞书那样持续编辑同一条消息。 +- 当前版本只做文本出站,AI 主动发图/发文件尚未接通。 +- 真实扫码联调依赖具备 ilink bot 权限的微信账号。 + ### Telegram ``` @@ -225,16 +303,24 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i **19. Telegram 通知模式互斥** `telegram-bot.ts` 的通知功能(UI 会话通知)与 bridge 模式互斥。通过 `globalThis.__codepilot_bridge_mode_active` 标志协调(存 globalThis 防 HMR 重置)。Bridge 启动时设 `true`,4 个 notify 函数检查此标志后提前返回。 +**20. 微信 `context_token` 必须持久化** +微信不是“只靠 chatId 就能主动回消息”的协议。`sendmessage` 依赖最近一次入站消息带来的 `context_token`,所以必须把 `(account_id, peer_user_id) -> context_token` 持久化到 `weixin_context_tokens`。只放内存会在进程重启后导致“能收消息、不能回消息”。 + +**21. 坏 cwd 不能继续 resume Claude 会话** +Bridge 绑定和 chat session 中可能残留已经删除的 `working_directory` / `sdk_cwd`。一旦用坏目录继续携带旧 `sdk_session_id` 调 `streamClaude()`,Claude 子进程会在错误项目上下文里瞬间退出。当前修复要求在 cwd 回退时同步清空 binding/session 的 `sdk_session_id`,并把修正后的 cwd 回写 DB。 + ## 设置项(settings 表) | Key | 说明 | |-----|------| | remote_bridge_enabled | 总开关 | | bridge_telegram_enabled | Telegram 通道开关 | +| bridge_weixin_enabled | 微信通道开关 | +| bridge_weixin_media_enabled | 微信入站媒体下载开关(默认 true;关闭后只收文本) | | bridge_auto_start | 服务启动时自动拉起桥接 | | bridge_default_work_dir | 新建会话默认工作目录 | | bridge_default_model | 新建会话默认模型 | -| bridge_default_provider_id | 新建会话默认服务商 | +| bridge_default_provider_id | 新建会话默认服务商(Bridge 系统独立设置,与全局默认模型的 `global_default_model_provider` 分离;Bridge 会话使用此值而非全局默认) | | telegram_bridge_allowed_users | 白名单用户 ID(逗号分隔) | | bridge_telegram_image_enabled | Telegram 图片接收开关(默认 true,设为 false 关闭) | | bridge_telegram_max_image_size | 图片大小上限(字节,默认 20MB) | @@ -262,6 +348,11 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i | /api/bridge | POST | `{ action: 'start' \| 'stop' \| 'auto-start' }` | | /api/bridge/channels | GET | 列出活跃通道(支持 `?active=true/false` 过滤) | | /api/bridge/settings | GET/PUT | 读写 bridge 设置 | +| /api/settings/weixin | GET/PUT | 读写微信全局设置(当前仅开关和媒体选项) | +| /api/settings/weixin/accounts | GET | 列出微信账号(token 脱敏,只返回 `has_token`) | +| /api/settings/weixin/accounts/[accountId] | PATCH/DELETE | 启停或删除微信账号;bridge 运行中会同步 restart | +| /api/settings/weixin/login/start | POST | 创建二维码登录会话并返回二维码图片 | +| /api/settings/weixin/login/wait | POST | 轮询二维码状态;确认后自动尝试重启 bridge | ## Telegram 命令 @@ -281,10 +372,16 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i - `src/lib/telegram-bot.ts` — 通知模式(UI 发起会话的通知),与 bridge 模式互斥 - `src/lib/permission-registry.ts` — 权限 Promise 注册表,bridge 和 UI 共用 - `src/lib/claude-client.ts` — streamClaude(),bridge 和 UI 共用 -- `src/components/bridge/BridgeSection.tsx` — Bridge 设置 UI(一级导航 /bridge),含 Telegram/飞书通道开关 -- `src/components/bridge/BridgeLayout.tsx` — 侧边栏导航(Telegram + Feishu 入口) +- `src/components/bridge/BridgeSection.tsx` — Bridge 设置 UI(一级导航 /bridge),含 Telegram/微信/飞书通道开关 +- `src/components/bridge/BridgeLayout.tsx` — 侧边栏导航(Telegram / 微信 / 飞书 入口) - `src/components/bridge/TelegramBridgeSection.tsx` — Telegram 凭据 + 白名单设置 UI(/bridge#telegram) +- `src/components/bridge/WeixinBridgeSection.tsx` — 微信设置 UI:账号列表、二维码登录、运行态错误提示(/bridge#weixin) - `src/components/bridge/FeishuBridgeSection.tsx` — 飞书设置 UI:凭据 + 访问与行为(2 卡片 2 保存按钮 + 脏状态追踪) +- `src/app/api/settings/weixin/route.ts` — 微信全局设置 API(当前仅 `bridge_weixin_enabled` / `bridge_weixin_media_enabled`) +- `src/app/api/settings/weixin/accounts/route.ts` — 微信账号列表 API +- `src/app/api/settings/weixin/accounts/[accountId]/route.ts` — 微信账号启停/删除 API(带 bridge restart 语义) +- `src/app/api/settings/weixin/login/start/route.ts` — 微信二维码登录启动 API +- `src/app/api/settings/weixin/login/wait/route.ts` — 微信二维码状态轮询 API(confirmed 后自动 restart bridge) - `src/app/api/settings/feishu/route.ts` — 飞书设置读写 API(简化后 10 个 key) - `src/app/api/settings/feishu/verify/route.ts` — 飞书凭据验证 API(测试 token 获取 + bot info) - `src/lib/channels/` — V2 ChannelPlugin 架构(见目录结构) diff --git a/docs/handover/global-default-model.md b/docs/handover/global-default-model.md new file mode 100644 index 00000000..97ac3ffd --- /dev/null +++ b/docs/handover/global-default-model.md @@ -0,0 +1,58 @@ +# 全局默认模型 + +## 概述 +v0.38.4 引入全局默认模型机制,替代了之前的"默认服务商"概念。用户在设置页选择一个模型(自带所属 provider),新对话自动使用该模型。已有对话不受影响。 + +## 数据存储 +- `global_default_model` — settings 表,存储默认模型 ID(如 'kimi-k2.5') +- `global_default_model_provider` — settings 表,存储默认模型所属的 provider ID +- `default_provider_id` — settings 表,legacy 兼容字段,由 setDefaultProviderId() 同步写入 + +## 读写函数 +- `getDefaultProviderId()` — 优先读 global_default_model_provider,fallback 到 legacy default_provider_id +- `setDefaultProviderId(id)` — 三写:default_provider_id + global_default_model_provider + 清空 global_default_model +- `getProviderOptions('__global__')` / `setProviderOptions('__global__')` — 读写全局默认模型设置 + +## 新对话模型选择优先级 +1. 全局默认模型(global_default_model + global_default_model_provider 都有效) +2. 全局默认 provider 但 model 被清空(如 doctor repair 后)→ 该 provider 的第一个可用模型 +3. localStorage 的 last-model / last-provider-id +4. groups[0] 的第一个模型 + +## 已有对话 +- 已有对话始终使用 session 自己存储的 model 和 provider_id +- MessageInput 的自动纠正逻辑只 fallback 到 modelOptions[0],不使用全局默认模型 +- 全局默认模型变化不会影响已有对话 + +## provider-resolver 中的归属校验 +- env 分支和 DB provider 分支都会检查 global_default_model_provider 是否与当前 provider ID 一致 +- 不一致时忽略全局默认模型,防止 A 服务商的模型串到 B 服务商 + +## 竞态防护 +- 新对话页 currentModel/currentProviderId 初始为空 +- modelReady 状态门控:fetch 完成前禁止发送 +- provider-changed 事件触发时先 setModelReady(false),fetch 完成后所有分支都 setModelReady(true) + +## UI +- 设置页:连接诊断卡片内,分割线下方,左边标题+描述,右边 select(w-[160px]) +- 聊天输入框:当前模型是默认模型时显示"默认"/"Default" tag +- 模型选择下拉框:默认模型旁标"默认"/"Default" tag + +## 关键文件 +- src/lib/db.ts — getDefaultProviderId, setDefaultProviderId, getProviderOptions('__global__'), setProviderOptions('__global__') +- src/lib/provider-resolver.ts — 模型解析优先级链中的归属校验 +- src/app/chat/page.tsx — 新对话初始化 + checkProvider + modelReady 门控 +- src/components/settings/ProviderManager.tsx — 全局默认模型 select UI + handleGlobalDefaultModelChange +- src/components/chat/ModelSelectorDropdown.tsx — "默认" tag 显示 +- src/hooks/useProviderModels.ts — globalDefaultModel / globalDefaultProvider +- src/components/chat/MessageInput.tsx — 自动纠正逻辑(不使用全局默认) + +## 与 Bridge 系统的关系 +Bridge 系统使用独立的 bridge_default_provider_id,与全局默认模型分离。 + +## 测试覆盖 +- src/__tests__/unit/provider-resolver.test.ts — Global Default Model describe block(7 条测试) + - env provider 归属正确/不正确 + - DB provider 归属正确/不正确 + - explicit model / session model 覆盖 +- src/__tests__/unit/stale-default-provider.test.ts — stale default 清理(隔离 global_default_model_provider) diff --git a/docs/handover/provider-error-doctor.md b/docs/handover/provider-error-doctor.md index 1aa8fa3a..e8691fcf 100644 --- a/docs/handover/provider-error-doctor.md +++ b/docs/handover/provider-error-doctor.md @@ -58,8 +58,9 @@ provider-changed 事件 → 同步 localStorage onProviderModelChange → 写入 localStorage ``` -**新增 provider 自动设默认流程:** -- 首个 provider → 自动设为默认 +**全局默认模型机制:** +- 不再有独立的"默认服务商"概念,改为使用全局默认模型(`global_default_model` + `global_default_model_provider`)决定新对话的 provider 和 model +- 首个 provider → 自动设为全局默认模型的 provider - 已有 provider → `confirm()` 询问是否切换 - 写入 DB (`set-default` API) + localStorage @@ -100,7 +101,7 @@ GET /api/doctor → runDiagnosis() | 动作 | 触发条件 | 效果 | |------|---------|------| -| set-default-provider | provider.no-default | 设置第一个 provider 为默认 | +| set-default-provider | provider.no-default | 设置第一个 provider 为默认;同时写入 `default_provider_id` 和 `global_default_model_provider`,并清空 `global_default_model`(旧 model 不再有效) | | apply-provider-to-session | auth.resolved-no-creds | 将默认 provider 赋给无 provider 的 session | | clear-stale-resume | features.stale-session-id | 清理所有 stale sdk_session_id | | switch-auth-style | auth.style-mismatch | 在 provider extra_env 中切换 API_KEY ↔ AUTH_TOKEN | @@ -134,6 +135,7 @@ GET /api/doctor → runDiagnosis() | `src/app/api/doctor/repair/route.ts` | 修复 API(5 种动作) | | `src/app/api/doctor/export/route.ts` | 脱敏日志导出 | | `src/app/api/providers/set-default/route.ts` | 设置默认 provider | +| `src/lib/db.ts` | `getDefaultProviderId()` / `setDefaultProviderId()` — 默认服务商读写 | | `src/components/settings/ProviderDoctorDialog.tsx` | 诊断 UI | | `src/components/settings/ProviderManager.tsx` | 诊断入口(设置项样式) | diff --git a/docs/research/README.md b/docs/research/README.md index 112c8809..79f78f92 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -8,6 +8,8 @@ | 文件 | 主题 | |------|------| +| chat-latency-investigation-2026-03-20.md | 聊天响应变慢问题排查报告(用户设置 / MCP / resume 链路) | | chat-sdk-integration-feasibility.md | Vercel Chat SDK 集成可行性调研 | | context-storage-migration-plan.md | 上下文共享与存储迁移设计(详细方案;执行跟踪见 `docs/exec-plans/active/context-storage-migration.md`) | | mobile-remote-control-overall-plan.md | 移动端远程控制整体方案(Host / Controller / Lease / 多设备控制) | +| weixin-openclaw-plugin-review-2026-03-22.md | OpenClaw 微信插件拆包与 CodePilot 逆向集成可行性调研 | diff --git a/docs/research/chat-latency-investigation-2026-03-20.md b/docs/research/chat-latency-investigation-2026-03-20.md new file mode 100644 index 00000000..6b1e0886 --- /dev/null +++ b/docs/research/chat-latency-investigation-2026-03-20.md @@ -0,0 +1,249 @@ +# Chat Latency Investigation (2026-03-20) + +## 结论 + +这次“所有模型都要等十几秒才开始回”的问题,主因不是前端渲染,也不是 assistant workspace 的索引逻辑,而是聊天请求在真正进入模型生成前,继承了过重的 Claude Code 运行环境: + +1. **CodePilot 对所有 provider 都加载用户级 Claude 设置**,当前机器的 `~/.claude/settings.json` 开着: + - `alwaysThinkingEnabled: true` + - `effortLevel: "high"` +2. **每次请求还会带上 MCP / plugin / hook 生态**,其中项目级 `.mcp.json` 里有: + - `chrome-devtools`: `npx -y chrome-devtools-mcp@latest` +3. **resume 路径在发出任何可见 SSE 之前就会阻塞等待第一条 SDK 消息**,导致用户体感是“完全没反应”。 + +综合判断: + +- **最高优先级根因**:用户级 Claude 设置泄漏进 CodePilot,会把所有 provider 都拉进高 effort / always-thinking 路径。 +- **第二优先级放大器**:MCP server 初始化过重,尤其是 `npx -y ...@latest` 这种每次可能触发包解析/网络探测的配置。 +- **第三优先级体感问题**:resume / init 阶段没有尽早给 UI 可见状态,放大了“卡住”的感觉。 + +## 证据 + +### 1. 所有 provider 都会加载用户级 Claude 设置 + +代码里对已配置 provider 的 `settingSources` 固定返回: + +- `['user', 'project', 'local']` + +位置: + +- [`src/lib/provider-resolver.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/provider-resolver.ts) + +同时 SDK 只在显式传入时才追加 `--effort`: + +- [`src/lib/claude-client.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/claude-client.ts) +- [`node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs`](/Users/guohao/Documents/code/codepilot/CodePilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs) + +本机实际用户设置: + +- `~/.claude/settings.json` + - `alwaysThinkingEnabled: true` + - `effortLevel: "high"` + - `enabledPlugins.code-simplifier@claude-plugins-official: true` + - 多个 hooks(`SessionStart` / `UserPromptSubmit` / `Stop` / `PreCompact`) + - `statusLine.command: "npx -y ccstatusline@latest"` + +这意味着: + +- 即使 CodePilot UI 没主动把 effort 选到 high,只要没有显式覆盖,SDK 很可能仍继承用户级默认值。 +- 这是**跨模型、跨 provider**都成立的统一慢路径。 + +### 2. assistant workspace 不是主要瓶颈 + +当前活跃 assistant workspace: + +- `/Users/guohao/Documents/op7418的仓库` + +我对这条链路做了本机拆分测量,结果如下: + +- `walkDir`: 12.6ms +- `read manifest`: 1.8ms +- `parse manifest`: 2.1ms +- `read chunks`: 54.8ms +- `parse chunks`: 40.6ms +- `build manifest map`: 0.3ms +- `build chunks map`: 4.5ms +- `stat compare`: 3.1ms + +总量级大约 **120ms**。 + +另外: + +- workspace 总文件数:915 +- 可索引的 `.md/.txt/.markdown` 文件:592 +- `.assistant/index/chunks.jsonl` 大小:21MB + +结论: + +- assistant workspace 的增量索引和检索确实有成本,但**远远不够解释“十几秒”**。 + +相关位置: + +- [`src/app/api/chat/route.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/app/api/chat/route.ts) +- [`src/lib/assistant-workspace.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/assistant-workspace.ts) +- [`src/lib/workspace-indexer.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/workspace-indexer.ts) +- [`src/lib/workspace-retrieval.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/workspace-retrieval.ts) + +### 3. CLI 工具探测也不是主要瓶颈 + +`buildCliToolsContext()` 最终依赖 `detectAllCliTools()`;我按当前 catalog 和 extra bins 做了本机近似测量: + +- 总耗时:**427.7ms** + +这也不是十几秒级。 + +相关位置: + +- [`src/lib/cli-tools-context.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/cli-tools-context.ts) +- [`src/lib/cli-tools-detect.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/cli-tools-detect.ts) + +### 4. MCP 配置明显存在重启动风险 + +当前项目 `.mcp.json`: + +- `chrome-devtools` + - `command: "npx"` + - `args: ["-y", "chrome-devtools-mcp@latest"]` + +当前用户全局 `~/.claude.json`: + +- 全局 `mcpServers.deepwiki` + +当前 `~/.claude.json` 项目配置(`/Users/guohao`)里还有: + +- `figma-dev-mode-mcp-server` +- `context7` + +所以真实聊天环境里,SDK 可能同时面对多组 MCP 来源: + +1. CodePilot 显式注入的 `loadMcpServers()` +2. SDK 通过 `settingSources: ['user','project','local']` 自己再读到的用户/项目配置 + +尤其危险的是: + +- `chrome-devtools-mcp@latest` 不是固定版本 +- 它通过 `npx -y` 启动,天然可能触发 npm 解析/校验/联网 + +本地探针里,执行: + +```bash +npx -y chrome-devtools-mcp@latest --help +``` + +在 10 秒以上仍没有返回结果,说明这个启动路径本身就非常可疑。 + +相关位置: + +- [`src/app/api/chat/route.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/app/api/chat/route.ts) +- [`.mcp.json`](/Users/guohao/Documents/code/codepilot/CodePilot/.mcp.json) + +### 5. resume 路径会放大“没反应”的体感 + +在 resume 分支里,代码会先: + +1. `query(...)` +2. 立即 `await iter.next()` +3. 拿到第一条消息后才继续往前走 + +也就是说: + +- 在 SDK 第一条消息回来之前,前端收不到任何可见 SSE +- 如果 resume 初始化、MCP handshake、plugin/hook 处理慢,UI 会表现为“发送后长时间完全静止” + +而且内部 resume fallback status 又被 `_internal` 过滤掉,用户无感知: + +- [`src/lib/claude-client.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/claude-client.ts) +- [`src/hooks/useSSEStream.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/hooks/useSSEStream.ts) +- [`docs/handover/provider-error-doctor.md`](/Users/guohao/Documents/code/codepilot/CodePilot/docs/handover/provider-error-doctor.md) + +### 6. 数据库里的真实会话也符合“统一变慢” + +我抽了几条最近的简单会话看消息落库时间: + +- `e4528e...`:`你好` -> 助手消息落库,约 9 秒 +- `624de8...`:`你好` -> 助手消息落库,约 14 秒 +- `a2d0cb...`:`妹有,结束` -> 助手消息落库,约 23 秒 + +这些会话分布在不同 provider / 不同目录下,不是单一模型或单一 workspace 才会慢。 + +说明: + +- 问题更像**统一的 SDK 启动/配置层**,不是某个模型本身。 + +## 排查结论排序 + +### P0 + +**用户级 Claude 设置泄漏到 CodePilot** + +- 症状匹配度最高 +- 影响范围最广 +- 和“所有模型都慢”完全一致 + +### P1 + +**MCP 初始化过重,特别是 `chrome-devtools-mcp@latest`** + +- 会统一拖慢首个可见响应 +- 和当前项目配置直接相关 + +### P2 + +**resume 逻辑在首条消息前阻塞,放大感知延迟** + +- 不一定是根因 +- 但会显著恶化体感,并隐藏内部 fallback + +### P3 + +**启动后立刻 `captureCapabilities()`** + +- 会额外打 SDK 控制请求: + - `supportedModels()` + - `supportedCommands()` + - `accountInfo()` + - `mcpServerStatus()` +- 更像放大器,不像唯一根因 + +## 建议修复 + +### 方案 A:先止血 + +1. **不要让 CodePilot 默认继承 `~/.claude/settings.json` 的 thinking / effort** + - 最稳妥:对 provider 会话去掉 `user` setting source + - 或者在 CodePilot 里显式传默认值,例如: + - `thinking: { type: 'disabled' }` 或明确的 UI 默认 + - `effort: 'low'` / `'medium'` +2. **把项目 `.mcp.json` 的 `chrome-devtools-mcp@latest` 改成固定版本** + - 避免每次走 `@latest` +3. **先临时关闭聊天自动注入的 MCP** + - 至少给 plain chat 一个“无 MCP 快速路径” + +### 方案 B:改善体感 + +1. 在 `streamClaude()` 一开始就先发一个本地 `status` + - 比如 `Connecting...` +2. resume 分支不要在 UI 完全无反馈的情况下等待 `iter.next()` +3. 不要把 resume fallback 全部标成 `_internal` + - 至少在 debug 模式或状态栏里可见 + +### 方案 C:进一步优化 + +1. 把 `captureCapabilities()` 延后到首个 assistant event 之后 +2. 给 `/api/chat` 增加服务端耗时日志 + - `preflight_ms` + - `sdk_init_ms` + - `first_event_ms` + - `first_text_ms` + - `complete_ms` + +## 我这次没有做的事 + +- 没有直接对真实 provider 发线上请求测“首 token 时间”,因为当前沙箱环境不适合安全地跑外部网络压测。 +- 所以这份报告里的“主因”是基于: + - 代码链路 + - 本机配置 + - 本地耗时拆分 + - 数据库中的真实会话时间 + +但证据已经足够把范围缩到:**不是前端,不是 assistant workspace,不是 CLI tools context,而是 SDK 继承设置 + MCP 启动 + resume 可见性**。 diff --git a/docs/research/chat-latency-remediation-review-2026-03-22.md b/docs/research/chat-latency-remediation-review-2026-03-22.md new file mode 100644 index 00000000..1a2ebdae --- /dev/null +++ b/docs/research/chat-latency-remediation-review-2026-03-22.md @@ -0,0 +1,153 @@ +# Chat Latency Remediation — Code Review Report + +> Date: 2026-03-22 +> Author: Claude (implementation) / pending Codex review +> Related: [investigation](./chat-latency-investigation-2026-03-20.md) | [exec plan](../exec-plans/active/chat-latency-remediation.md) + +## Summary + +15 files changed, +100 / -45 lines. Covers exec plan Phase 1 (mode convergence) + Phase 2 (MCP persistent toggle) + Phase 3 (first-token latency optimization). Phase 4 (observability) deferred. + +## Changes by Category + +### P0 Fix: Prevent user-level Claude settings from injecting high effort + +**File:** `src/lib/claude-client.ts` + +```diff +- if (effort) { +- queryOptions.effort = effort; +- } ++ queryOptions.effort = effort || 'medium'; +``` + +- **Why:** `~/.claude/settings.json` has `effortLevel: "high"` + `alwaysThinkingEnabled: true`. SDK inherits these via `settingSources: ['user', 'project', 'local']`. Without explicit override, all providers run at high effort. +- **Behavior change:** Default effort is now `medium` for all chats. User-selected effort from UI still takes priority. +- **Not changed:** `thinking` config not overridden this round — more complex interaction with UI states. + +### P1 Fix: MCP persistent enable/disable toggle + +| File | Change | +|------|--------| +| `src/types/index.ts` | Added `enabled?: boolean` to `MCPServerConfig` | +| `src/app/api/chat/route.ts` | `loadMcpServers()` filters `enabled === false` | +| `src/lib/bridge/conversation-engine.ts` | Same filter in bridge's `loadMcpServers()` | +| `src/app/api/plugins/mcp/route.ts` | No change needed — PUT handler's `{ _source, ...cleanServer }` already preserves `enabled` | +| `src/components/plugins/McpServerList.tsx` | Added `Switch` toggle per server card; `opacity-50` when disabled | +| `src/components/plugins/McpManager.tsx` | Added `handlePersistentToggle()` → `PUT /api/plugins/mcp` | +| `src/i18n/en.ts` + `zh.ts` | Added `mcp.enabled` / `mcp.disabled` keys | + +- **Semantics:** `enabled: undefined` and `enabled: true` both mean enabled. Only explicit `false` = disabled. Zero-migration. +- **Scope:** Persistent toggle writes to `~/.claude/settings.json` or `~/.claude.json` (respects `_source`). Runtime toggle (`/api/plugins/mcp/toggle`) kept separate for live reconnect. +- **Note:** Project-level `.mcp.json` servers (like `chrome-devtools`) are not shown in the MCP management UI — they're only managed by editing the file. The filter still applies to them. + +### P1 Fix: Pin .mcp.json version + +```diff +- "chrome-devtools-mcp@latest" ++ "chrome-devtools-mcp@0.20.3", ++ "--headless" +``` + +- **Why:** `npx -y ...@latest` triggers npm registry check on every cold start (measured 10s+). +- **Note:** `--headless` flag was already in the original but lost in a previous edit — restored here. + +### P2 Fix: Resume visible status event + +**File:** `src/lib/claude-client.ts` + +```typescript +if (shouldResume) { + controller.enqueue(formatSSE({ + type: 'status', + data: JSON.stringify({ + title: 'Resuming session', + message: 'Reconnecting to previous conversation...', + }), + })); + queryOptions.resume = sdkSessionId; +} +``` + +- **Why:** Resume path blocks on `await iter.next()` with zero UI feedback. Users see "sent message, nothing happening" for 10+ seconds. +- **No `_internal: true`** — event passes through `useSSEStream.ts` filter and is shown to user. + +### P3 Fix: Defer capability capture + +**File:** `src/lib/agent-sdk-capabilities.ts` + +```typescript +const CACHE_TTL_MS = 5 * 60 * 1000; +export function isCacheFresh(providerId: string = 'env'): boolean { ... } +``` + +**File:** `src/lib/claude-client.ts` + +- `captureCapabilities()` moved from immediately after `registerConversation()` to inside the `for await` loop's first `'assistant'` case. +- Skipped entirely if cache is fresh (within 5-minute TTL). +- **Trade-off:** Model selector may show stale data for up to 5 minutes after provider switch. Acceptable because users rarely switch providers mid-conversation. + +### Phase 1: Chat mode entry convergence + +| File | Change | +|------|--------| +| `src/components/chat/ChatView.tsx` | Removed `initialMode` prop; hardcoded `useState('code')`; removed `useEffect` syncing `initialMode` | +| `src/app/chat/[id]/page.tsx` | Removed `sessionMode` state; removed `initialMode` prop passing | +| `src/components/layout/SplitColumn.tsx` | Same as above | +| `src/app/api/chat/route.ts` | Replaced `effectiveMode` switch with hardcoded `permissionMode = 'acceptEdits'`; `enableFileCheckpointing` defaults to `true` | +| `src/i18n/en.ts` + `zh.ts` | Commented out `messageInput.modeCode` / `messageInput.modePlan` (not deleted, in case bridge UI references them) | + +- **Kept:** `handleModeChange` callback in ChatView (SDK can still push mode changes). `mode/route.ts` API kept for bridge. DB schema unchanged. +- **Kept:** `mode` field in request body parsing (bridge still sends it). + +## What Was NOT Changed + +- DB `mode` column schema (`CHECK(mode IN ('code', 'plan', 'ask'))`) +- Bridge `/mode` command and `conversation-engine.ts` mode handling +- `thinking` config override (only `effort` overridden) +- `settingSources` in `provider-resolver.ts` (SDK still reads user config for plugins/hooks) +- `useSSEStream.ts` `_internal` filter (no change needed) +- Runtime MCP toggle route (`/api/plugins/mcp/toggle`) + +## Test Results + +- **Typecheck:** Pass +- **Unit tests:** 444/444 pass, 0 fail +- **Playwright verification (headless):** + - Chat page: No mode selector (Plan button count: 0) ✅ + - MCP page: 2 switch toggles visible, both `checked` state ✅ + - Server cards display correctly with toggle ✅ + +## Risk Areas for Review + +1. **`effort || 'medium'` override** — If SDK internally applies effort before our explicit value, we might double-set. Verify SDK precedence: explicit `queryOptions.effort` > `settingSources` inheritance. + +2. **`handlePersistentToggle` optimistic update** — Sets state before API call, reverts via `fetchServers()` on failure. Race condition possible if user toggles rapidly. Low severity — worst case is a stale UI state that corrects on next fetch. + +3. **i18n commented keys** — `messageInput.modeCode` / `messageInput.modePlan` are commented out, not deleted. If `TranslationKey` type is derived from the object keys, the type is already removed. Any runtime reference to these keys would return the key itself. Grep confirms no runtime references exist outside i18n files. + +4. **`enableFileCheckpointing` default changed** — From `effectiveMode === 'code'` to `true`. Since desktop chat is now always 'code', this is semantically identical. But if the request body explicitly sends `enableFileCheckpointing: false`, it's still respected. + +5. **Bridge path unaffected** — `conversation-engine.ts` has its own mode resolution at lines 189-194. The `route.ts` mode convergence only affects desktop `/api/chat`. Verify bridge still receives and applies `mode` from IM messages. + +## Diff Stats + +``` +15 files changed, 100 insertions(+), 45 deletions(-) + +.mcp.json 3 ++- +src/app/api/chat/route.ts 29 +++------- +src/app/chat/[id]/page.tsx 5 +--- +src/components/chat/ChatView.tsx 6 ++-- +src/components/layout/SplitColumn.tsx 3 --- +src/components/plugins/McpManager.tsx 18 +++++++ +src/components/plugins/McpServerList.tsx 15 +++++++- +src/i18n/en.ts 6 ++-- +src/i18n/zh.ts 6 ++-- +src/lib/agent-sdk-capabilities.ts 12 ++++++ +src/lib/bridge/conversation-engine.ts 6 +++ +src/lib/claude-client.ts 32 +++++++++++------ +src/types/index.ts 2 ++ +docs/exec-plans/README.md 1 + +docs/research/README.md 1 + +``` diff --git a/docs/research/weixin-openclaw-plugin-review-2026-03-22.md b/docs/research/weixin-openclaw-plugin-review-2026-03-22.md new file mode 100644 index 00000000..5cbbb2e3 --- /dev/null +++ b/docs/research/weixin-openclaw-plugin-review-2026-03-22.md @@ -0,0 +1,316 @@ +# OpenClaw 微信插件拆包与 CodePilot 逆向集成可行性调研 + +> 调研时间:2026-03-22 +> 调研方式:静态拆包 + 本地架构比对,未做真实微信账号登录联调 +> 样本位置: +> - `资料/weixin-openclaw-cli/package/` +> - `资料/weixin-openclaw-package/package/` + +## 一句话结论 + +可以集成,但不建议把 `@tencent-weixin/openclaw-weixin` 这个 npm 包直接塞进 CodePilot 运行。 + +更合适的做法是:把它当成协议说明和参考实现,按 CodePilot 现有 `Bridge` 架构原生实现一个 `weixin` adapter。 + +原因很简单: + +- **协议层可复用度高**:二维码登录、长轮询、发消息、媒体上传下载都写得很清楚。 +- **运行时层耦合很深**:真实业务逻辑大量绑定 `openclaw/plugin-sdk`、OpenClaw 的 routing/session/reply runtime、`~/.openclaw` 状态目录和 pairing 文件格式。 + +## 一、本次拉取到的包 + +### 1. CLI 包 + +- 包名:`@tencent-weixin/openclaw-weixin-cli` +- latest:`1.0.2` +- npm registry 时间:`2026-03-21T14:50:38.585Z` +- 体积很小,解包后只有 `cli.mjs`、`package.json`、`LICENSE` + +### 2. 真正的渠道包 + +- 包名:`@tencent-weixin/openclaw-weixin` +- latest:`1.0.2` +- npm registry 时间:`2026-03-21T15:43:24.503Z` +- License:`MIT` +- 发布内容直接带 TypeScript 源码,便于静态分析 + +## 二、CLI 实际只做了什么 + +CLI 没有任何微信协议逻辑,只是一个薄安装器,流程只有四步: + +1. 检查本机是否存在 `openclaw` CLI +2. 执行 `openclaw plugins install "@tencent-weixin/openclaw-weixin"` +3. 执行 `openclaw channels login --channel openclaw-weixin` +4. 执行 `openclaw gateway restart` + +这意味着: + +- `npx -y @tencent-weixin/openclaw-weixin-cli install` 本身没有可“嵌入”的核心价值 +- 真正值得研究的是 `@tencent-weixin/openclaw-weixin` + +## 三、真实插件的结构 + +### 1. OpenClaw 插件壳 + +插件入口是 `index.ts`,通过 `openclaw/plugin-sdk` 注册: + +- `api.registerChannel({ plugin: weixinPlugin })` +- `api.registerCli(...)` +- `setWeixinRuntime(api.runtime)` + +说明它不是一个独立 Node SDK,而是 **OpenClaw 插件运行时中的一个渠道扩展**。 + +### 2. 最有价值的可复用层 + +真正可借鉴的是下面这几层: + +- `src/auth/login-qr.ts` + 负责二维码登录:获取二维码、轮询扫码状态、拿到 `bot_token` +- `src/api/api.ts` + 负责所有 HTTP JSON API 调用、头部构造、long-poll timeout、错误处理 +- `src/monitor/monitor.ts` + 负责 `getUpdates` 长轮询、同步游标保存、会话过期处理 +- `src/media/` + `src/cdn/` + 负责微信媒体下载/解密/上传/加密 +- `src/auth/accounts.ts` + 负责多账号状态落盘 + +### 3. 深耦合、不能直接拿来跑的层 + +下面这些部分直接绑在 OpenClaw runtime 上: + +- `ChannelPlugin` / `OpenClawPluginApi` +- `PluginRuntime["channel"]` +- `resolveSenderCommandAuthorizationWithRuntime` +- `reply.createReplyDispatcherWithTyping` +- `routing.resolveAgentRoute` +- `session.recordInboundSession` +- pairing 的 `allowFrom` 文件协议 + +结论: + +- **直接安装到 CodePilot 里当依赖运行:不现实** +- **按协议和实现样本重写一个 CodePilot 原生 adapter:可行** + +## 四、微信后端协议已经暴露得足够清楚 + +从 README 和源码可以确认,插件与后端的主链路是 HTTP JSON API: + +### 1. 登录相关 + +- `GET ilink/bot/get_bot_qrcode?bot_type=3` +- `GET ilink/bot/get_qrcode_status?qrcode=...` + +扫码确认后服务端返回: + +- `bot_token` +- `ilink_bot_id` +- `ilink_user_id` +- `baseurl` + +### 2. 消息主链路 + +- `POST ilink/bot/getupdates` +- `POST ilink/bot/sendmessage` +- `POST ilink/bot/getuploadurl` +- `POST ilink/bot/getconfig` +- `POST ilink/bot/sendtyping` + +### 3. 关键请求头 + +- `AuthorizationType: ilink_bot_token` +- `Authorization: Bearer ` +- `X-WECHAT-UIN: <随机 uint32 的 base64>` +- 可选 `SKRouteTag` + +### 4. 关键状态字段 + +- `get_updates_buf` + 长轮询同步游标,必须本地持久化 +- `context_token` + 回复时必须带回去,否则消息无法正确关联会话 +- `typing_ticket` + 由 `getconfig` 返回,供 `sendtyping` 使用 +- `errcode = -14` + 表示 session 过期,插件会暂停该账号 1 小时 + +## 五、媒体链路也已经写明白了 + +微信媒体不是普通 URL 直传,而是: + +1. 本地文件计算明文大小和 MD5 +2. 用 AES-128-ECB 计算密文大小 +3. 调 `getuploadurl` 获取 `upload_param` +4. 本地 AES-128-ECB 加密 +5. PUT 上传到 CDN +6. 把 `encrypt_query_param` + `aes_key` 回填到消息体 + +入站下载同理: + +1. 从消息里拿 `encrypt_query_param` + `aes_key` +2. 从 CDN 拉密文 +3. 本地 AES-128-ECB 解密 +4. 图片/文件/视频直接保存 +5. 语音再尝试 SILK -> WAV 转码 + +这部分对 CodePilot 的意义很大: + +- 我们不用猜协议 +- 也不需要 native 依赖 +- 直接用 Node `crypto` + `fetch` 就能复刻 + +## 六、它当前的限制和隐藏坑 + +### 1. 只支持私聊 + +插件明确声明: + +- `capabilities.chatTypes = ["direct"]` + +当前没有群聊、线程或群策略实现。 + +### 2. `context_token` 只存在内存 Map + +源码里 `contextTokenStore` 是一个进程内 `Map`。 + +这意味着: + +- 当前会话内的正常回复没问题 +- 进程重启后,历史 peer 的 `context_token` 会丢 +- 任何“冷启动主动推送”都可能失败 + +这对 CodePilot 很关键,因为我们已经有: + +- 持久会话 +- bridge 自动启动 +- 未来的 scheduled task / automation + +如果做原生集成,**必须把最近一次可用的 `context_token` 持久化到 DB**,不能照搬它的内存实现。 + +### 3. 多账号能力和我们当前设置模型不完全匹配 + +OpenClaw 微信插件支持: + +- 多个微信号同时在线 +- 每个账号独立 token +- 每个账号独立 sync buf + +而 CodePilot 当前 bridge 设置还是以平面 key-value 为主,例如: + +- `bridge_qq_app_id` +- `bridge_feishu_app_secret` + +这不阻塞集成,但意味着微信这条线至少要新增: + +- 每账号凭证存储 +- 每账号同步游标 +- 每账号状态展示 + +### 4. 直接依赖方式会卡在 OpenClaw runtime + +即便忽略协议层,直接复用包也会卡在这些点: + +- 缺失 `openclaw/plugin-sdk` +- 缺失 OpenClaw 的 runtime object +- 缺失它的 session/routing/reply/pairing 体系 +- 状态文件路径默认写在 `~/.openclaw` + +所以“npm install 然后 require 进 CodePilot”这条路不值得走。 + +## 七、与 CodePilot 现状的匹配度 + +### 1. 能直接复用的现有能力 + +CodePilot 已有这些基础能力,能接住微信适配器: + +- `src/lib/bridge/` 的统一 adapter 生命周期 +- `consumeOne()` 队列消费模型 +- `conversation-engine.ts` 的图片附件入站处理 +- `channel_bindings` / `channel_offsets` / 审计日志 +- 现有 Telegram/QQ 的图片下载经验 + +### 2. 明确缺口 + +需要新增或改造的点: + +- 微信专用设置页和 API +- 二维码登录 API / UI +- 每账号状态与账号列表 +- `context_token` 持久化 +- `get_updates_buf` 的账号级存储键设计 +- 微信媒体加解密层 +- 如果想支持“AI 主动发送图片/文件”,需要扩展当前 `OutboundMessage` + +## 八、可行的集成路线 + +### 方案 A:直接嵌 npm 包 + +不推荐。 + +原因: + +- 运行时耦合太深 +- 会把 OpenClaw runtime 假设硬塞进 CodePilot +- 后续调试成本会很高 + +### 方案 B:原生实现 `weixin` adapter + +推荐。 + +建议拆成下面几块: + +1. `src/lib/bridge/adapters/weixin-api.ts` + 封装 `getupdates/sendmessage/getuploadurl/getconfig/sendtyping` +2. `src/lib/bridge/adapters/weixin-auth.ts` + 封装二维码登录、token 保存、多账号 +3. `src/lib/bridge/adapters/weixin-media.ts` + 封装 CDN 下载/上传 + AES-128-ECB 加解密 +4. `src/lib/bridge/adapters/weixin-adapter.ts` + 实现 `BaseChannelAdapter` +5. `src/app/api/settings/weixin/*` + 设置、校验、登录、状态 API +6. `src/components/bridge/WeixinBridgeSection.tsx` + 登录/账号/状态 UI + +### MVP 建议范围 + +第一阶段先做: + +- 私聊 +- 文本入站/出站 +- 二维码登录 +- `get_updates_buf` 持久化 +- `context_token` 持久化 +- 入站图片 + +第二阶段再补: + +- 文件/视频/语音 +- 出站媒体 +- 多账号管理 UI +- 诊断 / logs upload + +## 九、最终判断 + +**能做,而且可做性不低。** + +但这个“能做”的前提是: + +- **把腾讯这套包当协议参考,不当运行时依赖** +- **用 CodePilot 自己的 bridge 架构重写微信 adapter** + +如果只问“是否值得往前推进一个 POC”,我的结论是: + +- **值得** +- **适合先做一个 text-only + QR login 的 POC** +- **不建议直接在主产品里硬接 OpenClaw 插件包本体** + +## 十、这次调研没有覆盖的内容 + +下面这些仍然需要后续实测确认: + +- 真机扫码登录是否稳定 +- `context_token` 生命周期和失效条件 +- session 过期后恢复策略是否只能等 1 小时 +- 图片/视频上传在真实账号下的大小限制 +- 多账号并发长轮询时的速率限制 diff --git a/package-lock.json b/package-lock.json index 58087fef..ed584417 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "codepilot", - "version": "0.38.1", + "version": "0.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.38.1", + "version": "0.39.0", + "license": "BUSL-1.1", "workspaces": [ "apps/*", "packages/*" @@ -26,7 +27,8 @@ "@streamdown/cjk": "^1.0.1", "@streamdown/code": "^1.0.1", "@streamdown/math": "^1.0.1", - "@streamdown/mermaid": "^1.0.1", + "@streamdown/mermaid": "1.0.1", + "@types/qrcode": "^1.5.6", "ai": "^6.0.73", "ansi-to-react": "^6.2.6", "better-sqlite3": "^12.6.2", @@ -40,8 +42,9 @@ "morphdom": "^2.7.8", "motion": "^12.33.0", "nanoid": "^5.1.6", - "next": "16.1.6", + "next": "16.2.1", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", @@ -73,17 +76,17 @@ "@types/ws": "^8.18.1", "concurrently": "^9.2.1", "electron": "^40.2.1", - "electron-builder": "^26.7.0", + "electron-builder": "^26.8.1", "esbuild": "^0.27.3", "eslint": "^9", - "eslint-config-next": "16.1.6", + "eslint-config-next": "16.2.1", "husky": "^9.1.7", "lint-staged": "^16.3.2", "tailwindcss": "^4", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", "typescript": "^5", - "wait-on": "^9.0.3" + "wait-on": "^9.0.4" } }, "apps/site": { @@ -100,7 +103,7 @@ "fumadocs-mdx": "^11", "fumadocs-ui": "^15", "lucide-react": "^0.563.0", - "next": "15.3.6", + "next": "15.5.14", "react": "^19", "react-dom": "^19", "shadcn": "^4.0.2", @@ -587,15 +590,15 @@ } }, "apps/site/node_modules/@next/env": { - "version": "15.3.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.6.tgz", - "integrity": "sha512-/cK+QPcfRbDZxmI/uckT4lu9pHCfRIPBLqy88MhE+7Vg5hKrEYc333Ae76dn/cw2FBP2bR/GoK/4DU+U7by/Nw==", + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.14.tgz", + "integrity": "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA==", "license": "MIT" }, "apps/site/node_modules/@next/swc-darwin-arm64": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.5.tgz", - "integrity": "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==", + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.14.tgz", + "integrity": "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg==", "cpu": [ "arm64" ], @@ -609,9 +612,9 @@ } }, "apps/site/node_modules/@next/swc-darwin-x64": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.5.tgz", - "integrity": "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==", + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.14.tgz", + "integrity": "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw==", "cpu": [ "x64" ], @@ -625,9 +628,9 @@ } }, "apps/site/node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.5.tgz", - "integrity": "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==", + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.14.tgz", + "integrity": "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q==", "cpu": [ "arm64" ], @@ -641,9 +644,9 @@ } }, "apps/site/node_modules/@next/swc-linux-arm64-musl": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.5.tgz", - "integrity": "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==", + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.14.tgz", + "integrity": "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ==", "cpu": [ "arm64" ], @@ -657,9 +660,9 @@ } }, "apps/site/node_modules/@next/swc-linux-x64-gnu": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.5.tgz", - "integrity": "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==", + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.14.tgz", + "integrity": "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ==", "cpu": [ "x64" ], @@ -673,9 +676,9 @@ } }, "apps/site/node_modules/@next/swc-linux-x64-musl": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.5.tgz", - "integrity": "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==", + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.14.tgz", + "integrity": "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw==", "cpu": [ "x64" ], @@ -689,9 +692,9 @@ } }, "apps/site/node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.5.tgz", - "integrity": "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==", + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.14.tgz", + "integrity": "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g==", "cpu": [ "arm64" ], @@ -705,9 +708,9 @@ } }, "apps/site/node_modules/@next/swc-win32-x64-msvc": { - "version": "15.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.5.tgz", - "integrity": "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==", + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.14.tgz", + "integrity": "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg==", "cpu": [ "x64" ], @@ -954,16 +957,13 @@ } }, "apps/site/node_modules/next": { - "version": "15.3.6", - "resolved": "https://registry.npmjs.org/next/-/next-15.3.6.tgz", - "integrity": "sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w==", - "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz", + "integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==", "license": "MIT", "dependencies": { - "@next/env": "15.3.6", - "@swc/counter": "0.1.3", + "@next/env": "15.5.14", "@swc/helpers": "0.5.15", - "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -975,19 +975,19 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.3.5", - "@next/swc-darwin-x64": "15.3.5", - "@next/swc-linux-arm64-gnu": "15.3.5", - "@next/swc-linux-arm64-musl": "15.3.5", - "@next/swc-linux-x64-gnu": "15.3.5", - "@next/swc-linux-x64-musl": "15.3.5", - "@next/swc-win32-arm64-msvc": "15.3.5", - "@next/swc-win32-x64-msvc": "15.3.5", - "sharp": "^0.34.1" + "@next/swc-darwin-arm64": "15.5.14", + "@next/swc-darwin-x64": "15.5.14", + "@next/swc-linux-arm64-gnu": "15.5.14", + "@next/swc-linux-arm64-musl": "15.5.14", + "@next/swc-linux-x64-gnu": "15.5.14", + "@next/swc-linux-x64-musl": "15.5.14", + "@next/swc-win32-arm64-msvc": "15.5.14", + "@next/swc-win32-x64-msvc": "15.5.14", + "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", + "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", @@ -2036,54 +2036,42 @@ "license": "MIT" }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", - "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "11.0.3", - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" } }, - "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/@chevrotain/gast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", - "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" } }, - "node_modules/@chevrotain/gast/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", - "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", - "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", - "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", "license": "Apache-2.0" }, "node_modules/@codepilot/site": { @@ -2758,9 +2746,9 @@ } }, "node_modules/@electron/rebuild/node_modules/node-abi": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.26.0.tgz", - "integrity": "sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", + "integrity": "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==", "dev": true, "license": "MIT", "dependencies": { @@ -2813,9 +2801,9 @@ } }, "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "dev": true, "license": "MIT", "dependencies": { @@ -2841,13 +2829,13 @@ } }, "node_modules/@electron/universal/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2866,72 +2854,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/windows-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -4549,29 +4471,6 @@ } } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5113,12 +5012,12 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", - "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz", + "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==", "license": "MIT", "dependencies": { - "langium": "3.3.1" + "langium": "^4.0.0" } }, "node_modules/@modelcontextprotocol/sdk": { @@ -5214,15 +5113,15 @@ } }, "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", + "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", - "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.1.tgz", + "integrity": "sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==", "dev": true, "license": "MIT", "dependencies": { @@ -5230,9 +5129,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz", + "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==", "cpu": [ "arm64" ], @@ -5246,9 +5145,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", + "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", "cpu": [ "x64" ], @@ -5262,9 +5161,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", + "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", "cpu": [ "arm64" ], @@ -5278,9 +5177,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", + "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", "cpu": [ "arm64" ], @@ -5294,9 +5193,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", + "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", "cpu": [ "x64" ], @@ -5310,9 +5209,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", + "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", "cpu": [ "x64" ], @@ -5326,9 +5225,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", + "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", "cpu": [ "arm64" ], @@ -5342,9 +5241,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", + "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", "cpu": [ "x64" ], @@ -8282,12 +8181,6 @@ "react": "^18.0.0 || ^19.0.0" } }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -9192,6 +9085,15 @@ "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", @@ -9501,13 +9403,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -9846,6 +9748,16 @@ "win32" ] }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@use-gesture/core": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", @@ -9951,9 +9863,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -10022,9 +9934,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -10250,9 +10162,9 @@ "license": "MIT" }, "node_modules/app-builder-lib": { - "version": "26.7.0", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.7.0.tgz", - "integrity": "sha512-/UgCD8VrO79Wv8aBNpjMfsS1pIUfIPURoRn0Ik6tMe5avdZF+vQgl/juJgipcMmH3YS0BD573lCdCHyoi84USg==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", + "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -10267,7 +10179,7 @@ "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", - "builder-util": "26.4.1", + "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", @@ -10275,7 +10187,7 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.6.0", + "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", @@ -10297,8 +10209,8 @@ "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.7.0", - "electron-builder-squirrel-windows": "26.7.0" + "dmg-builder": "26.8.1", + "electron-builder-squirrel-windows": "26.8.1" } }, "node_modules/app-builder-lib/node_modules/@electron/get": { @@ -10348,6 +10260,29 @@ "semver": "bin/semver.js" } }, + "node_modules/app-builder-lib/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/app-builder-lib/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/app-builder-lib/node_modules/ci-info": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", @@ -10403,26 +10338,26 @@ } }, "node_modules/app-builder-lib/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/app-builder-lib/node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -10791,13 +10726,13 @@ } }, "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -11068,9 +11003,9 @@ "license": "MIT" }, "node_modules/builder-util": { - "version": "26.4.1", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.4.1.tgz", - "integrity": "sha512-FlgH43XZ50w3UtS1RVGDWOz8v9qMXPC7upMtKMtBEnYdt1OVoS61NYhKm/4x+cIaWqJTXua0+VVPI+fSPGXNIw==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", + "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", "dev": true, "license": "MIT", "dependencies": { @@ -11158,17 +11093,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -11242,13 +11166,13 @@ "license": "ISC" }, "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -11343,6 +11267,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001768", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", @@ -11431,17 +11364,17 @@ } }, "node_modules/chevrotain": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", - "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.0.3", - "@chevrotain/gast": "11.0.3", - "@chevrotain/regexp-to-ast": "11.0.3", - "@chevrotain/types": "11.0.3", - "@chevrotain/utils": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" } }, "node_modules/chevrotain-allstar": { @@ -11456,12 +11389,6 @@ "chevrotain": "^11.0.0" } }, - "node_modules/chevrotain/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -11918,15 +11845,6 @@ "buffer": "^5.1.0" } }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12458,9 +12376,9 @@ } }, "node_modules/dagre-d3-es": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", - "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -12560,6 +12478,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -12823,6 +12750,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -12871,14 +12804,14 @@ } }, "node_modules/dmg-builder": { - "version": "26.7.0", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.7.0.tgz", - "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", + "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.7.0", - "builder-util": "26.4.1", + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" @@ -13091,18 +13024,18 @@ } }, "node_modules/electron-builder": { - "version": "26.7.0", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.7.0.tgz", - "integrity": "sha512-LoXbCvSFxLesPneQ/fM7FB4OheIDA2tjqCdUkKlObV5ZKGhYgi5VHPHO/6UUOUodAlg7SrkPx7BZJPby+Vrtbg==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", + "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.7.0", - "builder-util": "26.4.1", + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", - "dmg-builder": "26.7.0", + "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", @@ -13117,15 +13050,15 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "26.7.0", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.7.0.tgz", - "integrity": "sha512-3EqkQK+q0kGshdPSKEPb2p5F75TENMKu6Fe5aTdeaPfdzFK4Yjp5L0d6S7K8iyvqIsGQ/ei4bnpyX9wt+kVCKQ==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz", + "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "app-builder-lib": "26.7.0", - "builder-util": "26.4.1", + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, @@ -13168,14 +13101,14 @@ } }, "node_modules/electron-publish": { - "version": "26.6.0", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.6.0.tgz", - "integrity": "sha512-LsyHMMqbvJ2vsOvuWJ19OezgF2ANdCiHpIucDHNiLhuI+/F3eW98ouzWSRmXXi82ZOPZXC07jnIravY4YYwCLQ==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", + "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", - "builder-util": "26.4.1", + "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", @@ -13811,13 +13744,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", - "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.1.tgz", + "integrity": "sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.1.6", + "@next/eslint-plugin-next": "16.2.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -14733,9 +14666,9 @@ "license": "MIT" }, "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -14753,9 +14686,9 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { @@ -14849,9 +14782,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -16009,9 +15942,9 @@ } }, "node_modules/hono": { - "version": "4.12.6", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.6.tgz", - "integrity": "sha512-KljEp+MeEEEIOT75qBo1UjqqB29fRMtlDEwCxcexOzdkUq6LR/vRvHk5pdROcxyOYyW1niq7Gb5pFVGy5R1eBw==", + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", + "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -17326,19 +17259,20 @@ } }, "node_modules/langium": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", - "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", "license": "MIT", "dependencies": { - "chevrotain": "~11.0.3", - "chevrotain-allstar": "~0.3.0", + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.0.8" + "vscode-uri": "~3.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.10.0", + "npm": ">=10.2.3" } }, "node_modules/language-subtag-registry": { @@ -18832,27 +18766,28 @@ } }, "node_modules/mermaid": { - "version": "11.12.2", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz", - "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==", + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", + "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", - "@iconify/utils": "^3.0.1", - "@mermaid-js/parser": "^0.6.3", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.0.1", "@types/d3": "^7.4.3", - "cytoscape": "^3.29.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.13", - "dayjs": "^1.11.18", - "dompurify": "^3.2.5", - "katex": "^0.16.22", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", "khroma": "^2.1.0", - "lodash-es": "^4.17.21", - "marked": "^16.2.1", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -19760,9 +19695,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -19968,15 +19903,15 @@ "license": "MIT" }, "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "license": "MIT", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", - "ufo": "^1.6.1" + "ufo": "^1.6.3" } }, "node_modules/morphdom": { @@ -20182,14 +20117,14 @@ } }, "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", + "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==", "license": "MIT", "dependencies": { - "@next/env": "16.1.6", + "@next/env": "16.2.1", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -20201,15 +20136,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.1", + "@next/swc-darwin-x64": "16.2.1", + "@next/swc-linux-arm64-gnu": "16.2.1", + "@next/swc-linux-arm64-musl": "16.2.1", + "@next/swc-linux-x64-gnu": "16.2.1", + "@next/swc-linux-x64-musl": "16.2.1", + "@next/swc-win32-arm64-msvc": "16.2.1", + "@next/swc-win32-x64-msvc": "16.2.1", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -20409,13 +20344,13 @@ } }, "node_modules/node-gyp/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/node-gyp/node_modules/semver": { @@ -20891,6 +20826,15 @@ "node": ">= 4" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -21007,7 +20951,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -21192,6 +21135,15 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -21289,36 +21241,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -21552,6 +21474,141 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -22928,6 +22985,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -23100,9 +23163,9 @@ } }, "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", "license": "Unlicense" }, "node_modules/roughjs": { @@ -23266,9 +23329,9 @@ "license": "MIT" }, "node_modules/sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", "dev": true, "license": "WTFPL OR ISC", "dependencies": { @@ -23414,6 +23477,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -24395,14 +24464,6 @@ "node": ">= 20" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -24828,9 +24889,9 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.12", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz", + "integrity": "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -25527,9 +25588,9 @@ } }, "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -26096,21 +26157,21 @@ "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, "node_modules/wait-on": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.3.tgz", - "integrity": "sha512-13zBnyYvFDW1rBvWiJ6Av3ymAaq8EDQuvxZnPIw3g04UqGi4TyoIJABmfJ6zrvKo9yeFQExNkOk7idQbDJcuKA==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", + "integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==", "dev": true, "license": "MIT", "dependencies": { - "axios": "^1.13.2", - "joi": "^18.0.1", - "lodash": "^4.17.21", + "axios": "^1.13.5", + "joi": "^18.0.2", + "lodash": "^4.17.23", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, @@ -26232,6 +26293,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/package.json b/package.json index 6f1dd647..689be345 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.38.1", + "version": "0.39.0", "private": true, "license": "BUSL-1.1", "workspaces": [ @@ -53,7 +53,8 @@ "@streamdown/cjk": "^1.0.1", "@streamdown/code": "^1.0.1", "@streamdown/math": "^1.0.1", - "@streamdown/mermaid": "^1.0.1", + "@streamdown/mermaid": "1.0.1", + "@types/qrcode": "^1.5.6", "ai": "^6.0.73", "ansi-to-react": "^6.2.6", "better-sqlite3": "^12.6.2", @@ -67,8 +68,9 @@ "morphdom": "^2.7.8", "motion": "^12.33.0", "nanoid": "^5.1.6", - "next": "16.1.6", + "next": "16.2.1", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", @@ -100,16 +102,26 @@ "@types/ws": "^8.18.1", "concurrently": "^9.2.1", "electron": "^40.2.1", - "electron-builder": "^26.7.0", + "electron-builder": "^26.8.1", "esbuild": "^0.27.3", "eslint": "^9", - "eslint-config-next": "16.1.6", + "eslint-config-next": "16.2.1", "husky": "^9.1.7", "lint-staged": "^16.3.2", "tailwindcss": "^4", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", "typescript": "^5", - "wait-on": "^9.0.3" + "wait-on": "^9.0.4" + }, + "overrides": { + "@mermaid-js/parser": "1.0.1", + "axios": "1.13.6", + "flatted": "3.4.2", + "hono": "4.12.8", + "lodash-es": "4.17.23", + "mermaid": "11.13.0", + "tar": "7.5.12", + "undici": "6.24.1" } } diff --git a/src/__tests__/unit/assistant-workspace.test.ts b/src/__tests__/unit/assistant-workspace.test.ts index 2b2107d7..423834ce 100644 --- a/src/__tests__/unit/assistant-workspace.test.ts +++ b/src/__tests__/unit/assistant-workspace.test.ts @@ -60,7 +60,7 @@ describe('Assistant Workspace', () => { const state = JSON.parse(fs.readFileSync(statePath, 'utf-8')); assert.equal(state.onboardingComplete, false); assert.equal(state.lastCheckInDate, null); - assert.equal(state.schemaVersion, 3); + assert.equal(state.schemaVersion, 4); }); it('should create all 4 template files', () => { @@ -129,19 +129,24 @@ describe('Assistant Workspace', () => { }); it('should trigger check-in if onboarding done and no check-in today', () => { - const state = { onboardingComplete: true, lastCheckInDate: '2020-01-01', schemaVersion: 2 }; + const state = { onboardingComplete: true, lastCheckInDate: '2020-01-01', schemaVersion: 2, dailyCheckInEnabled: true }; assert.equal(needsDailyCheckIn(state), true); }); it('should not trigger check-in if already done today', () => { const today = getLocalDateString(); - const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 2 }; + const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 2, dailyCheckInEnabled: true }; assert.equal(needsDailyCheckIn(state), false); }); it('onboarding day should skip daily check-in (lastCheckInDate set)', () => { const today = getLocalDateString(); - const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 2 }; + const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 2, dailyCheckInEnabled: true }; + assert.equal(needsDailyCheckIn(state), false); + }); + + it('should not trigger check-in if dailyCheckInEnabled is not set (default off)', () => { + const state = { onboardingComplete: true, lastCheckInDate: '2020-01-01', schemaVersion: 3 }; assert.equal(needsDailyCheckIn(state), false); }); }); @@ -263,20 +268,20 @@ describe('Assistant Workspace', () => { migrateStateV1ToV2(workDir); const state = loadState(workDir); - assert.equal(state.schemaVersion, 3); + assert.equal(state.schemaVersion, 4); assert.ok(fs.existsSync(path.join(workDir, 'memory', 'daily'))); assert.ok(fs.existsSync(path.join(workDir, 'Inbox'))); }); - it('should not re-migrate v3 state', () => { + it('should not re-migrate v4 state', () => { initializeWorkspace(workDir); const state = loadState(workDir); - assert.equal(state.schemaVersion, 3); + assert.equal(state.schemaVersion, 4); // Should not throw or change anything migrateStateV1ToV2(workDir); const reloaded = loadState(workDir); - assert.equal(reloaded.schemaVersion, 3); + assert.equal(reloaded.schemaVersion, 4); }); }); diff --git a/src/__tests__/unit/context-assembler.test.ts b/src/__tests__/unit/context-assembler.test.ts new file mode 100644 index 00000000..313ebfa8 --- /dev/null +++ b/src/__tests__/unit/context-assembler.test.ts @@ -0,0 +1,164 @@ +/** + * Unit tests for context-assembler. + * + * Run with: npx tsx --test src/__tests__/unit/context-assembler.test.ts + * + * Tests verify: + * 1. Desktop entry point includes widget prompt + * 2. Bridge entry point does NOT include widget prompt + * 3. Workspace prompt only injected for assistant project sessions + * 4. Widget MCP keyword detection + * 5. generative_ui_enabled=false skips widget even on desktop + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { ChatSession } from '../../types'; + +function makeSession(overrides: Partial = {}): ChatSession { + return { + id: 'test-session', + title: 'Test', + model: 'sonnet', + working_directory: '/Users/test/project', + system_prompt: 'You are a helpful assistant.', + created_at: '2024-01-01', + updated_at: '2024-01-01', + sdk_session_id: '', + mode: 'code', + provider_id: '', + sdk_cwd: '', + permission_profile: 'default', + project_name: '', + status: 'active', + provider_name: '', + runtime_status: 'idle', + runtime_updated_at: '', + runtime_error: '', + ...overrides, + }; +} + +describe('assembleContext', () => { + + it('desktop: includes session prompt and enables generativeUI', async () => { + const { assembleContext } = await import('../../lib/context-assembler'); + const result = await assembleContext({ + session: makeSession(), + entryPoint: 'desktop', + userPrompt: 'hello', + }); + + assert.ok(result.systemPrompt?.includes('You are a helpful assistant.')); + assert.equal(result.generativeUIEnabled, true); + assert.equal(result.isAssistantProject, false); + }); + + it('bridge: does NOT enable generativeUI or widget MCP', async () => { + const { assembleContext } = await import('../../lib/context-assembler'); + const result = await assembleContext({ + session: makeSession(), + entryPoint: 'bridge', + userPrompt: 'hello', + }); + + assert.equal(result.generativeUIEnabled, false); + assert.equal(result.needsWidgetMcp, false); + }); + + it('includes systemPromptAppend when provided', async () => { + const { assembleContext } = await import('../../lib/context-assembler'); + const result = await assembleContext({ + session: makeSession(), + entryPoint: 'desktop', + userPrompt: 'hello', + systemPromptAppend: 'EXTRA INSTRUCTIONS HERE', + }); + + assert.ok(result.systemPrompt?.includes('EXTRA INSTRUCTIONS HERE')); + assert.ok(result.systemPrompt?.includes('You are a helpful assistant.')); + }); + + it('non-workspace session: isAssistantProject is false', async () => { + const { assembleContext } = await import('../../lib/context-assembler'); + const result = await assembleContext({ + session: makeSession({ working_directory: '/Users/test/project' }), + entryPoint: 'desktop', + userPrompt: 'hello', + }); + + assert.equal(result.isAssistantProject, false); + assert.equal(result.assistantProjectInstructions, ''); + }); + + it('widget MCP detection: keyword triggers needsWidgetMcp on desktop', async () => { + const { assembleContext } = await import('../../lib/context-assembler'); + const result = await assembleContext({ + session: makeSession(), + entryPoint: 'desktop', + userPrompt: '帮我画一个可视化图表', + }); + + assert.equal(result.needsWidgetMcp, true); + }); + + it('widget MCP detection: no keyword means no widget MCP', async () => { + const { assembleContext } = await import('../../lib/context-assembler'); + const result = await assembleContext({ + session: makeSession(), + entryPoint: 'desktop', + userPrompt: '帮我写一个函数', + }); + + assert.equal(result.needsWidgetMcp, false); + }); + + it('widget MCP detection: conversation history with show-widget', async () => { + const { assembleContext } = await import('../../lib/context-assembler'); + const result = await assembleContext({ + session: makeSession(), + entryPoint: 'desktop', + userPrompt: '继续', + conversationHistory: [ + { role: 'assistant', content: '```show-widget\n{"title":"test"}\n```' }, + ], + }); + + assert.equal(result.needsWidgetMcp, true); + }); + + it('bridge: widget MCP is never enabled even with keywords', async () => { + const { assembleContext } = await import('../../lib/context-assembler'); + const result = await assembleContext({ + session: makeSession(), + entryPoint: 'bridge', + userPrompt: '帮我画一个可视化图表', + }); + + assert.equal(result.needsWidgetMcp, false); + assert.equal(result.generativeUIEnabled, false); + }); + + it('session with empty system_prompt: does not throw', async () => { + const { assembleContext } = await import('../../lib/context-assembler'); + const result = await assembleContext({ + session: makeSession({ system_prompt: '' }), + entryPoint: 'bridge', + userPrompt: 'hello', + }); + + // Should not throw — prompt may be undefined or contain only CLI context + assert.ok(true); + }); + + it('prompt ordering: session prompt present in result', async () => { + const { assembleContext } = await import('../../lib/context-assembler'); + const result = await assembleContext({ + session: makeSession({ system_prompt: '<>' }), + entryPoint: 'desktop', + userPrompt: 'hello', + }); + + assert.ok(result.systemPrompt?.includes('<>')); + }); +}); diff --git a/src/__tests__/unit/mcp-loader.test.ts b/src/__tests__/unit/mcp-loader.test.ts new file mode 100644 index 00000000..4c4cd31e --- /dev/null +++ b/src/__tests__/unit/mcp-loader.test.ts @@ -0,0 +1,78 @@ +/** + * Unit tests for mcp-loader. + * + * Run with: npx tsx --test src/__tests__/unit/mcp-loader.test.ts + * + * Tests verify: + * 1. loadCodePilotMcpServers returns undefined when no servers have ${...} placeholders + * 2. loadCodePilotMcpServers returns only servers with resolved placeholders + * 3. loadAllMcpServers returns all merged servers + * 4. Cache invalidation works + * 5. Disabled servers are filtered out + */ + +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// Import the module — will use real filesystem +// Tests are designed to work with the user's actual config +import { loadCodePilotMcpServers, loadAllMcpServers, invalidateMcpCache } from '../../lib/mcp-loader'; + +afterEach(() => { + invalidateMcpCache(); +}); + +describe('mcp-loader', () => { + + it('loadCodePilotMcpServers: returns undefined when no servers have placeholders', () => { + // Most real configs don't have ${...} placeholders + // This test verifies the common case returns undefined (= let SDK handle everything) + const result = loadCodePilotMcpServers(); + // Either undefined (no placeholders) or a map (has placeholders) + // Both are valid; we just verify it doesn't throw + if (result === undefined) { + assert.equal(result, undefined); + } else { + assert.ok(typeof result === 'object'); + // Verify all returned servers have env values (they were resolved from placeholders) + for (const server of Object.values(result)) { + assert.ok(server.env, 'returned servers should have env property'); + } + } + }); + + it('loadAllMcpServers: returns merged config or undefined', () => { + const result = loadAllMcpServers(); + // Result depends on whether user has MCP servers configured + if (result !== undefined) { + assert.ok(typeof result === 'object'); + for (const [name, server] of Object.entries(result)) { + assert.ok(typeof name === 'string'); + // All servers should NOT be disabled (disabled ones are filtered out) + assert.notEqual(server.enabled, false); + } + } + }); + + it('cache: consecutive calls return same reference', () => { + const result1 = loadAllMcpServers(); + const result2 = loadAllMcpServers(); + // Same cache should be hit — references should be equal + // (unless there are no servers, in which case both are undefined) + if (result1 !== undefined && result2 !== undefined) { + assert.equal(result1, result2, 'cached results should be the same reference'); + } + }); + + it('invalidateMcpCache: forces fresh read', () => { + const result1 = loadAllMcpServers(); + invalidateMcpCache(); + const result2 = loadAllMcpServers(); + // After invalidation, a new object should be created + // (they may be deeply equal but should be different references) + if (result1 !== undefined && result2 !== undefined) { + // New cache entry means new object + assert.notEqual(result1, result2, 'invalidated cache should produce new reference'); + } + }); +}); diff --git a/src/__tests__/unit/onboarding-completion.test.ts b/src/__tests__/unit/onboarding-completion.test.ts index c28eec2e..13718425 100644 --- a/src/__tests__/unit/onboarding-completion.test.ts +++ b/src/__tests__/unit/onboarding-completion.test.ts @@ -243,7 +243,8 @@ describe('onboarding completion + workspace state integration', () => { const state = loadState(workDir); state.onboardingComplete = true; state.lastCheckInDate = today; - state.schemaVersion = 3; + state.schemaVersion = 4; + state.dailyCheckInEnabled = true; saveState(workDir, state); const reloaded = loadState(workDir); @@ -261,7 +262,8 @@ describe('onboarding completion + workspace state integration', () => { const state = loadState(workDir); state.onboardingComplete = true; state.lastCheckInDate = '2020-01-01'; // yesterday or earlier - state.schemaVersion = 3; + state.schemaVersion = 4; + state.dailyCheckInEnabled = true; saveState(workDir, state); const reloaded = loadState(workDir); diff --git a/src/__tests__/unit/provider-resolver.test.ts b/src/__tests__/unit/provider-resolver.test.ts index 9365382b..5b8b45bc 100644 --- a/src/__tests__/unit/provider-resolver.test.ts +++ b/src/__tests__/unit/provider-resolver.test.ts @@ -691,26 +691,49 @@ describe('Env Provider AI SDK Consistency', () => { }); it('toAiSdkConfig with env resolution produces valid anthropic config', () => { - const resolved: ResolvedProvider = { - provider: undefined, - protocol: 'anthropic', - authStyle: 'api_key', - model: 'sonnet', - upstreamModel: 'sonnet', - modelDisplayName: undefined, - headers: {}, - envOverrides: {}, - roleModels: {}, - hasCredentials: true, - availableModels: [], - settingSources: ['user', 'project', 'local'], + // Isolate from real env vars AND DB settings that may be set on developer machines + const envSnapshot = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, + ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, + }; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_BASE_URL; + const dbSnapshot = { + anthropic_auth_token: getSetting('anthropic_auth_token'), + anthropic_base_url: getSetting('anthropic_base_url'), }; - const config = toAiSdkConfig(resolved); - assert.equal(config.sdkType, 'anthropic'); - assert.equal(config.modelId, 'sonnet'); - // No apiKey/baseUrl — SDK will read from process.env - assert.equal(config.apiKey, undefined); - assert.equal(config.baseUrl, undefined); + setSetting('anthropic_auth_token', ''); + setSetting('anthropic_base_url', ''); + try { + const resolved: ResolvedProvider = { + provider: undefined, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + upstreamModel: 'sonnet', + modelDisplayName: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['user', 'project', 'local'], + }; + const config = toAiSdkConfig(resolved); + assert.equal(config.sdkType, 'anthropic'); + assert.equal(config.modelId, 'sonnet'); + // No apiKey/baseUrl — SDK will read from process.env + assert.equal(config.apiKey, undefined); + assert.equal(config.baseUrl, undefined); + } finally { + for (const [k, v] of Object.entries(envSnapshot)) { + if (v !== undefined) process.env[k] = v; else delete process.env[k]; + } + setSetting('anthropic_auth_token', dbSnapshot.anthropic_auth_token || ''); + setSetting('anthropic_base_url', dbSnapshot.anthropic_base_url || ''); + } }); }); @@ -846,27 +869,50 @@ describe('Entry Point Resolution Contract', () => { }); it('toAiSdkConfig for env mode does not require provider record', () => { - // env mode: provider=undefined, hasCredentials=true - // toAiSdkConfig must produce a valid config that relies on process.env for auth - const resolved: ResolvedProvider = { - provider: undefined, - protocol: 'anthropic', - authStyle: 'api_key', - model: 'sonnet', - upstreamModel: 'sonnet', - modelDisplayName: undefined, - headers: {}, - envOverrides: {}, - roleModels: {}, - hasCredentials: true, - availableModels: [], - settingSources: ['user', 'project', 'local'], + // Isolate from real env vars AND DB settings + const envSnapshot = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, + ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, + }; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_BASE_URL; + const dbSnapshot = { + anthropic_auth_token: getSetting('anthropic_auth_token'), + anthropic_base_url: getSetting('anthropic_base_url'), }; - const config = toAiSdkConfig(resolved); - assert.equal(config.sdkType, 'anthropic'); - assert.equal(config.apiKey, undefined, 'env mode should not inject apiKey — SDK reads from process.env'); - assert.equal(config.baseUrl, undefined, 'env mode should not inject baseUrl — SDK reads from process.env'); - assert.equal(config.modelId, 'sonnet'); + setSetting('anthropic_auth_token', ''); + setSetting('anthropic_base_url', ''); + try { + // env mode: provider=undefined, hasCredentials=true + // toAiSdkConfig must produce a valid config that relies on process.env for auth + const resolved: ResolvedProvider = { + provider: undefined, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + upstreamModel: 'sonnet', + modelDisplayName: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['user', 'project', 'local'], + }; + const config = toAiSdkConfig(resolved); + assert.equal(config.sdkType, 'anthropic'); + assert.equal(config.apiKey, undefined, 'env mode should not inject apiKey — SDK reads from process.env'); + assert.equal(config.baseUrl, undefined, 'env mode should not inject baseUrl — SDK reads from process.env'); + assert.equal(config.modelId, 'sonnet'); + } finally { + for (const [k, v] of Object.entries(envSnapshot)) { + if (v !== undefined) process.env[k] = v; else delete process.env[k]; + } + setSetting('anthropic_auth_token', dbSnapshot.anthropic_auth_token || ''); + setSetting('anthropic_base_url', dbSnapshot.anthropic_base_url || ''); + } }); it('upstream model mapping is consistent between AI SDK and Claude Code paths', () => { @@ -907,3 +953,150 @@ describe('Entry Point Resolution Contract', () => { assert.equal(aiConfig.modelId, ccEnv.ANTHROPIC_MODEL, 'AI SDK and Claude Code must use same upstream model'); }); }); + +// ── Global Default Model Tests ────────────────────────────────── + +import { getSetting, setSetting } from '../../lib/db'; + +describe('Global Default Model', () => { + // Save and restore settings around each test + let savedModel: string | null | undefined; + let savedProvider: string | null | undefined; + + const setup = () => { + savedModel = getSetting('global_default_model'); + savedProvider = getSetting('global_default_model_provider'); + }; + const teardown = () => { + setSetting('global_default_model', savedModel || ''); + setSetting('global_default_model_provider', savedProvider || ''); + }; + + // ── env provider branch ─────────────────────────────────────── + + it('env provider uses global default model when it belongs to env', () => { + setup(); + try { + setSetting('global_default_model', 'opus'); + setSetting('global_default_model_provider', 'env'); + + const resolved = resolveProvider({ providerId: 'env' }); + assert.equal(resolved.model, 'opus', 'should use global default model for env provider'); + } finally { + teardown(); + } + }); + + it('env provider ignores global default model when it belongs to another provider', () => { + setup(); + try { + setSetting('global_default_model', 'some-model'); + setSetting('global_default_model_provider', 'some-other-provider-id'); + + const resolved = resolveProvider({ providerId: 'env' }); + // Should NOT use 'some-model' because it belongs to a different provider + assert.notEqual(resolved.model, 'some-model', + 'should not apply global default from another provider'); + } finally { + teardown(); + } + }); + + it('explicit model overrides global default', () => { + setup(); + try { + setSetting('global_default_model', 'opus'); + setSetting('global_default_model_provider', 'env'); + + const resolved = resolveProvider({ providerId: 'env', model: 'haiku' }); + assert.equal(resolved.model, 'haiku', 'explicit model should take priority'); + } finally { + teardown(); + } + }); + + it('session model overrides global default', () => { + setup(); + try { + setSetting('global_default_model', 'opus'); + setSetting('global_default_model_provider', 'env'); + + const resolved = resolveProvider({ providerId: 'env', sessionModel: 'sonnet' }); + assert.equal(resolved.model, 'sonnet', 'session model should take priority'); + } finally { + teardown(); + } + }); + + // ── DB provider branch ──────────────────────────────────────── + + it('DB provider uses global default model when it belongs to that provider', () => { + setup(); + const { createProvider, deleteProvider } = require('../../lib/db'); + const provider = createProvider({ + name: '__test_global_default__', + provider_type: 'anthropic', + base_url: 'https://api.anthropic.com', + api_key: 'test-key', + }); + try { + setSetting('global_default_model', 'test-model-x'); + setSetting('global_default_model_provider', provider.id); + + const resolved = resolveProvider({ providerId: provider.id }); + assert.equal(resolved.model, 'test-model-x', + 'DB provider should use global default when provider ID matches'); + } finally { + deleteProvider(provider.id); + teardown(); + } + }); + + it('DB provider ignores global default model when it belongs to a different provider', () => { + setup(); + const { createProvider, deleteProvider } = require('../../lib/db'); + const provider = createProvider({ + name: '__test_global_default_cross__', + provider_type: 'anthropic', + base_url: 'https://api.anthropic.com', + api_key: 'test-key', + role_models_json: JSON.stringify({ default: 'own-default-model' }), + }); + try { + setSetting('global_default_model', 'foreign-model'); + setSetting('global_default_model_provider', 'some-completely-different-id'); + + const resolved = resolveProvider({ providerId: provider.id }); + // Should fall through to roleModels.default, NOT use 'foreign-model' + assert.notEqual(resolved.model, 'foreign-model', + 'DB provider should not use global default from another provider'); + assert.equal(resolved.model, 'own-default-model', + 'should fall through to roleModels.default'); + } finally { + deleteProvider(provider.id); + teardown(); + } + }); + + it('DB provider: session model overrides global default even when provider matches', () => { + setup(); + const { createProvider, deleteProvider } = require('../../lib/db'); + const provider = createProvider({ + name: '__test_global_default_session__', + provider_type: 'anthropic', + base_url: 'https://api.anthropic.com', + api_key: 'test-key', + }); + try { + setSetting('global_default_model', 'global-pick'); + setSetting('global_default_model_provider', provider.id); + + const resolved = resolveProvider({ providerId: provider.id, sessionModel: 'session-pick' }); + assert.equal(resolved.model, 'session-pick', + 'session model should take priority over global default'); + } finally { + deleteProvider(provider.id); + teardown(); + } + }); +}); diff --git a/src/__tests__/unit/stale-default-provider.test.ts b/src/__tests__/unit/stale-default-provider.test.ts index 7cf550d7..09dbf9f6 100644 --- a/src/__tests__/unit/stale-default-provider.test.ts +++ b/src/__tests__/unit/stale-default-provider.test.ts @@ -21,6 +21,8 @@ import { createProvider, deleteProvider, getDb, + getSetting, + setSetting, } from '../../lib/db'; import { resolveProvider } from '../../lib/provider-resolver'; @@ -57,17 +59,22 @@ function cleanupTestProviders() { // ── Tests ─────────────────────────────────────────────────────── describe('Stale default_provider_id cleanup', () => { - // Save and restore original default + // Save and restore original default + global default model provider let originalDefault: string | undefined; + let originalGlobalProvider: string | undefined; beforeEach(() => { originalDefault = getDefaultProviderId(); + originalGlobalProvider = getSetting('global_default_model_provider') || undefined; + // Clear global_default_model_provider so these tests exercise the legacy path + setSetting('global_default_model_provider', ''); cleanupTestProviders(); }); afterEach(() => { cleanupTestProviders(); - // Restore original default + // Restore originals + setSetting('global_default_model_provider', originalGlobalProvider || ''); if (originalDefault) { setDefaultProviderId(originalDefault); } diff --git a/src/__tests__/unit/timezone-boundaries.test.ts b/src/__tests__/unit/timezone-boundaries.test.ts index b1919a17..b8505d3d 100644 --- a/src/__tests__/unit/timezone-boundaries.test.ts +++ b/src/__tests__/unit/timezone-boundaries.test.ts @@ -116,7 +116,7 @@ describe('needsDailyCheckIn timezone boundaries', () => { it('UTC+9: check-in done as local 2026-03-10, still valid at 08:30 JST', () => { setTZ('Asia/Tokyo'); const now = new Date('2026-03-10T00:30:00Z'); // 09:30 JST, still March 10 local - const state = { onboardingComplete: true, lastCheckInDate: '2026-03-10', schemaVersion: 3 }; + const state = { onboardingComplete: true, lastCheckInDate: '2026-03-10', schemaVersion: 3, dailyCheckInEnabled: true }; assert.equal(needsDailyCheckIn(state, now), false); }); @@ -126,7 +126,7 @@ describe('needsDailyCheckIn timezone boundaries', () => { // localToday = '2026-03-10', utcToday = '2026-03-10' // stored '2026-03-09' matches neither → triggers const now = new Date('2026-03-10T01:00:00Z'); - const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3 }; + const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3, dailyCheckInEnabled: true }; assert.equal(needsDailyCheckIn(state, now), true); }); @@ -137,7 +137,7 @@ describe('needsDailyCheckIn timezone boundaries', () => { // stored '2026-03-09' matches utcToday → compat suppresses (correct: // old code could have written this just hours ago during the same UTC day) const now = new Date('2026-03-09T15:00:00Z'); - const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3 }; + const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3, dailyCheckInEnabled: true }; assert.equal(needsDailyCheckIn(state, now), false); }); @@ -149,7 +149,7 @@ describe('needsDailyCheckIn timezone boundaries', () => { // DID check in today (local March 10, 1am), but old code wrote UTC date. // The UTC fallback catches this: '2026-03-09' === utcToday → skip. const now = new Date('2026-03-09T17:00:00Z'); - const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3 }; + const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3, dailyCheckInEnabled: true }; // utcToday = '2026-03-09' matches stored → should NOT trigger assert.equal(needsDailyCheckIn(state, now), false); }); @@ -159,7 +159,7 @@ describe('needsDailyCheckIn timezone boundaries', () => { const now = new Date('2026-03-10T02:00:00Z'); // local March 10 10:00 // localToday = '2026-03-10', utcToday = '2026-03-10' // stored '2026-03-07' matches neither → triggers - const state = { onboardingComplete: true, lastCheckInDate: '2026-03-07', schemaVersion: 3 }; + const state = { onboardingComplete: true, lastCheckInDate: '2026-03-07', schemaVersion: 3, dailyCheckInEnabled: true }; assert.equal(needsDailyCheckIn(state, now), true); }); }); @@ -245,7 +245,7 @@ describe('v2→v3 migration', () => { assert.equal(raw.lastCheckInDate, '2026-03-10', 'should not change v3 state'); }); - it('loadState auto-migrates v2 state to v3', () => { + it('loadState auto-migrates v2 state to v4', () => { const stateDir = path.join(workDir, '.assistant'); fs.mkdirSync(stateDir, { recursive: true }); // Also need daily dir for v1→v2 migration path @@ -263,9 +263,11 @@ describe('v2→v3 migration', () => { ); const state = loadState(workDir); - assert.equal(state.schemaVersion, 3); + assert.equal(state.schemaVersion, 4); // Past date should be preserved, not overwritten to today assert.equal(state.lastCheckInDate, '2026-01-15'); + // v3→v4 migration resets dailyCheckInEnabled to false + assert.equal(state.dailyCheckInEnabled, false); }); it('should preserve null lastCheckInDate during migration', () => { diff --git a/src/__tests__/unit/update-release.test.ts b/src/__tests__/unit/update-release.test.ts new file mode 100644 index 00000000..6c80aa37 --- /dev/null +++ b/src/__tests__/unit/update-release.test.ts @@ -0,0 +1,68 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { selectRecommendedReleaseAsset, type ReleaseAsset } from '../../lib/update-release'; + +const assets: ReleaseAsset[] = [ + { + name: 'CodePilot-0.38.5-arm64.dmg', + browser_download_url: 'https://example.com/CodePilot-0.38.5-arm64.dmg', + }, + { + name: 'CodePilot-0.38.5-x64.dmg', + browser_download_url: 'https://example.com/CodePilot-0.38.5-x64.dmg', + }, + { + name: 'CodePilot-0.38.5-arm64.zip', + browser_download_url: 'https://example.com/CodePilot-0.38.5-arm64.zip', + }, + { + name: 'CodePilot-0.38.5-x64.zip', + browser_download_url: 'https://example.com/CodePilot-0.38.5-x64.zip', + }, + { + name: 'CodePilot-0.38.5.exe', + browser_download_url: 'https://example.com/CodePilot-0.38.5.exe', + }, +]; + +describe('selectRecommendedReleaseAsset', () => { + it('prefers the arm64 dmg for Apple Silicon Macs', () => { + const selected = selectRecommendedReleaseAsset(assets, { + platform: 'darwin', + processArch: 'x64', + hostArch: 'arm64', + }); + + assert.equal(selected?.name, 'CodePilot-0.38.5-arm64.dmg'); + }); + + it('prefers the x64 dmg for Intel Macs', () => { + const selected = selectRecommendedReleaseAsset(assets, { + platform: 'darwin', + processArch: 'x64', + hostArch: 'x64', + }); + + assert.equal(selected?.name, 'CodePilot-0.38.5-x64.dmg'); + }); + + it('falls back to the windows installer on Windows', () => { + const selected = selectRecommendedReleaseAsset(assets, { + platform: 'win32', + processArch: 'x64', + hostArch: 'x64', + }); + + assert.equal(selected?.name, 'CodePilot-0.38.5.exe'); + }); + + it('returns null when no matching asset exists', () => { + const selected = selectRecommendedReleaseAsset([], { + platform: 'darwin', + processArch: 'arm64', + hostArch: 'arm64', + }); + + assert.equal(selected, null); + }); +}); diff --git a/src/__tests__/unit/weixin-adapter-ack.test.ts b/src/__tests__/unit/weixin-adapter-ack.test.ts new file mode 100644 index 00000000..e9df92d4 --- /dev/null +++ b/src/__tests__/unit/weixin-adapter-ack.test.ts @@ -0,0 +1,90 @@ +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codepilot-weixin-ack-test-')); +process.env.CLAUDE_GUI_DATA_DIR = tmpDir; + +let WeixinAdapter: typeof import('../../lib/bridge/adapters/weixin-adapter').WeixinAdapter; +let getChannelOffset: typeof import('../../lib/db').getChannelOffset; +let closeDb: typeof import('../../lib/db').closeDb; + +before(async () => { + ({ WeixinAdapter } = await import('../../lib/bridge/adapters/weixin-adapter')); + ({ getChannelOffset, closeDb } = await import('../../lib/db')); +}); + +after(() => { + closeDb(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('WeixinAdapter deferred cursor ack', () => { + it('attaches batch updateId to enqueued inbound messages', async () => { + const adapter = new WeixinAdapter() as any; + adapter.pendingCursors.set(42, { + offsetKey: 'weixin:acc-1', + cursor: 'cursor-42', + remaining: 0, + sealed: false, + }); + + await adapter.processMessage( + 'acc-1', + { + botToken: 'token', + ilinkBotId: 'acc-1', + baseUrl: 'https://example.test', + cdnBaseUrl: 'https://cdn.example.test', + }, + { + from_user_id: 'peer-1', + message_id: 'msg-1', + item_list: [{ type: 1, text_item: { text: 'hello' } }], + }, + 42, + ); + + const inbound = await adapter.consumeOne(); + assert.ok(inbound); + assert.equal(inbound?.updateId, 42); + assert.equal(adapter.pendingCursors.get(42)?.remaining, 1); + }); + + it('commits cursor only after final acknowledgeUpdate', () => { + const adapter = new WeixinAdapter() as any; + + adapter.pendingCursors.set(7, { + offsetKey: 'weixin:acc-7', + cursor: 'cursor-7', + remaining: 2, + sealed: true, + }); + + adapter.acknowledgeUpdate(7); + assert.equal(getChannelOffset('weixin:acc-7'), '0'); + + adapter.acknowledgeUpdate(7); + assert.equal(getChannelOffset('weixin:acc-7'), 'cursor-7'); + }); + + it('does not commit cursor before the batch is sealed', () => { + const adapter = new WeixinAdapter() as any; + + adapter.pendingCursors.set(8, { + offsetKey: 'weixin:acc-8', + cursor: 'cursor-8', + remaining: 1, + sealed: false, + }); + + adapter.acknowledgeUpdate(8); + assert.equal(getChannelOffset('weixin:acc-8'), '0'); + + adapter.pendingCursors.get(8).sealed = true; + adapter.maybeCommitPendingCursor(8); + assert.equal(getChannelOffset('weixin:acc-8'), 'cursor-8'); + }); +}); diff --git a/src/__tests__/unit/weixin-api.test.ts b/src/__tests__/unit/weixin-api.test.ts new file mode 100644 index 00000000..8bb06d6e --- /dev/null +++ b/src/__tests__/unit/weixin-api.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + sendTextMessage, + sendTyping, +} from '../../lib/bridge/adapters/weixin/weixin-api'; + +const creds = { + botToken: 'token', + ilinkBotId: 'bot-id', + baseUrl: 'https://example.test', + cdnBaseUrl: 'https://cdn.example.test', +}; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('weixin-api empty success bodies', () => { + it('sendTextMessage tolerates empty 200 response bodies', async () => { + let requestBody = ''; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requestBody = String(init?.body || ''); + return new Response('', { status: 200 }); + }) as typeof fetch; + + const { clientId } = await sendTextMessage(creds, 'peer-user', 'hello', 'ctx-token'); + + assert.match(clientId, /^codepilot-wx-/); + const parsed = JSON.parse(requestBody); + assert.equal(parsed.msg.to_user_id, 'peer-user'); + assert.equal(parsed.msg.context_token, 'ctx-token'); + assert.equal(parsed.msg.message_state, 2); + }); + + it('sendTyping tolerates empty 200 response bodies', async () => { + let requestBody = ''; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requestBody = String(init?.body || ''); + return new Response('', { status: 200 }); + }) as typeof fetch; + + await assert.doesNotReject(() => sendTyping(creds, 'peer-user', 'ticket-1', 1)); + + const parsed = JSON.parse(requestBody); + assert.equal(parsed.ilink_user_id, 'peer-user'); + assert.equal(parsed.typing_ticket, 'ticket-1'); + assert.equal(parsed.status, 1); + }); +}); diff --git a/src/__tests__/unit/weixin-ids.test.ts b/src/__tests__/unit/weixin-ids.test.ts new file mode 100644 index 00000000..f4a3ea9f --- /dev/null +++ b/src/__tests__/unit/weixin-ids.test.ts @@ -0,0 +1,54 @@ +/** + * Unit tests for WeChat chat ID encoding/decoding. + * + * Run with: npx tsx src/__tests__/unit/weixin-ids.test.ts + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { encodeWeixinChatId, decodeWeixinChatId, isWeixinChatId } from '../../lib/bridge/adapters/weixin/weixin-ids'; + +describe('encodeWeixinChatId', () => { + it('produces correct format', () => { + assert.equal(encodeWeixinChatId('acc123', 'user456'), 'weixin::acc123::user456'); + }); + + it('handles special characters in IDs', () => { + assert.equal(encodeWeixinChatId('acc-im-bot', 'user@test'), 'weixin::acc-im-bot::user@test'); + }); +}); + +describe('decodeWeixinChatId', () => { + it('round-trips correctly', () => { + const encoded = encodeWeixinChatId('myAccount', 'peerUser'); + const decoded = decodeWeixinChatId(encoded); + assert.deepEqual(decoded, { accountId: 'myAccount', peerUserId: 'peerUser' }); + }); + + it('returns null for non-weixin chatId', () => { + assert.equal(decodeWeixinChatId('telegram:12345'), null); + assert.equal(decodeWeixinChatId('random_string'), null); + }); + + it('returns null for malformed weixin chatId', () => { + assert.equal(decodeWeixinChatId('weixin::'), null); + assert.equal(decodeWeixinChatId('weixin::acc'), null); + assert.equal(decodeWeixinChatId('weixin::::user'), null); + }); + + it('handles empty components', () => { + assert.equal(decodeWeixinChatId('weixin::::peer'), null); + assert.equal(decodeWeixinChatId('weixin::acc::'), null); + }); +}); + +describe('isWeixinChatId', () => { + it('returns true for valid weixin chatIds', () => { + assert.equal(isWeixinChatId('weixin::acc::user'), true); + }); + + it('returns false for non-weixin chatIds', () => { + assert.equal(isWeixinChatId('telegram:123'), false); + assert.equal(isWeixinChatId('qq:456'), false); + }); +}); diff --git a/src/__tests__/unit/weixin-media.test.ts b/src/__tests__/unit/weixin-media.test.ts new file mode 100644 index 00000000..164f79ba --- /dev/null +++ b/src/__tests__/unit/weixin-media.test.ts @@ -0,0 +1,77 @@ +/** + * Unit tests for WeChat media encryption/decryption. + * + * Run with: npx tsx src/__tests__/unit/weixin-media.test.ts + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { encryptMedia, decryptMedia, generateMediaKey, aesEcbPaddedSize } from '../../lib/bridge/adapters/weixin/weixin-media'; + +describe('AES-128-ECB encrypt/decrypt', () => { + it('round-trips correctly', () => { + const key = generateMediaKey(); + const plaintext = Buffer.from('Hello, WeChat media encryption!'); + const encrypted = encryptMedia(plaintext, key); + const decrypted = decryptMedia(encrypted, key); + assert.equal(decrypted.toString(), plaintext.toString()); + }); + + it('round-trips with various sizes', () => { + const key = generateMediaKey(); + + // Exactly 16 bytes (one block) + const data16 = Buffer.alloc(16, 0x42); + assert.deepEqual(decryptMedia(encryptMedia(data16, key), key), data16); + + // 15 bytes (less than one block) + const data15 = Buffer.alloc(15, 0x43); + assert.deepEqual(decryptMedia(encryptMedia(data15, key), key), data15); + + // 17 bytes (just over one block) + const data17 = Buffer.alloc(17, 0x44); + assert.deepEqual(decryptMedia(encryptMedia(data17, key), key), data17); + + // Large data + const dataLarge = Buffer.alloc(1024, 0x45); + assert.deepEqual(decryptMedia(encryptMedia(dataLarge, key), key), dataLarge); + + // Empty data should still work with PKCS7 + const dataEmpty = Buffer.alloc(0); + assert.deepEqual(decryptMedia(encryptMedia(dataEmpty, key), key), dataEmpty); + }); + + it('produces correct ciphertext size', () => { + const key = generateMediaKey(); + const data = Buffer.alloc(100, 0x46); + const encrypted = encryptMedia(data, key); + // AES-128-ECB with PKCS7: ceil((100+1)/16) * 16 = 112 + assert.equal(encrypted.length, 112); + }); +}); + +describe('generateMediaKey', () => { + it('generates 16-byte key', () => { + const key = generateMediaKey(); + assert.equal(key.length, 16); + }); + + it('generates unique keys', () => { + const key1 = generateMediaKey(); + const key2 = generateMediaKey(); + assert.equal(key1.equals(key2), false); + }); +}); + +describe('aesEcbPaddedSize', () => { + it('computes correct padded sizes', () => { + assert.equal(aesEcbPaddedSize(0), 16); + assert.equal(aesEcbPaddedSize(1), 16); + assert.equal(aesEcbPaddedSize(15), 16); + assert.equal(aesEcbPaddedSize(16), 32); + assert.equal(aesEcbPaddedSize(17), 32); + assert.equal(aesEcbPaddedSize(31), 32); + assert.equal(aesEcbPaddedSize(32), 48); + assert.equal(aesEcbPaddedSize(100), 112); + }); +}); diff --git a/src/__tests__/unit/weixin-session-guard.test.ts b/src/__tests__/unit/weixin-session-guard.test.ts new file mode 100644 index 00000000..8bf51e62 --- /dev/null +++ b/src/__tests__/unit/weixin-session-guard.test.ts @@ -0,0 +1,55 @@ +/** + * Unit tests for WeChat session guard (account pause management). + * + * Run with: npx tsx src/__tests__/unit/weixin-session-guard.test.ts + */ + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { isPaused, setPaused, clearPause, clearAllPauses, getPauseRemainingMs } from '../../lib/bridge/adapters/weixin/weixin-session-guard'; + +describe('weixin-session-guard', () => { + beforeEach(() => { + clearAllPauses(); + }); + + it('starts unpaused', () => { + assert.equal(isPaused('test-account'), false); + }); + + it('can be paused and detected', () => { + setPaused('test-account', 'Session expired'); + assert.equal(isPaused('test-account'), true); + }); + + it('can be cleared', () => { + setPaused('test-account'); + clearPause('test-account'); + assert.equal(isPaused('test-account'), false); + }); + + it('tracks multiple accounts independently', () => { + setPaused('account-1'); + assert.equal(isPaused('account-1'), true); + assert.equal(isPaused('account-2'), false); + }); + + it('clearAllPauses clears everything', () => { + setPaused('account-1'); + setPaused('account-2'); + clearAllPauses(); + assert.equal(isPaused('account-1'), false); + assert.equal(isPaused('account-2'), false); + }); + + it('getPauseRemainingMs returns positive value when paused', () => { + setPaused('test-account'); + const remaining = getPauseRemainingMs('test-account'); + assert.ok(remaining > 0); + assert.ok(remaining <= 60 * 60 * 1000); + }); + + it('getPauseRemainingMs returns 0 when not paused', () => { + assert.equal(getPauseRemainingMs('test-account'), 0); + }); +}); diff --git a/src/__tests__/unit/working-directory.test.ts b/src/__tests__/unit/working-directory.test.ts new file mode 100644 index 00000000..85f071df --- /dev/null +++ b/src/__tests__/unit/working-directory.test.ts @@ -0,0 +1,65 @@ +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const TEST_ROOT = path.join(os.tmpdir(), `codepilot-working-dir-${Date.now()}`); +const VALID_DIR = path.join(TEST_ROOT, 'valid-project'); +const HOME_DIR = path.join(TEST_ROOT, 'fake-home'); + +describe('working-directory helpers', () => { + const originalHome = process.env.HOME; + let helpers: typeof import('../../lib/working-directory'); + + before(async () => { + fs.mkdirSync(VALID_DIR, { recursive: true }); + fs.mkdirSync(HOME_DIR, { recursive: true }); + process.env.HOME = HOME_DIR; + helpers = await import(path.resolve(__dirname, '../../lib/working-directory.ts')); + }); + + after(() => { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + fs.rmSync(TEST_ROOT, { recursive: true, force: true }); + }); + + it('accepts an existing directory', () => { + assert.equal(helpers.isExistingDirectory(VALID_DIR), true); + }); + + it('rejects a missing directory', () => { + assert.equal(helpers.isExistingDirectory(path.join(TEST_ROOT, 'missing')), false); + }); + + it('picks the first valid candidate after skipping invalid ones', () => { + const resolved = helpers.resolveWorkingDirectory([ + { path: path.join(TEST_ROOT, 'missing-a'), source: 'requested' }, + { path: VALID_DIR, source: 'binding' }, + ]); + + assert.equal(resolved.path, VALID_DIR); + assert.equal(resolved.source, 'binding'); + assert.deepEqual(resolved.invalidCandidates, [ + { path: path.join(TEST_ROOT, 'missing-a'), source: 'requested' }, + ]); + }); + + it('falls back to HOME when no candidates are valid', () => { + const resolved = helpers.resolveWorkingDirectory([ + { path: path.join(TEST_ROOT, 'missing-a'), source: 'requested' }, + { path: path.join(TEST_ROOT, 'missing-b'), source: 'setting' }, + ]); + + assert.equal(resolved.path, HOME_DIR); + assert.equal(resolved.source, 'home'); + assert.deepEqual(resolved.invalidCandidates, [ + { path: path.join(TEST_ROOT, 'missing-a'), source: 'requested' }, + { path: path.join(TEST_ROOT, 'missing-b'), source: 'setting' }, + ]); + }); +}); diff --git a/src/app/api/app/updates/route.ts b/src/app/api/app/updates/route.ts index 9294f49e..19c34881 100644 --- a/src/app/api/app/updates/route.ts +++ b/src/app/api/app/updates/route.ts @@ -1,4 +1,6 @@ import { NextResponse } from "next/server"; +import { getRuntimeArchitectureInfo } from "@/lib/platform"; +import { selectRecommendedReleaseAsset, type ReleaseAsset } from "@/lib/update-release"; const GITHUB_REPO = "op7418/CodePilot"; @@ -15,6 +17,7 @@ function compareSemver(a: string, b: string): number { export async function GET() { try { const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; + const runtimeInfo = getRuntimeArchitectureInfo(); const res = await fetch( `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, @@ -34,6 +37,10 @@ export async function GET() { const release = await res.json(); const latestVersion = (release.tag_name || "").replace(/^v/, ""); const updateAvailable = compareSemver(latestVersion, currentVersion) > 0; + const recommendedAsset = selectRecommendedReleaseAsset( + Array.isArray(release.assets) ? (release.assets as ReleaseAsset[]) : [], + runtimeInfo, + ); return NextResponse.json({ latestVersion, @@ -43,6 +50,12 @@ export async function GET() { releaseNotes: release.body || "", publishedAt: release.published_at || "", releaseUrl: release.html_url || "", + downloadUrl: recommendedAsset?.browser_download_url || release.html_url || "", + downloadAssetName: recommendedAsset?.name || "", + detectedPlatform: runtimeInfo.platform, + detectedArch: runtimeInfo.processArch, + hostArch: runtimeInfo.hostArch, + runningUnderRosetta: runtimeInfo.runningUnderRosetta, }); } catch { return NextResponse.json( diff --git a/src/app/api/bridge/route.ts b/src/app/api/bridge/route.ts index 14a530fc..007945c9 100644 --- a/src/app/api/bridge/route.ts +++ b/src/app/api/bridge/route.ts @@ -33,8 +33,8 @@ export async function POST(request: NextRequest) { const { action } = body; if (action === 'start') { - await bridgeManager.start(); - return Response.json({ ok: true, status: bridgeManager.getStatus() }); + const result = await bridgeManager.start(); + return Response.json({ ok: result.started, reason: result.reason, status: bridgeManager.getStatus() }); } else if (action === 'stop') { await bridgeManager.stop(); return Response.json({ ok: true, status: bridgeManager.getStatus() }); diff --git a/src/app/api/bridge/settings/route.ts b/src/app/api/bridge/settings/route.ts index 4787e210..d5511fd0 100644 --- a/src/app/api/bridge/settings/route.ts +++ b/src/app/api/bridge/settings/route.ts @@ -41,6 +41,8 @@ const BRIDGE_SETTING_KEYS = [ 'bridge_qq_allowed_users', 'bridge_qq_image_enabled', 'bridge_qq_max_image_size', + 'bridge_weixin_enabled', + 'bridge_weixin_media_enabled', ] as const; export async function GET() { diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 42ab31ee..b9a110be 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -4,50 +4,17 @@ import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessi import { resolveProvider as resolveProviderUnified } from '@/lib/provider-resolver'; import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot'; import { extractCompletion } from '@/lib/onboarding-completion'; +import { loadCodePilotMcpServers } from '@/lib/mcp-loader'; +import { assembleContext } from '@/lib/context-assembler'; import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, ClaudeStreamOptions } from '@/types'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import os from 'os'; -import type { MCPServerConfig } from '@/types'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** Read MCP server configs from ~/.claude.json, ~/.claude/settings.json, and project .mcp.json */ -function loadMcpServers(): Record | undefined { - try { - const readJson = (p: string): Record => { - if (!fs.existsSync(p)) return {}; - try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return {}; } - }; - const userConfig = readJson(path.join(os.homedir(), '.claude.json')); - const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')); - // Also read project-level .mcp.json - const projectMcp = readJson(path.join(process.cwd(), '.mcp.json')); - const merged = { - ...((userConfig.mcpServers || {}) as Record), - ...((settings.mcpServers || {}) as Record), - ...((projectMcp.mcpServers || {}) as Record), - }; - // Resolve ${...} placeholders in env values against DB settings - for (const server of Object.values(merged)) { - if (server.env) { - for (const [key, value] of Object.entries(server.env)) { - if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) { - const settingKey = value.slice(2, -1); - const resolved = getSetting(settingKey); - server.env[key] = resolved || ''; - } - } - } - } - return Object.keys(merged).length > 0 ? merged : undefined; - } catch { - return undefined; - } -} - export async function POST(request: NextRequest) { let activeSessionId: string | undefined; let activeLockId: string | undefined; @@ -153,23 +120,17 @@ export async function POST(request: NextRequest) { updateSessionProviderId(session_id, persistProviderId); } - // Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools) + // Resolve permission mode from request body (sent by frontend on each message) + // or fall back to session's persisted mode from DB. + // Request body mode takes priority to avoid race condition: user switches mode + // then immediately sends — the PATCH may not have landed in DB yet. const effectiveMode = mode || session.mode || 'code'; - let permissionMode: string; - let systemPromptOverride: string | undefined; - switch (effectiveMode) { - case 'plan': - permissionMode = 'plan'; - break; - case 'ask': - permissionMode = 'default'; - systemPromptOverride = (session.system_prompt || '') + - '\n\nYou are in Ask mode. Answer questions and provide information only. Do not use any tools, do not read or write files, do not execute commands. Only respond with text.'; - break; - default: // 'code' - permissionMode = 'acceptEdits'; - break; - } + const permissionMode = effectiveMode === 'plan' ? 'plan' : 'acceptEdits'; + + // Plan mode takes precedence over full_access: if the user explicitly chose + // Plan, they expect no tool execution regardless of permission profile. + const bypassPermissions = session.permission_profile === 'full_access' && effectiveMode !== 'plan'; + const systemPromptOverride: string | undefined = undefined; const abortController = new AbortController(); @@ -195,153 +156,6 @@ export async function POST(request: NextRequest) { }) : undefined; - // Load assistant workspace prompt if configured - let workspacePrompt = ''; - let assistantProjectInstructions = ''; - try { - const workspacePath = getSetting('assistant_workspace_path'); - if (workspacePath) { - const { loadWorkspaceFiles, assembleWorkspacePrompt, loadState, needsDailyCheckIn } = await import('@/lib/assistant-workspace'); - - // Only inject workspace files for assistant project sessions - const sessionWd = session.working_directory || ''; - const isAssistantProject = sessionWd === workspacePath; - - if (isAssistantProject) { - // Incremental reindex BEFORE search so current turn sees latest content - try { - const { indexWorkspace } = await import('@/lib/workspace-indexer'); - indexWorkspace(workspacePath); - } catch { - // indexer not available, skip - } - - const files = loadWorkspaceFiles(workspacePath); - - // Retrieval: search workspace index for relevant context - let retrievalResults: import('@/types').SearchResult[] | undefined; - try { - const { searchWorkspace, updateHotset } = await import('@/lib/workspace-retrieval'); - if (content.length > 10) { - retrievalResults = searchWorkspace(workspacePath, content, { limit: 5 }); - if (retrievalResults.length > 0) { - updateHotset(workspacePath, retrievalResults.map(r => r.path)); - } - } - } catch { - // retrieval module not available, skip - } - - workspacePrompt = assembleWorkspacePrompt(files, retrievalResults); - - const state = loadState(workspacePath); - - if (!state.onboardingComplete) { - // First-time onboarding: instruct AI to ask onboarding questions - assistantProjectInstructions = ` -You are now in the assistant workspace onboarding session. Your task is to interview the user to build their profile. - -Ask the following 13 questions ONE AT A TIME. Wait for the user's answer before asking the next question. Be conversational and friendly. - -1. How should I address you? -2. What name should I use for myself? -3. Do you prefer "concise and direct" or "detailed explanations"? -4. Do you prefer "minimal interruptions" or "proactive suggestions"? -5. What are your three hard boundaries? -6. What are your three most important current goals? -7. Do you prefer output as "lists", "reports", or "conversation summaries"? -8. What information may be written to long-term memory? -9. What information must never be written to long-term memory? -10. What three things should I do first when entering a project? -11. How do you organize your materials? (by project / time / topic / mixed) -12. Where should new information go by default? -13. How should completed tasks be archived? - -After the user answers the LAST question (Q13), you MUST immediately output the completion block below. Do NOT wait for the user to say anything else. Do NOT ask for confirmation. Just output the block right after your response to Q13. - -CRITICAL FORMATTING RULES for the completion block: -- Each value must be a single line (replace any newlines with spaces) -- Escape all double quotes inside values with backslash: \\" -- Do NOT use single quotes for JSON keys or values -- Do NOT add trailing commas -- The JSON must be on a SINGLE line - -\`\`\`onboarding-complete -{"q1":"answer1","q2":"answer2","q3":"answer3","q4":"answer4","q5":"answer5","q6":"answer6","q7":"answer7","q8":"answer8","q9":"answer9","q10":"answer10","q11":"answer11","q12":"answer12","q13":"answer13"} -\`\`\` - -After outputting the completion block, tell the user that the setup is complete and the system is now initializing their workspace. Keep this message brief and friendly. - -Do NOT try to write files yourself. The system will automatically generate soul.md, user.md, claude.md, memory.md, config.json, and taxonomy.json from your collected answers. - -Start by greeting the user and asking the first question. -`; - } else if (needsDailyCheckIn(state)) { - // Daily check-in: instruct AI to ask 3 quick questions - assistantProjectInstructions = ` -You are now in the assistant workspace daily check-in session. Ask the user these 3 questions ONE AT A TIME: - -1. What did you work on or accomplish today? -2. Any changes to your current priorities or goals? -3. Anything you'd like me to remember going forward? - -After collecting all 3 answers, output a summary in exactly this format: - -\`\`\`checkin-complete -{"q1":"answer1","q2":"answer2","q3":"answer3"} -\`\`\` - -Do NOT try to write files yourself. The system will automatically write a daily memory entry and update user.md from your collected answers. - -Start by greeting the user and asking the first question. -`; - } - - } - } - } catch (e) { - console.warn('[chat API] Failed to load assistant workspace:', e); - } - - // Append per-request system prompt (e.g. skill injection for image generation) - let finalSystemPrompt = systemPromptOverride || session.system_prompt || undefined; - if (systemPromptAppend) { - finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + systemPromptAppend; - } - - // Workspace prompt goes first (base personality), session prompt after (task override) - if (workspacePrompt) { - finalSystemPrompt = workspacePrompt + '\n\n' + (finalSystemPrompt || ''); - } - - // Assistant project instructions go after workspace prompt - if (assistantProjectInstructions) { - finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + assistantProjectInstructions; - } - - // Inject available CLI tools context (best-effort, non-blocking) - try { - const { buildCliToolsContext } = await import('@/lib/cli-tools-context'); - const cliToolsCtx = await buildCliToolsContext(); - if (cliToolsCtx) { - finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + cliToolsCtx; - } - } catch { - // CLI tools context injection failed — don't block chat - } - - // Inject widget (generative UI) system prompt — gated by user setting (default: enabled) - const generativeUISetting = getSetting('generative_ui_enabled'); - const generativeUIEnabled = generativeUISetting !== 'false'; - if (generativeUIEnabled) { - try { - const { WIDGET_SYSTEM_PROMPT } = await import('@/lib/widget-guidelines'); - finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + WIDGET_SYSTEM_PROMPT; - } catch { - // Widget prompt injection failed — don't block chat - } - } - // Load recent conversation history from DB as fallback context. // This is used when SDK session resume is unavailable or fails, // so the model still has conversation context. @@ -352,9 +166,23 @@ Start by greeting the user and asking the first question. content: m.content, })); - // Load MCP servers from Claude config files so the SDK knows about them - // even when settingSources skips 'user' (custom provider scenario). - const mcpServers = loadMcpServers(); + // Unified context assembly — extracts workspace, CLI tools, widget prompt + const assembled = await assembleContext({ + session, + entryPoint: 'desktop', + userPrompt: content, + systemPromptAppend, + conversationHistory: historyMsgs, + imageAgentMode: !!systemPromptAppend, + }); + const finalSystemPrompt = assembled.systemPrompt; + const generativeUIEnabled = assembled.generativeUIEnabled; + const assistantProjectInstructions = assembled.assistantProjectInstructions; + const isAssistantProject = assembled.isAssistantProject; + + // Load only MCP servers needing CodePilot-specific processing (${...} env placeholders). + // All other MCP servers are auto-loaded by the SDK via settingSources. + const mcpServers = loadCodePilotMcpServers(); // Stream Claude response, using SDK session ID for resume if available console.log('[chat API] streamClaude params:', { @@ -381,12 +209,12 @@ Start by greeting the user and asking the first question. sessionProviderId: session.provider_id || undefined, mcpServers, conversationHistory: historyMsgs, - bypassPermissions: session.permission_profile === 'full_access', + bypassPermissions, thinking: thinking as ClaudeStreamOptions['thinking'], effort: effort as ClaudeStreamOptions['effort'], context1m: context_1m, generativeUI: generativeUIEnabled, - enableFileCheckpointing: enableFileCheckpointing ?? (effectiveMode === 'code'), + enableFileCheckpointing: enableFileCheckpointing ?? true, autoTrigger: !!autoTrigger, onRuntimeStatusChange: (status: string) => { try { setSessionRuntimeStatus(session_id, status); } catch { /* best effort */ } diff --git a/src/app/api/chat/sessions/[id]/route.ts b/src/app/api/chat/sessions/[id]/route.ts index 32295981..d9322255 100644 --- a/src/app/api/chat/sessions/[id]/route.ts +++ b/src/app/api/chat/sessions/[id]/route.ts @@ -41,6 +41,12 @@ export async function PATCH( if (body.mode) { updateSessionMode(id, body.mode); } + // Track whether provider or model actually changed — if so, the old + // sdk_session_id is stale and must be cleared to prevent resume failures + // against a different provider/model (fixes #343, #346). + const modelChanged = body.model !== undefined && body.model !== session.model; + const providerChanged = body.provider_id !== undefined && body.provider_id !== session.provider_id; + if (body.model !== undefined) { updateSessionModel(id, body.model); } @@ -50,6 +56,20 @@ export async function PATCH( if (body.sdk_session_id !== undefined) { updateSdkSessionId(id, body.sdk_session_id); } + + // Server-side guard: when provider or model changed and the caller didn't + // explicitly set sdk_session_id in the same request, force-clear it so the + // next chat message starts a fresh SDK session instead of trying to resume + // the old one (which would fail with a different provider/model). + if ((modelChanged || providerChanged) && body.sdk_session_id === undefined) { + if (session.sdk_session_id) { + console.log( + `[session-api] Provider/model changed for session ${id}, clearing stale sdk_session_id`, + { modelChanged, providerChanged, oldSdkSessionId: session.sdk_session_id.slice(0, 8) + '...' } + ); + } + updateSdkSessionId(id, ''); + } if (body.permission_profile !== undefined) { if (body.permission_profile !== 'default' && body.permission_profile !== 'full_access') { return Response.json({ error: 'permission_profile must be "default" or "full_access"' }, { status: 400 }); diff --git a/src/app/api/doctor/export/route.ts b/src/app/api/doctor/export/route.ts index a9e41bff..d1f68c72 100644 --- a/src/app/api/doctor/export/route.ts +++ b/src/app/api/doctor/export/route.ts @@ -1,6 +1,6 @@ import os from 'os'; import { NextResponse } from 'next/server'; -import { runDiagnosis } from '@/lib/provider-doctor'; +import { getLastDiagnosisResult, runDiagnosis, getLastLiveProbeError } from '@/lib/provider-doctor'; import { getRecentLogs } from '@/lib/runtime-log'; import { resolveProvider } from '@/lib/provider-resolver'; @@ -100,15 +100,17 @@ function sanitizeValue(value: unknown): unknown { export async function GET() { try { - // Gather data in parallel - const [diagnosis, runtimeLogs] = await Promise.all([ - runDiagnosis(), - getRecentLogs(), - ]); + // Use cached diagnosis if available (avoid re-running live probe). + // Only run fresh diagnosis if Doctor hasn't been opened yet. + const diagnosis = getLastDiagnosisResult() ?? await runDiagnosis(); + const runtimeLogs = getRecentLogs(); // Resolve current provider chain (no raw keys thanks to sanitization) const providerResolution = resolveProvider(); + // Capture live probe error (if any) for debugging + const liveProbeError = getLastLiveProbeError(); + // Build the export package const exportPackage = { diagnosis: sanitizeValue(diagnosis), @@ -125,6 +127,15 @@ export async function GET() { providerName: providerResolution.provider?.name, providerType: providerResolution.provider?.provider_type, }), + liveProbeError: liveProbeError ? sanitizeValue({ + category: liveProbeError.category, + userMessage: liveProbeError.userMessage, + actionHint: liveProbeError.actionHint, + retryable: liveProbeError.retryable, + providerName: liveProbeError.providerName, + details: liveProbeError.details, + rawMessage: liveProbeError.rawMessage, + }) : null, exportedAt: new Date().toISOString(), }; diff --git a/src/app/api/doctor/route.ts b/src/app/api/doctor/route.ts index 2c3b04f2..ceebb7b3 100644 --- a/src/app/api/doctor/route.ts +++ b/src/app/api/doctor/route.ts @@ -1,12 +1,31 @@ -import { NextResponse } from 'next/server'; -import { runDiagnosis } from '@/lib/provider-doctor'; +import { NextRequest, NextResponse } from 'next/server'; +import { runDiagnosis, runLiveProbe, setLastDiagnosisResult } from '@/lib/provider-doctor'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export async function GET() { +/** + * GET /api/doctor — run diagnostic probes. + * ?live=true — also run the live probe (spawns CLI, takes up to 15s). + * Without ?live, only runs fast static probes (~1s). + */ +export async function GET(request: NextRequest) { try { const result = await runDiagnosis(); + + // Live probe is opt-in to avoid blocking the Doctor UI + const wantLive = request.nextUrl.searchParams.get('live') === 'true'; + if (wantLive) { + const liveResult = await runLiveProbe(); + result.probes.push(liveResult); + // Recalculate overall severity + if (liveResult.severity === 'error') result.overallSeverity = 'error'; + else if (liveResult.severity === 'warn' && result.overallSeverity === 'ok') result.overallSeverity = 'warn'; + result.durationMs = Date.now() - new Date(result.timestamp).getTime(); + // Update cache so export includes the live probe result + setLastDiagnosisResult(result); + } + return NextResponse.json(result); } catch (error) { console.error('[doctor] Diagnosis failed:', error); diff --git a/src/app/api/files/raw/route.ts b/src/app/api/files/raw/route.ts index 33dd3aeb..d9ed3b42 100644 --- a/src/app/api/files/raw/route.ts +++ b/src/app/api/files/raw/route.ts @@ -1,10 +1,16 @@ import { NextRequest } from 'next/server'; import fs from 'fs/promises'; import path from 'path'; +import os from 'os'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +function isPathSafe(base: string, target: string): boolean { + const normalizedBase = base.endsWith(path.sep) ? base : base + path.sep; + return target === base || target.startsWith(normalizedBase); +} + const MIME_TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', @@ -79,8 +85,8 @@ const MIME_TYPES: Record = { }; /** - * Serve raw file content from the user's home directory. - * Security: only allows reading files within the user's home directory. + * Serve raw file content. + * Security: restricts to user's home directory to prevent arbitrary file reads. */ export async function GET(request: NextRequest) { const filePath = request.nextUrl.searchParams.get('path'); @@ -93,6 +99,15 @@ export async function GET(request: NextRequest) { } const resolved = path.resolve(filePath); + const homeDir = os.homedir(); + + // Only allow reading files within the user's home directory + if (!isPathSafe(homeDir, resolved)) { + return new Response(JSON.stringify({ error: 'File is outside the allowed scope' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } try { await fs.access(resolved); diff --git a/src/app/api/plugins/mcp/[name]/route.ts b/src/app/api/plugins/mcp/[name]/route.ts index 4cb53cd9..70e26c62 100644 --- a/src/app/api/plugins/mcp/[name]/route.ts +++ b/src/app/api/plugins/mcp/[name]/route.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import type { MCPServerConfig, ErrorResponse, SuccessResponse } from '@/types'; +import { invalidateMcpCache } from '@/lib/mcp-loader'; function getSettingsPath(): string { return path.join(os.homedir(), '.claude', 'settings.json'); @@ -67,6 +68,7 @@ export async function DELETE( ); } + invalidateMcpCache(); return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( diff --git a/src/app/api/plugins/mcp/route.ts b/src/app/api/plugins/mcp/route.ts index acdd48aa..4560d971 100644 --- a/src/app/api/plugins/mcp/route.ts +++ b/src/app/api/plugins/mcp/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; import os from 'os'; +import { invalidateMcpCache } from '@/lib/mcp-loader'; import type { MCPServerConfig, MCPConfigResponse, @@ -47,15 +48,32 @@ export async function GET(): Promise; const userConfigServers = (userConfig.mcpServers || {}) as Record; - // Merge: ~/.claude/settings.json takes precedence over ~/.claude.json + // Also read project-level .mcp.json so the UI can display and toggle project servers + const projectMcp = readJsonFile(path.join(process.cwd(), '.mcp.json')); + const projectServers = (projectMcp.mcpServers || {}) as Record; + + // Merge: settings.json > claude.json > project .mcp.json // Tag each server with _source so UI knows where it came from const mcpServers: Record = {}; + for (const [name, server] of Object.entries(projectServers)) { + mcpServers[name] = { ...server, _source: 'project' }; + } for (const [name, server] of Object.entries(userConfigServers)) { mcpServers[name] = { ...server, _source: 'claude.json' }; } for (const [name, server] of Object.entries(settingsServers)) { mcpServers[name] = { ...server, _source: 'settings.json' }; } + + // For project-source servers, check if settings.json has an enabled override + // (project .mcp.json is read-only; we persist enabled state to settings.json) + const settingsOverrides = (settings.mcpServerOverrides || {}) as Record; + for (const [name, server] of Object.entries(mcpServers)) { + if (server._source === 'project' && settingsOverrides[name]?.enabled !== undefined) { + mcpServers[name] = { ...server, enabled: settingsOverrides[name].enabled }; + } + } + return NextResponse.json({ mcpServers }); } catch (error) { return NextResponse.json( @@ -77,10 +95,17 @@ export async function PUT( // Servers with _source='claude.json' → ~/.claude.json const forSettings: Record = {}; const forUserConfig: Record = {}; + let forProjectOverrides: Record | undefined; for (const [name, server] of Object.entries(incoming)) { const { _source, ...cleanServer } = server; - if (_source === 'claude.json') { + if (_source === 'project') { + // Project servers are read-only — only persist enabled override to settings.json + if (cleanServer.enabled !== undefined) { + if (!forProjectOverrides) forProjectOverrides = {}; + forProjectOverrides[name] = { enabled: cleanServer.enabled }; + } + } else if (_source === 'claude.json') { forUserConfig[name] = cleanServer; } else { forSettings[name] = cleanServer; @@ -90,6 +115,9 @@ export async function PUT( // Write settings.json const settings = readSettings(); settings.mcpServers = forSettings; + if (forProjectOverrides) { + settings.mcpServerOverrides = forProjectOverrides; + } writeSettings(settings); // Write ~/.claude.json (only the mcpServers key, preserve other fields) @@ -102,6 +130,7 @@ export async function PUT( } fs.writeFileSync(userConfigPath, JSON.stringify(userConfig, null, 2), 'utf-8'); + invalidateMcpCache(); return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( @@ -148,6 +177,7 @@ export async function POST( mcpServers[name] = server; writeSettings(settings); + invalidateMcpCache(); return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts index bad8de18..740d97e3 100644 --- a/src/app/api/providers/models/route.ts +++ b/src/app/api/providers/models/route.ts @@ -151,7 +151,7 @@ export async function GET() { } // Add each role model to the list (default role first, so it appears at the top) for (const entry of roleEntries) { - if (!rawModels.some(m => m.value === entry.id)) { + if (!rawModels.some(m => m.value === entry.id || m.upstreamModelId === entry.id)) { const label = entry.role === 'default' ? entry.id : `${entry.id} (${entry.role})`; rawModels.unshift({ value: entry.id, label }); } @@ -159,10 +159,12 @@ export async function GET() { } catch { /* ignore */ } // Legacy: inject ANTHROPIC_MODEL from env overrides if not already present + // Also check upstreamModelId to avoid duplicates (e.g. catalog has modelId='sonnet' + // with upstreamModelId='mimo-v2-pro', and env has ANTHROPIC_MODEL='mimo-v2-pro') try { const envOverrides = provider.env_overrides_json || provider.extra_env || '{}'; const envObj = JSON.parse(envOverrides); - if (envObj.ANTHROPIC_MODEL && !rawModels.some(m => m.value === envObj.ANTHROPIC_MODEL)) { + if (envObj.ANTHROPIC_MODEL && !rawModels.some(m => m.value === envObj.ANTHROPIC_MODEL || m.upstreamModelId === envObj.ANTHROPIC_MODEL)) { rawModels.unshift({ value: envObj.ANTHROPIC_MODEL, label: envObj.ANTHROPIC_MODEL }); } } catch { /* ignore */ } @@ -176,7 +178,7 @@ export async function GET() { }); // Detect SDK-proxy-only providers via preset match - const preset = findPresetForLegacy(provider.base_url, provider.provider_type); + const preset = findPresetForLegacy(provider.base_url, provider.provider_type, protocol); const sdkProxyOnly = preset?.sdkProxyOnly === true; groups.push({ diff --git a/src/app/api/settings/weixin/accounts/[accountId]/route.ts b/src/app/api/settings/weixin/accounts/[accountId]/route.ts new file mode 100644 index 00000000..7b841360 --- /dev/null +++ b/src/app/api/settings/weixin/accounts/[accountId]/route.ts @@ -0,0 +1,95 @@ +/** + * Single WeChat account operations. + * PATCH — enable/disable account + * DELETE — remove account + */ + +import { NextResponse } from 'next/server'; +import { getWeixinAccount, setWeixinAccountEnabled, deleteWeixinAccount } from '@/lib/db'; +import { getStatus, restart } from '@/lib/bridge/bridge-manager'; + +function isBenignRestartReason(reason: string): boolean { + if (reason === 'no_channels_enabled') { + return true; + } + if (!reason.startsWith('adapter_config_invalid: ')) { + return false; + } + const errors = reason + .slice('adapter_config_invalid: '.length) + .split('; ') + .map(item => item.trim()) + .filter(Boolean); + return errors.length > 0 && errors.every(error => + error.startsWith('weixin: No enabled WeChat accounts') + || error.startsWith('weixin: No WeChat accounts have valid tokens') + ); +} + +/** Restart bridge if running so worker pool reflects account changes. */ +async function restartIfRunning(): Promise { + if (!getStatus().running) { + return null; + } + try { + const result = await restart(); + if (result.started || !result.reason || isBenignRestartReason(result.reason)) { + return null; + } + return result.reason; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ accountId: string }> }, +) { + try { + const { accountId } = await params; + const account = getWeixinAccount(accountId); + if (!account) { + return NextResponse.json({ error: 'Account not found' }, { status: 404 }); + } + + const body = await request.json(); + if (typeof body.enabled === 'boolean') { + setWeixinAccountEnabled(accountId, body.enabled); + const restartError = await restartIfRunning(); + if (restartError) { + return NextResponse.json( + { ok: false, error: restartError, account_updated: true }, + { status: 503 }, + ); + } + } + + return NextResponse.json({ ok: true }); + } catch (err) { + return NextResponse.json({ error: 'Failed to update account' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ accountId: string }> }, +) { + try { + const { accountId } = await params; + const deleted = deleteWeixinAccount(accountId); + if (!deleted) { + return NextResponse.json({ error: 'Account not found' }, { status: 404 }); + } + const restartError = await restartIfRunning(); + if (restartError) { + return NextResponse.json( + { ok: false, error: restartError, account_deleted: true }, + { status: 503 }, + ); + } + return NextResponse.json({ ok: true }); + } catch (err) { + return NextResponse.json({ error: 'Failed to delete account' }, { status: 500 }); + } +} diff --git a/src/app/api/settings/weixin/accounts/route.ts b/src/app/api/settings/weixin/accounts/route.ts new file mode 100644 index 00000000..1b7b258d --- /dev/null +++ b/src/app/api/settings/weixin/accounts/route.ts @@ -0,0 +1,28 @@ +/** + * WeChat accounts list API. + * GET — returns all accounts (token masked) + */ + +import { NextResponse } from 'next/server'; +import { listWeixinAccounts } from '@/lib/db'; + +export async function GET() { + try { + const accounts = listWeixinAccounts().map(a => ({ + account_id: a.account_id, + user_id: a.user_id, + base_url: a.base_url, + cdn_base_url: a.cdn_base_url, + name: a.name, + enabled: a.enabled === 1, + last_login_at: a.last_login_at, + created_at: a.created_at, + updated_at: a.updated_at, + // Mask token for security + has_token: !!(a.token && a.token.length > 0), + })); + return NextResponse.json({ accounts }); + } catch (err) { + return NextResponse.json({ error: 'Failed to list accounts' }, { status: 500 }); + } +} diff --git a/src/app/api/settings/weixin/login/start/route.ts b/src/app/api/settings/weixin/login/start/route.ts new file mode 100644 index 00000000..fd5c3b27 --- /dev/null +++ b/src/app/api/settings/weixin/login/start/route.ts @@ -0,0 +1,19 @@ +/** + * Start WeChat QR code login. + * POST — generates a QR code for scanning + */ + +import { NextResponse } from 'next/server'; +import { startQrLoginSession } from '@/lib/bridge/adapters/weixin/weixin-auth'; + +export async function POST() { + try { + const { sessionId, qrImage } = await startQrLoginSession(); + return NextResponse.json({ session_id: sessionId, qr_image: qrImage }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to start QR login' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/settings/weixin/login/wait/route.ts b/src/app/api/settings/weixin/login/wait/route.ts new file mode 100644 index 00000000..ec758499 --- /dev/null +++ b/src/app/api/settings/weixin/login/wait/route.ts @@ -0,0 +1,58 @@ +/** + * Poll WeChat QR login status. + * POST { session_id } — polls the QR code status + */ + +import { NextResponse } from 'next/server'; +import { pollQrLoginStatus, cancelQrLoginSession } from '@/lib/bridge/adapters/weixin/weixin-auth'; +import { getStatus, restart } from '@/lib/bridge/bridge-manager'; + +async function restartIfRunning(): Promise { + if (!getStatus().running) { + return null; + } + try { + const result = await restart(); + return result.started ? null : (result.reason || 'Bridge restart failed'); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const sessionId = body.session_id; + if (!sessionId) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }); + } + + const session = await pollQrLoginStatus(sessionId); + + // Clean up completed sessions + if (session.status === 'confirmed' || session.status === 'failed') { + // Don't delete immediately — let the client read the final status + setTimeout(() => cancelQrLoginSession(sessionId), 30_000); + } + + // Auto-restart bridge when a new account is confirmed so the weixin + // adapter picks it up without manual stop/start. + let bridgeRestartError: string | undefined; + if (session.status === 'confirmed') { + bridgeRestartError = (await restartIfRunning()) || undefined; + } + + return NextResponse.json({ + status: session.status, + qr_image: session.qrImage || undefined, + account_id: session.accountId || undefined, + error: session.error || undefined, + bridge_restart_error: bridgeRestartError, + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to poll login status' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/settings/weixin/route.ts b/src/app/api/settings/weixin/route.ts new file mode 100644 index 00000000..32f4b590 --- /dev/null +++ b/src/app/api/settings/weixin/route.ts @@ -0,0 +1,45 @@ +/** + * WeChat global settings API. + * GET — returns current settings + * PUT — updates settings + */ + +import { NextResponse } from 'next/server'; +import { getSetting, setSetting } from '@/lib/db'; + +const WEIXIN_KEYS = [ + 'bridge_weixin_enabled', + 'bridge_weixin_media_enabled', +] as const; + +export async function GET() { + try { + const settings: Record = {}; + for (const key of WEIXIN_KEYS) { + settings[key] = getSetting(key) || ''; + } + return NextResponse.json({ settings }); + } catch (err) { + return NextResponse.json({ error: 'Failed to load settings' }, { status: 500 }); + } +} + +export async function PUT(request: Request) { + try { + const body = await request.json(); + const { settings } = body as { settings?: Record }; + if (!settings) { + return NextResponse.json({ error: 'Missing settings' }, { status: 400 }); + } + + for (const key of WEIXIN_KEYS) { + if (key in settings) { + setSetting(key, settings[key]); + } + } + + return NextResponse.json({ ok: true }); + } catch (err) { + return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 }); + } +} diff --git a/src/app/api/setup/route.ts b/src/app/api/setup/route.ts index ef9a50a1..40e2c8ce 100644 --- a/src/app/api/setup/route.ts +++ b/src/app/api/setup/route.ts @@ -34,9 +34,23 @@ export async function GET() { if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || appToken) { provider = 'completed'; } else { - // No real provider found — check if user previously skipped setup - const providerSkipped = getSetting('setup_provider_skipped'); - provider = providerSkipped === 'true' ? 'skipped' : 'not-configured'; + // Check if Claude Code CLI is available — it acts as a provider via SDK proxy + // (the built-in 'env' provider in /api/providers/models always lists Claude Code, + // so we must recognise the CLI as a valid provider to keep the UI consistent) + try { + const binary = findClaudeBinary(); + if (binary) { + provider = 'completed'; + } + } catch { + // CLI not found — continue to fallback + } + + if (provider === 'not-configured') { + // No real provider found — check if user previously skipped setup + const providerSkipped = getSetting('setup_provider_skipped'); + provider = providerSkipped === 'true' ? 'skipped' : 'not-configured'; + } } } diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index a14c3f04..14b4551e 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -20,9 +20,9 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const [error, setError] = useState(null); const [sessionModel, setSessionModel] = useState(''); const [sessionProviderId, setSessionProviderId] = useState(''); - const [sessionMode, setSessionMode] = useState(''); const [sessionInfoLoaded, setSessionInfoLoaded] = useState(false); const [sessionPermissionProfile, setSessionPermissionProfile] = useState<'default' | 'full_access'>('default'); + const [sessionMode, setSessionMode] = useState<'code' | 'plan'>('code'); const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle } = usePanel(); const { t } = useTranslation(); @@ -33,7 +33,6 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { setWorkingDirectory(''); setSessionModel(''); setSessionProviderId(''); - setSessionMode(''); setSessionInfoLoaded(false); async function loadSession() { @@ -53,8 +52,8 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { setPanelSessionTitle(title); setSessionModel(data.session.model || ''); setSessionProviderId(data.session.provider_id || ''); - setSessionMode(data.session.mode || 'code'); setSessionPermissionProfile(data.session.permission_profile || 'default'); + setSessionMode((data.session.mode as 'code' | 'plan') || 'code'); } } catch { // Session info load failed - panel will still work without directory @@ -127,7 +126,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { return (
- +
); } diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index c4bc1abf..9163f4ac 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -6,6 +6,7 @@ import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestE import { MessageList } from '@/components/chat/MessageList'; import { MessageInput } from '@/components/chat/MessageInput'; import { ChatComposerActionBar } from '@/components/chat/ChatComposerActionBar'; +import { ModeIndicator } from '@/components/chat/ModeIndicator'; import { ChatPermissionSelector } from '@/components/chat/ChatPermissionSelector'; import { ImageGenToggle } from '@/components/chat/ImageGenToggle'; import { PermissionPrompt } from '@/components/chat/PermissionPrompt'; @@ -43,25 +44,27 @@ export default function NewChatPage() { const [errorBanner, setErrorBanner] = useState<{ message: string; description?: string } | null>(null); const [recentProjects, setRecentProjects] = useState([]); const [hasProvider, setHasProvider] = useState(true); // assume true until checked - const [mode] = useState('code'); + const [mode, setMode] = useState('code'); + // Model/provider start empty — populated by the async global-default fetch. + // This prevents the race where a user sends before the fetch completes and + // gets the stale localStorage model instead of the configured default. + const [modelReady, setModelReady] = useState(false); const [currentModel, setCurrentModel] = useState(() => { - if (typeof window === 'undefined') return 'sonnet'; + if (typeof window === 'undefined') return ''; // One-time migration: clear stale model/provider from pre-0.38 installs if (!localStorage.getItem('codepilot:migration-038')) { localStorage.removeItem('codepilot:last-model'); localStorage.removeItem('codepilot:last-provider-id'); localStorage.setItem('codepilot:migration-038', '1'); - return 'sonnet'; } - return localStorage.getItem('codepilot:last-model') || 'sonnet'; + return ''; }); const [currentProviderId, setCurrentProviderId] = useState(() => { if (typeof window === 'undefined') return ''; - // Migration already ran above (or was already done), just read if (!localStorage.getItem('codepilot:migration-038')) { return ''; } - return localStorage.getItem('codepilot:last-provider-id') || ''; + return ''; }); const [pendingPermission, setPendingPermission] = useState(null); const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null); @@ -91,34 +94,87 @@ export default function NewChatPage() { return () => controller.abort(); }, [currentProviderId]); - // Validate restored model/provider against actual available providers/models + // Validate restored model/provider against actual available providers/models. + // For NEW conversations, the global default model takes priority + // over localStorage's last-model (which is a cross-session global memory). useEffect(() => { let cancelled = false; - fetch('/api/providers/models') - .then(r => r.ok ? r.json() : null) - .then(data => { - if (cancelled || !data?.groups || data.groups.length === 0) return; - const groups = data.groups as Array<{ provider_id: string; models: Array<{ value: string }> }>; - - // Validate provider - const validProvider = groups.find(g => g.provider_id === currentProviderId); - if (currentProviderId && !validProvider) { - setCurrentProviderId(''); - localStorage.removeItem('codepilot:last-provider-id'); + + // Fetch models and global default in parallel + const modelsP = fetch('/api/providers/models').then(r => r.ok ? r.json() : null); + const globalP = fetch('/api/providers/options?providerId=__global__').then(r => r.ok ? r.json() : null); + + Promise.all([modelsP, globalP]).then(([modelsData, globalData]) => { + if (cancelled || !modelsData?.groups || modelsData.groups.length === 0) { + // No provider data — fall back to localStorage best-effort + const savedModel = localStorage.getItem('codepilot:last-model') || 'sonnet'; + const savedProvider = localStorage.getItem('codepilot:last-provider-id') || ''; + setCurrentModel(savedModel); + setCurrentProviderId(savedProvider); + setModelReady(true); + return; + } + const groups = modelsData.groups as Array<{ provider_id: string; models: Array<{ value: string }> }>; + const globalDefaultModel = globalData?.options?.default_model || ''; + const globalDefaultProvider = globalData?.options?.default_model_provider || ''; + + // Apply global default for new conversations + // Case 1: both provider and model are set and valid + if (globalDefaultModel && globalDefaultProvider) { + const targetGroup = groups.find(g => g.provider_id === globalDefaultProvider); + const modelValid = targetGroup?.models.some(m => m.value === globalDefaultModel); + if (modelValid) { + setCurrentModel(globalDefaultModel); + setCurrentProviderId(globalDefaultProvider); + setModelReady(true); + return; } + } + // Case 2: provider is set but model was cleared (e.g. after doctor repair / provider delete) + // → use that provider's first available model + if (globalDefaultProvider && !globalDefaultModel) { + const targetGroup = groups.find(g => g.provider_id === globalDefaultProvider); + if (targetGroup?.models?.length) { + setCurrentModel(targetGroup.models[0].value); + setCurrentProviderId(globalDefaultProvider); + setModelReady(true); + return; + } + } - // Validate model against the resolved provider's model list - const resolvedGroup = validProvider || groups[0]; - if (resolvedGroup?.models && resolvedGroup.models.length > 0) { - const validModel = resolvedGroup.models.find(m => m.value === currentModel); - if (!validModel) { - const fallback = resolvedGroup.models[0].value; - setCurrentModel(fallback); - localStorage.setItem('codepilot:last-model', fallback); - } + // No global default — use localStorage, validate against provider's list + const savedProvider = localStorage.getItem('codepilot:last-provider-id') || ''; + const savedModel = localStorage.getItem('codepilot:last-model') || ''; + const validProvider = groups.find(g => g.provider_id === savedProvider); + const resolvedGroup = validProvider || groups[0]; + const resolvedPid = resolvedGroup?.provider_id || ''; + + if (validProvider) { + setCurrentProviderId(savedProvider); + } else { + setCurrentProviderId(resolvedPid); + } + + if (resolvedGroup?.models && resolvedGroup.models.length > 0) { + const validModel = savedModel && resolvedGroup.models.some(m => m.value === savedModel); + if (validModel) { + setCurrentModel(savedModel); + } else { + setCurrentModel(resolvedGroup.models[0].value); } - }) - .catch(() => {}); + } else { + setCurrentModel(savedModel || 'sonnet'); + } + setModelReady(true); + }).catch(() => { + // Fetch failed — fall back to localStorage best-effort + const savedModel = localStorage.getItem('codepilot:last-model') || 'sonnet'; + const savedProvider = localStorage.getItem('codepilot:last-provider-id') || ''; + setCurrentModel(savedModel); + setCurrentProviderId(savedProvider); + setModelReady(true); + }); + return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Run once on mount to validate initial values @@ -188,6 +244,8 @@ export default function NewChatPage() { // Check provider availability — only 'completed' counts, 'skipped' means user deferred but has no real credentials useEffect(() => { const checkProvider = () => { + // Lock sending while we re-resolve the model/provider + setModelReady(false); fetch('/api/setup') .then(r => r.ok ? r.json() : null) .then(data => { @@ -196,47 +254,84 @@ export default function NewChatPage() { } }) .catch(() => {}); - // Sync provider/model from localStorage, validating against available providers + // Sync provider/model, applying global default model for new conversations. const savedProviderId = localStorage.getItem('codepilot:last-provider-id'); - const savedModel = localStorage.getItem('codepilot:last-model'); - fetch('/api/providers/models') - .then(r => r.ok ? r.json() : null) - .then(data => { - if (!data?.groups || data.groups.length === 0) return; - const groups = data.groups as Array<{ provider_id: string; models: Array<{ value: string }> }>; - - // Validate and apply provider - if (savedProviderId !== null) { - const validProvider = groups.find(g => g.provider_id === savedProviderId); - if (validProvider) { - setCurrentProviderId(savedProviderId); - } else { - setCurrentProviderId(''); - localStorage.removeItem('codepilot:last-provider-id'); - } + + // Fetch models + global default in parallel + const modelsP = fetch('/api/providers/models').then(r => r.ok ? r.json() : null); + const globalP = fetch('/api/providers/options?providerId=__global__').then(r => r.ok ? r.json() : null); + + Promise.all([modelsP, globalP]).then(([modelsData, globalData]) => { + if (!modelsData?.groups || modelsData.groups.length === 0) { + setModelReady(true); + return; + } + const groups = modelsData.groups as Array<{ provider_id: string; models: Array<{ value: string }> }>; + const globalDefaultModel = globalData?.options?.default_model || ''; + const globalDefaultProvider = globalData?.options?.default_model_provider || ''; + + // Validate and apply provider + if (savedProviderId !== null) { + const validProvider = groups.find(g => g.provider_id === savedProviderId); + if (validProvider) { + setCurrentProviderId(savedProviderId); + } else { + setCurrentProviderId(''); + localStorage.removeItem('codepilot:last-provider-id'); } + } - // Validate and apply model - const resolvedPid = savedProviderId && groups.find(g => g.provider_id === savedProviderId) - ? savedProviderId - : groups[0]?.provider_id || ''; - const resolvedGroup = groups.find(g => g.provider_id === resolvedPid) || groups[0]; - if (savedModel && resolvedGroup?.models?.length > 0) { - const validModel = resolvedGroup.models.find((m: { value: string }) => m.value === savedModel); - if (validModel) { - setCurrentModel(savedModel); - } else { - const fallback = resolvedGroup.models[0].value; - setCurrentModel(fallback); - localStorage.setItem('codepilot:last-model', fallback); - } + // Apply global default for new conversations + // Case 1: both provider and model are set and valid + if (globalDefaultModel && globalDefaultProvider) { + const targetGroup = groups.find(g => g.provider_id === globalDefaultProvider); + const modelValid = targetGroup?.models.some(m => m.value === globalDefaultModel); + if (modelValid) { + setCurrentModel(globalDefaultModel); + setCurrentProviderId(globalDefaultProvider); + setModelReady(true); + return; } - }) - .catch(() => { - // On fetch failure, still apply localStorage values as-is (best effort) - if (savedProviderId !== null) setCurrentProviderId(savedProviderId); - if (savedModel) setCurrentModel(savedModel); - }); + } + // Case 2: provider is set but model was cleared (e.g. after doctor repair / provider delete) + // → use that provider's first available model + if (globalDefaultProvider && !globalDefaultModel) { + const targetGroup = groups.find(g => g.provider_id === globalDefaultProvider); + if (targetGroup?.models?.length) { + setCurrentModel(targetGroup.models[0].value); + setCurrentProviderId(globalDefaultProvider); + setModelReady(true); + return; + } + } + + // No global default — validate current model + const resolvedPid = savedProviderId && groups.find(g => g.provider_id === savedProviderId) + ? savedProviderId + : groups[0]?.provider_id || ''; + const resolvedGroup = groups.find(g => g.provider_id === resolvedPid) || groups[0]; + setCurrentProviderId(resolvedPid); + if (resolvedGroup?.models?.length > 0) { + const savedModel = localStorage.getItem('codepilot:last-model'); + const validModel = savedModel && resolvedGroup.models.some( + (m: { value: string }) => m.value === savedModel + ); + if (validModel) { + setCurrentModel(savedModel); + } else { + const fallback = resolvedGroup.models[0].value; + setCurrentModel(fallback); + localStorage.setItem('codepilot:last-model', fallback); + } + } + setModelReady(true); + }).catch(() => { + // On fetch failure, still apply localStorage values as-is (best effort) + if (savedProviderId !== null) setCurrentProviderId(savedProviderId); + const savedModel = localStorage.getItem('codepilot:last-model'); + if (savedModel) setCurrentModel(savedModel); + setModelReady(true); + }); }; checkProvider(); @@ -311,6 +406,9 @@ export default function NewChatPage() { async (content: string, _files?: unknown, systemPromptAppend?: string, displayOverride?: string) => { if (isStreaming) return; + // Wait for model/provider to be resolved from the global default before allowing send + if (!modelReady) return; + // Require a project directory before sending if (!workingDir.trim()) { setErrorBanner({ message: t('chat.empty.noDirectory') }); @@ -521,7 +619,7 @@ export default function NewChatPage() { 'CLI_NOT_FOUND', 'UNSUPPORTED_FEATURE', ]); if (diagCategories.has(parsed.category)) { - errorDisplay += '\n\n💡 Go to **Settings → Providers → Run Diagnostics** for detailed troubleshooting.'; + errorDisplay += '\n\n💡 [Run Provider Diagnostics](/settings#providers) to troubleshoot, or check the [Provider Setup Guide](https://www.codepilot.sh/docs/providers).'; } } else { errorDisplay = event.data; @@ -580,7 +678,7 @@ export default function NewChatPage() { abortControllerRef.current = null; } }, - [isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, selectedEffort, thinkingMode, context1m, setPendingApprovalSessionId, t, hasProvider] + [isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, selectedEffort, thinkingMode, context1m, setPendingApprovalSessionId, t, hasProvider, modelReady] ); const handleCommand = useCallback((command: string) => { @@ -660,7 +758,7 @@ export default function NewChatPage() { onSend={sendFirstMessage} onCommand={handleCommand} onStop={stopStreaming} - disabled={false} + disabled={!modelReady} isStreaming={isStreaming} modelName={currentModel} onModelChange={setCurrentModel} @@ -676,7 +774,7 @@ export default function NewChatPage() { onEffortChange={setSelectedEffort} /> } + left={<>} center={ { @@ -98,6 +101,7 @@ export function BridgeLayout() { {activeSection === "feishu" && } {activeSection === "discord" && } {activeSection === "qq" && } + {activeSection === "weixin" && } diff --git a/src/components/bridge/BridgeSection.tsx b/src/components/bridge/BridgeSection.tsx index d57815f9..a864b9b6 100644 --- a/src/components/bridge/BridgeSection.tsx +++ b/src/components/bridge/BridgeSection.tsx @@ -16,6 +16,7 @@ import { import { SpinnerGap, CheckCircle, Warning, TelegramLogo, ChatTeardrop, GameController, ChatsCircle } from "@/components/ui/icon"; import { useTranslation } from "@/hooks/useTranslation"; import { useBridgeStatus } from "@/hooks/useBridgeStatus"; +import { showToast } from "@/hooks/useToast"; import { SettingsCard } from "@/components/patterns/SettingsCard"; import { FieldRow } from "@/components/patterns/FieldRow"; import { StatusBanner } from "@/components/patterns/StatusBanner"; @@ -27,6 +28,7 @@ interface BridgeSettings { bridge_feishu_enabled: string; bridge_discord_enabled: string; bridge_qq_enabled: string; + bridge_weixin_enabled: string; bridge_auto_start: string; bridge_default_work_dir: string; bridge_default_model: string; @@ -39,6 +41,7 @@ const DEFAULT_SETTINGS: BridgeSettings = { bridge_feishu_enabled: "", bridge_discord_enabled: "", bridge_qq_enabled: "", + bridge_weixin_enabled: "", bridge_auto_start: "", bridge_default_work_dir: "", bridge_default_model: "", @@ -133,6 +136,10 @@ export function BridgeSection() { saveSettings({ bridge_qq_enabled: checked ? "true" : "" }); }; + const handleToggleWeixin = (checked: boolean) => { + saveSettings({ bridge_weixin_enabled: checked ? "true" : "" }); + }; + const handleSaveDefaults = () => { // Split composite "provider_id::model" value const parts = model.split("::"); @@ -168,11 +175,28 @@ export function BridgeSection() { saveSettings({ bridge_auto_start: checked ? "true" : "" }); }; + const handleStartBridge = async () => { + const reason = await startBridge(); + if (reason) { + const reasonMessages: Record = { + bridge_not_enabled: t("bridge.errorNotEnabled"), + no_channels_enabled: t("bridge.errorNoChannels"), + no_adapters_started: t("bridge.errorNoAdapters"), + network_error: t("bridge.errorNetwork"), + }; + const message = reason.startsWith("adapter_config_invalid:") + ? t("bridge.errorAdapterConfig") + : reasonMessages[reason] ?? reason; + showToast({ type: "error", message }); + } + }; + const isEnabled = settings.remote_bridge_enabled === "true"; const isTelegramEnabled = settings.bridge_telegram_enabled === "true"; const isFeishuEnabled = settings.bridge_feishu_enabled === "true"; const isDiscordEnabled = settings.bridge_discord_enabled === "true"; const isQQEnabled = settings.bridge_qq_enabled === "true"; + const isWeixinEnabled = settings.bridge_weixin_enabled === "true"; const isAutoStart = settings.bridge_auto_start === "true"; const isRunning = bridgeStatus?.running ?? false; const adapterCount = bridgeStatus?.adapters?.length ?? 0; @@ -242,7 +266,7 @@ export function BridgeSection() { ) : ( + + + ) : ( + + )} + + + ))} + + )} + + {/* Add Account / QR Login */} + {!qrImage ? ( + + ) : ( +
+
+

+ + {t("weixin.qrLogin")} +

+ +
+ + {/* QR Code Image */} +
+ WeChat QR Code +
+ + {/* Status */} +
+ {qrStatus === "waiting" && ( + + + {t("weixin.qrWaiting")} + + )} + {qrStatus === "scanned" && ( + + + {t("weixin.qrScanned")} + + )} + {qrStatus === "confirmed" && ( + + + {t("weixin.qrConfirmed")} + + )} + {qrStatus === "expired" && ( + + + {t("weixin.qrExpired")} + + )} + {qrStatus === "failed" && ( + + + {t("weixin.qrFailed")} + + )} + {qrBridgeError && ( + + + {formatToastMessage(t("weixin.qrConfirmedRestartFailed"), qrBridgeError)} + + )} +
+
+ )} + + + {/* Setup Guide */} + +
    +
  1. {t("weixin.step1")}
  2. +
  3. {t("weixin.step2")}
  4. +
  5. {t("weixin.step3")}
  6. +
  7. {t("weixin.step4")}
  8. +
  9. {t("weixin.step5")}
  10. +
+
+ + ); +} diff --git a/src/components/chat/ChatPermissionSelector.tsx b/src/components/chat/ChatPermissionSelector.tsx index 615ca1f1..14d27a16 100644 --- a/src/components/chat/ChatPermissionSelector.tsx +++ b/src/components/chat/ChatPermissionSelector.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { cn } from '@/lib/utils'; import { useTranslation } from '@/hooks/useTranslation'; import { DropdownMenu, @@ -72,13 +73,14 @@ export function ChatPermissionSelector({ diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index d54d1776..553de2af 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -81,10 +81,15 @@ export function MessageInput({ // --- Extracted hooks --- const popover = usePopoverState(modelName); - const { providerGroups, currentProviderIdValue, modelOptions, currentModelOption } = useProviderModels(providerId, modelName); + const { providerGroups, currentProviderIdValue, modelOptions, currentModelOption, globalDefaultModel, globalDefaultProvider } = useProviderModels(providerId, modelName); // Auto-correct model when it doesn't exist in the current provider's model list. // This prevents sending an unsupported model name (e.g. 'opus' to MiniMax which only has 'sonnet'). + // IMPORTANT: Only fall back to first model — never use globalDefaultModel here. + // Global default model is only for NEW conversations (chat/page.tsx). + // Existing sessions must keep their own selected model; if that model becomes + // invalid (provider changed), fall back to the provider's first model, not the + // global default, to avoid overwriting the session's model choice. useEffect(() => { if (modelName && modelOptions.length > 0 && !modelOptions.some(m => m.value === modelName)) { const fallback = modelOptions[0].value; @@ -425,6 +430,8 @@ export function MessageInput({ modelOptions={modelOptions} onModelChange={onModelChange} onProviderModelChange={onProviderModelChange} + globalDefaultModel={globalDefaultModel} + globalDefaultProvider={globalDefaultProvider} /> {/* Effort selector — only visible when model supports effort */} diff --git a/src/components/chat/MessageInputParts.tsx b/src/components/chat/MessageInputParts.tsx index 15bb7765..6f3a2a22 100644 --- a/src/components/chat/MessageInputParts.tsx +++ b/src/components/chat/MessageInputParts.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useCallback } from 'react'; import { ArrowUp, Plus, X, Stop, Terminal } from '@/components/ui/icon'; import { Button } from '@/components/ui/button'; import { useTranslation } from '@/hooks/useTranslation'; @@ -74,20 +74,43 @@ export function AttachFileButton() { /** * Bridge component that listens for 'attach-file-to-chat' custom events - * from the file tree and inserts `@filepath` into the textarea. + * from the file tree and adds the file as a proper attachment (capsule). + * Uses /api/files/raw to fetch the real file binary, preserving type and content. */ export function FileTreeAttachmentBridge() { + const attachments = usePromptInputAttachments(); + + const handleAttach = useCallback(async (filePath: string) => { + try { + const res = await fetch(`/api/files/raw?path=${encodeURIComponent(filePath)}`); + if (!res.ok) { + // Fallback: insert as @mention if the raw API fails + window.dispatchEvent(new CustomEvent('insert-file-mention', { detail: { path: filePath } })); + return; + } + const blob = await res.blob(); + const fileName = filePath.split('/').pop() || 'file'; + // Use the content-type from the server response (it resolves from extension) + const contentType = res.headers.get('content-type') || 'application/octet-stream'; + const file = new File([blob], fileName, { type: contentType }); + attachments.add([file]); + } catch { + // Fallback: insert as @mention if fetch fails + window.dispatchEvent(new CustomEvent('insert-file-mention', { detail: { path: filePath } })); + } + }, [attachments]); + useEffect(() => { const handler = (e: Event) => { const customEvent = e as CustomEvent<{ path: string }>; const filePath = customEvent.detail?.path; if (!filePath) return; - window.dispatchEvent(new CustomEvent('insert-file-mention', { detail: { path: filePath } })); + handleAttach(filePath); }; window.addEventListener('attach-file-to-chat', handler); return () => window.removeEventListener('attach-file-to-chat', handler); - }, []); + }, [handleAttach]); return null; } diff --git a/src/components/chat/ModeIndicator.tsx b/src/components/chat/ModeIndicator.tsx new file mode 100644 index 00000000..4f4fe68e --- /dev/null +++ b/src/components/chat/ModeIndicator.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useTranslation } from '@/hooks/useTranslation'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Code, NotePencil } from '@/components/ui/icon'; + +interface ModeIndicatorProps { + mode: 'code' | 'plan' | string; + onModeChange: (mode: string) => void; + disabled?: boolean; +} + +export function ModeIndicator({ mode, onModeChange, disabled }: ModeIndicatorProps) { + const { t } = useTranslation(); + + return ( + + + + + {t('messageInput.modeCode')} + + + + {t('messageInput.modePlan')} + + + + ); +} diff --git a/src/components/chat/ModelSelectorDropdown.tsx b/src/components/chat/ModelSelectorDropdown.tsx index 83670f4f..127661aa 100644 --- a/src/components/chat/ModelSelectorDropdown.tsx +++ b/src/components/chat/ModelSelectorDropdown.tsx @@ -31,6 +31,10 @@ interface ModelSelectorDropdownProps { modelOptions: ModelOption[]; onModelChange?: (model: string) => void; onProviderModelChange?: (providerId: string, model: string) => void; + /** Global default model value */ + globalDefaultModel?: string; + /** Global default model's provider ID */ + globalDefaultProvider?: string; } export function ModelSelectorDropdown({ @@ -40,14 +44,25 @@ export function ModelSelectorDropdown({ modelOptions, onModelChange, onProviderModelChange, + globalDefaultModel, + globalDefaultProvider, }: ModelSelectorDropdownProps) { const { t } = useTranslation(); + const isZh = t('nav.chats') === '对话'; const modelMenuRef = useRef(null); const [modelMenuOpen, setModelMenuOpen] = useState(false); const [modelSearch, setModelSearch] = useState(''); const currentModelOption = modelOptions.find((m) => m.value === currentModelValue) || modelOptions[0]; + // Is the currently displayed model the global default? + const isCurrentDefault = !!( + globalDefaultModel && + globalDefaultProvider && + currentModelValue === globalDefaultModel && + currentProviderIdValue === globalDefaultProvider + ); + // Click outside to close model menu useEffect(() => { if (!modelMenuOpen) return; @@ -84,6 +99,11 @@ export function ModelSelectorDropdown({ onClick={() => setModelMenuOpen((prev) => !prev)} > {currentModelOption?.label} + {isCurrentDefault && ( + + {isZh ? '默认' : 'Default'} + + )} @@ -111,6 +131,12 @@ export function ModelSelectorDropdown({
{group.models.map((opt) => { const isActive = opt.value === currentModelValue && group.provider_id === currentProviderIdValue; + const isDefault = !!( + globalDefaultModel && + globalDefaultProvider && + opt.value === globalDefaultModel && + group.provider_id === globalDefaultProvider + ); return ( handleModelSelect(group.provider_id, opt.value)} className="justify-between" > - {opt.label} + + {opt.label} + {isDefault && ( + + {isZh ? '默认' : 'Default'} + + )} + {isActive && } ); diff --git a/src/components/chat/PermissionPrompt.tsx b/src/components/chat/PermissionPrompt.tsx index ad85d5ef..07c0e5ad 100644 --- a/src/components/chat/PermissionPrompt.tsx +++ b/src/components/chat/PermissionPrompt.tsx @@ -23,6 +23,7 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; import type { ToolUIPart } from 'ai'; import type { PermissionRequestEvent } from '@/types'; @@ -40,6 +41,10 @@ interface PermissionPromptProps { permissionProfile?: 'default' | 'full_access'; } +/** Max lines to show in the tool input area before collapsing */ +const MAX_INPUT_LINES = 8; +const MAX_INPUT_CHARS = 500; + function AskUserQuestionUI({ toolInput, onSubmit, @@ -310,6 +315,66 @@ function ExitPlanModeUI({ ); } +/** + * Collapsible tool input display with truncation for long content. + */ +function ToolInputDisplay({ input }: { input: Record }) { + const [expanded, setExpanded] = useState(false); + + const formatToolInput = (inp: Record): string => { + // For Bash, show command prominently + if (inp.command) { + const cmd = String(inp.command); + // If there are other keys besides command/description, show full JSON + const extraKeys = Object.keys(inp).filter(k => k !== 'command' && k !== 'description'); + if (extraKeys.length > 0) { + return JSON.stringify(inp, null, 2); + } + return cmd; + } + // For Write/Edit, show the full input so content/old_string/new_string are visible + if (inp.file_path) { + const keys = Object.keys(inp); + if (keys.length === 1) return String(inp.file_path); + return JSON.stringify(inp, null, 2); + } + if (inp.path) { + const keys = Object.keys(inp); + if (keys.length === 1) return String(inp.path); + return JSON.stringify(inp, null, 2); + } + return JSON.stringify(inp, null, 2); + }; + + const formatted = formatToolInput(input); + const lineCount = formatted.split('\n').length; + const isTruncated = lineCount > MAX_INPUT_LINES || formatted.length > MAX_INPUT_CHARS; + + const displayText = !expanded && isTruncated + ? formatted.slice(0, MAX_INPUT_CHARS).split('\n').slice(0, MAX_INPUT_LINES).join('\n') + '\n…' + : formatted; + + return ( +
+
+        {displayText}
+      
+ {isTruncated && ( + + )} +
+ ); +} + export function PermissionPrompt({ pendingPermission, permissionResolved, @@ -339,6 +404,11 @@ export function PermissionPrompt({ // Nothing to show if (!pendingPermission && !permissionResolved) return null; + // Only show the resolved status text (not the full UI) when already resolved. + // This prevents stacking — once resolved, we show a minimal status line that + // auto-hides quickly (the stream-session-manager clears it after 1s). + const isResolved = !!permissionResolved; + const getConfirmationState = (): ToolUIPart['state'] => { if (permissionResolved) return 'approval-responded'; if (pendingPermission) return 'approval-requested'; @@ -356,17 +426,10 @@ export function PermissionPrompt({ return { id: pendingPermission?.permissionRequestId || '' }; }; - const formatToolInput = (input: Record): string => { - if (input.command) return String(input.command); - if (input.file_path) return String(input.file_path); - if (input.path) return String(input.path); - return JSON.stringify(input, null, 2); - }; - return ( -
+
{/* ExitPlanMode */} - {pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && ( + {pendingPermission?.toolName === 'ExitPlanMode' && !isResolved && ( } toolUses={toolUses} @@ -383,36 +446,32 @@ export function PermissionPrompt({ )} {/* AskUserQuestion */} - {pendingPermission?.toolName === 'AskUserQuestion' && !permissionResolved && ( + {pendingPermission?.toolName === 'AskUserQuestion' && !isResolved && ( } onSubmit={(decision, updatedInput) => onPermissionResponse(decision, updatedInput)} /> )} - {pendingPermission?.toolName === 'AskUserQuestion' && permissionResolved && ( + {pendingPermission?.toolName === 'AskUserQuestion' && isResolved && (

Answer submitted

)} - {/* Generic confirmation for other tools */} - {pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && (pendingPermission || permissionResolved) && ( + {/* Generic confirmation for other tools — only show when not yet resolved */} + {pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && pendingPermission && !isResolved && ( - {pendingPermission?.toolName} - {pendingPermission?.decisionReason && ( + {pendingPermission.toolName} + {pendingPermission.decisionReason && ( — {pendingPermission.decisionReason} )} - {pendingPermission && ( -
- {formatToolInput(pendingPermission.toolInput)} -
- )} + @@ -428,7 +487,7 @@ export function PermissionPrompt({ > Allow Once - {pendingPermission?.suggestions && pendingPermission.suggestions.length > 0 && ( + {pendingPermission.suggestions && pendingPermission.suggestions.length > 0 && ( onPermissionResponse('allow_session')} @@ -448,6 +507,16 @@ export function PermissionPrompt({
)} + + {/* Resolved status for generic tools — minimal one-liner */} + {pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && isResolved && ( +

+ {permissionResolved === 'allow' ? t('streaming.allowed') : t('streaming.denied')} +

+ )}
); } diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 3444e378..df76a2d7 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -53,7 +53,7 @@ function loadActiveColumn(): string { const EMPTY_SET = new Set(); const CHATLIST_MIN = 180; -const CHATLIST_MAX = 400; +const CHATLIST_MAX = 300; /** Extensions that default to "rendered" view mode */ const RENDERED_EXTENSIONS = new Set([".md", ".mdx", ".html", ".htm"]); diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index bb69314e..08ca1c58 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -272,6 +272,53 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { } }; + const handleRenameSession = async (sessionId: string, newTitle: string) => { + try { + const res = await fetch(`/api/chat/sessions/${sessionId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: newTitle }), + }); + if (res.ok) { + setSessions((prev) => + prev.map((s) => (s.id === sessionId ? { ...s, title: newTitle } : s)) + ); + window.dispatchEvent(new CustomEvent("session-updated")); + } + } catch { + // Silently fail + } + }; + + const handleRemoveProject = async (workingDirectory: string) => { + if (!confirm(`Remove project "${workingDirectory.split('/').pop()}" and all its conversations?`)) return; + const projectSessions = sessions.filter((s) => s.working_directory === workingDirectory); + const deletedIds = new Set(); + for (const session of projectSessions) { + try { + const res = await fetch(`/api/chat/sessions/${session.id}`, { method: "DELETE" }); + if (res.ok) { + deletedIds.add(session.id); + if (isInSplit(session.id)) { + removeFromSplit(session.id); + } + } + } catch { + // Continue with remaining + } + } + // Only remove sessions that were successfully deleted from backend + if (deletedIds.size > 0) { + setSessions((prev) => prev.filter((s) => !deletedIds.has(s.id))); + if (pathname?.startsWith('/chat/')) { + const currentSessionId = pathname.split('/chat/')[1]; + if (deletedIds.has(currentSessionId)) { + router.push("/chat"); + } + } + } + }; + const handleCreateSessionInProject = async ( e: React.MouseEvent, workingDirectory: string @@ -405,7 +452,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
{/* Session list grouped by project */} - +
{/* Section title */}
@@ -453,6 +500,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { onMouseEnter={() => setHoveredFolder(group.workingDirectory)} onMouseLeave={() => setHoveredFolder(null)} onCreateSession={(e) => handleCreateSessionInProject(e, group.workingDirectory)} + onRemoveProject={handleRemoveProject} /> {/* Session items */} @@ -477,6 +525,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { onMouseEnter={() => setHoveredSession(session.id)} onMouseLeave={() => setHoveredSession(null)} onDelete={handleDeleteSession} + onRename={handleRenameSession} onAddToSplit={(s) => addToSplit({ sessionId: s.id, title: s.title, diff --git a/src/components/layout/ProjectGroupHeader.tsx b/src/components/layout/ProjectGroupHeader.tsx index baedd09e..ac127815 100644 --- a/src/components/layout/ProjectGroupHeader.tsx +++ b/src/components/layout/ProjectGroupHeader.tsx @@ -6,17 +6,24 @@ import { CaretRight, Plus, FolderOpen, + FolderMinus, UserCircle, + DotsThree, + Copy, + ArrowSquareOut, } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { useTranslation } from '@/hooks/useTranslation'; -import { useClientPlatform } from '@/hooks/useClientPlatform'; +import type { TranslationKey } from "@/i18n"; +import { useState } from "react"; interface ProjectGroupHeaderProps { workingDirectory: string; @@ -28,6 +35,7 @@ interface ProjectGroupHeaderProps { onMouseEnter: () => void; onMouseLeave: () => void; onCreateSession: (e: React.MouseEvent) => void; + onRemoveProject?: (workingDirectory: string) => void; } export function ProjectGroupHeader({ @@ -40,83 +48,107 @@ export function ProjectGroupHeader({ onMouseEnter, onMouseLeave, onCreateSession, + onRemoveProject, }: ProjectGroupHeaderProps) { const { t } = useTranslation(); - const { fileManagerName } = useClientPlatform(); + const [menuOpen, setMenuOpen] = useState(false); + const showActions = isFolderHovered || menuOpen; return ( - - -
{ - e.stopPropagation(); - if (workingDirectory) { - if (window.electronAPI?.shell?.openPath) { - window.electronAPI.shell.openPath(workingDirectory); - } else { - fetch('/api/files/open', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: workingDirectory }), - }).catch(() => {}); - } - } - }} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - > - {isCollapsed ? ( - - ) : ( - - )} - {isCollapsed ? ( - - ) : ( - - )} - - {displayName} - - {isWorkspace && ( - - )} - {/* New chat in project button (on hover) */} - {workingDirectory !== "" && ( - - - - - - {t('chatList.newConversation')} - {displayName} - - - )} +
+ {isCollapsed ? ( + + ) : ( + + )} + {isCollapsed ? ( + + ) : ( + + )} + + {displayName} + + {isWorkspace && ( + + )} + {/* Action buttons (on hover) */} + {workingDirectory !== "" && ( +
+ {/* New chat button */} + + {/* Three-dot menu */} + + + + + e.stopPropagation()}> + { + const w = window as unknown as { electronAPI?: { shell?: { openPath?: (p: string) => void } } }; + if (w.electronAPI?.shell?.openPath) { + w.electronAPI.shell.openPath(workingDirectory); + } else { + fetch('/api/files/open', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: workingDirectory }), + }).catch(() => {}); + } + }}> + + {t('chatList.openFolder' as TranslationKey)} + + { + navigator.clipboard.writeText(workingDirectory); + }}> + + {t('chatList.copyFolderPath' as TranslationKey)} + + {onRemoveProject && !isWorkspace && ( + <> + + onRemoveProject(workingDirectory)} + > + + {t('chatList.removeProject' as TranslationKey)} + + + )} + +
- - -

{workingDirectory || t('chatList.noSessions')}

- {workingDirectory &&

{t('platform.openInFileManager', { fileManager: fileManagerName })}

} -
- + )} +
); } diff --git a/src/components/layout/SessionListItem.tsx b/src/components/layout/SessionListItem.tsx index 053d654c..07a943ff 100644 --- a/src/components/layout/SessionListItem.tsx +++ b/src/components/layout/SessionListItem.tsx @@ -1,13 +1,24 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { Trash, Bell, Columns, X, + DotsThree, + Copy, + PencilSimple, } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import type { ChatSession } from "@/types"; import type { TranslationKey } from "@/i18n"; @@ -25,6 +36,7 @@ interface SessionListItemProps { onMouseEnter: () => void; onMouseLeave: () => void; onDelete: (e: React.MouseEvent, sessionId: string) => void; + onRename: (sessionId: string, newTitle: string) => void; onAddToSplit: (session: ChatSession) => void; } @@ -41,8 +53,12 @@ export function SessionListItem({ onMouseEnter, onMouseLeave, onDelete, + onRename, onAddToSplit, }: SessionListItemProps) { + const [menuOpen, setMenuOpen] = useState(false); + const showActions = isHovered || menuOpen || isDeleting; + return (
- {/* Left icon area — always same size, swap content via opacity */} + {/* Left icon area — streaming/approval indicators */} - {/* Split icon: visible on hover when splittable */} - {canSplit && ( - - )} - {/* Streaming indicator: hidden when hover shows split icon */} {isSessionStreaming && ( - + )} - {/* Approval indicator: hidden when hover shows split icon */} {needsApproval && !isSessionStreaming && ( - + )} -
- - {session.title} - -
- {/* Right area — fixed width, time and delete stacked with opacity */} -
+ {/* Title — flex-1 + truncate ensures it shrinks */} + + {session.title} + + {/* Right area — fixed width, time or dots swap via opacity */} + {formatRelativeTime(session.updated_at, t)} + + + {/* Three-dot menu — absolute over the right area */} + + -
- + + + onAddToSplit(session)} + > + + {t('chatList.splitScreen' as TranslationKey)} + + { + const newTitle = prompt("Rename conversation:", session.title); + if (newTitle && newTitle !== session.title) { + onRename(session.id, newTitle); + } + }}> + + {t('chatList.renameConversation' as TranslationKey)} + + { + navigator.clipboard.writeText(session.id); + }}> + + {t('chatList.copySessionId' as TranslationKey)} + + + onDelete(e as unknown as React.MouseEvent, session.id)} + > + + {t('chatList.deleteConversation' as TranslationKey)} + + +
); } diff --git a/src/components/layout/SplitColumn.tsx b/src/components/layout/SplitColumn.tsx index e17b96aa..e774e447 100644 --- a/src/components/layout/SplitColumn.tsx +++ b/src/components/layout/SplitColumn.tsx @@ -25,7 +25,6 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu const [sessionModel, setSessionModel] = useState(""); const [sessionProviderId, setSessionProviderId] = useState(""); const [sessionInfoLoaded, setSessionInfoLoaded] = useState(false); - const [sessionMode, setSessionMode] = useState(""); const [projectName, setProjectName] = useState(""); const [sessionWorkingDir, setSessionWorkingDir] = useState(""); const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle } = usePanel(); @@ -46,7 +45,6 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu setSessionTitle(data.session.title || t("chat.newConversation")); setSessionModel(data.session.model || ""); setSessionProviderId(data.session.provider_id || ""); - setSessionMode(data.session.mode || "code"); setProjectName(data.session.project_name || ""); setSessionWorkingDir(data.session.working_directory || ""); } @@ -185,7 +183,6 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu initialMessages={messages} initialHasMore={hasMore} modelName={sessionModel} - initialMode={sessionMode} providerId={sessionProviderId} />
diff --git a/src/components/layout/UpdateBanner.tsx b/src/components/layout/UpdateBanner.tsx index 122afce9..221b4eb9 100644 --- a/src/components/layout/UpdateBanner.tsx +++ b/src/components/layout/UpdateBanner.tsx @@ -1,21 +1,83 @@ "use client"; +import { useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { useUpdate } from "@/hooks/useUpdate"; import { useTranslation } from "@/hooks/useTranslation"; +function getRosettaDismissKey(assetName: string, version: string): string { + return `codepilot:rosetta-warning-dismissed:${assetName || version || 'unknown'}`; +} + export function UpdateBanner() { const { updateInfo, quitAndInstall } = useUpdate(); const { t } = useTranslation(); + const [dismissedRosetta, setDismissedRosetta] = useState(false); + + const rosettaDismissKey = useMemo( + () => getRosettaDismissKey(updateInfo?.downloadAssetName || '', updateInfo?.latestVersion || ''), + [updateInfo?.downloadAssetName, updateInfo?.latestVersion], + ); + + useEffect(() => { + if (!updateInfo?.runningUnderRosetta) { + setDismissedRosetta(false); + return; + } - if (!updateInfo?.isNativeUpdate || !updateInfo.readyToInstall) return null; + try { + setDismissedRosetta(localStorage.getItem(rosettaDismissKey) === '1'); + } catch { + setDismissedRosetta(false); + } + }, [rosettaDismissKey, updateInfo?.runningUnderRosetta]); + + const dismissRosettaWarning = () => { + try { + localStorage.setItem(rosettaDismissKey, '1'); + } catch { + // ignore persistence failures + } + setDismissedRosetta(true); + }; + + const openRecommendedDownload = () => { + if (!updateInfo) return; + window.open(updateInfo.downloadUrl || updateInfo.releaseUrl, '_blank'); + }; + + const showRosettaWarning = !!updateInfo?.runningUnderRosetta && !dismissedRosetta; + const showReadyBanner = !!updateInfo?.isNativeUpdate && !!updateInfo.readyToInstall; + + if (!showRosettaWarning && !showReadyBanner) return null; return ( -
- {t('update.readyToInstall', { version: updateInfo.latestVersion })} - -
+ <> + {showRosettaWarning && updateInfo && ( +
+ {t('update.rosettaWarning')} + {updateInfo.downloadAssetName && ( + + {t('update.recommendedAsset', { asset: updateInfo.downloadAssetName })} + + )} + + +
+ )} + + {showReadyBanner && updateInfo && ( +
+ {t('update.readyToInstall', { version: updateInfo.latestVersion })} + +
+ )} + ); } diff --git a/src/components/layout/UpdateDialog.tsx b/src/components/layout/UpdateDialog.tsx index 663df141..cd189a0e 100644 --- a/src/components/layout/UpdateDialog.tsx +++ b/src/components/layout/UpdateDialog.tsx @@ -83,6 +83,18 @@ export function UpdateDialog() { Current: v{updateInfo.currentVersion} → Latest: v{updateInfo.latestVersion}

+ {updateInfo.runningUnderRosetta && ( +

+ {t('update.rosettaWarning')} +

+ )} + + {updateInfo.downloadAssetName && ( +

+ {t('update.recommendedAsset', { asset: updateInfo.downloadAssetName })} +

+ )} + {/* Download progress bar */} {isDownloading && (
@@ -111,10 +123,10 @@ export function UpdateDialog() { {!isNativeUpdate ? ( ) : readyToInstall ? (

- Configure Model Context Protocol servers for Claude + {t('mcp.managerDesc' as TranslationKey)}

)} @@ -354,7 +402,7 @@ export function ProviderDoctorDialog({ open, onOpenChange }: ProviderDoctorDialo : "如果您仍然遇到问题,"} 请先点击「导出日志」,然后前往{" "} {" "}提交问题报告,并附上导出的日志文件。 +
+ 📖 查看{" "} + + 服务商配置指南 + ) : ( <> @@ -370,7 +428,7 @@ export function ProviderDoctorDialog({ open, onOpenChange }: ProviderDoctorDialo : "If you're still experiencing problems, "} click “Export Logs” first, then{" "} {" "}and attach the exported log file. +
+ 📖 See the{" "} + + Provider Setup Guide + )}
diff --git a/src/components/settings/ProviderManager.tsx b/src/components/settings/ProviderManager.tsx index c28f4abb..3ba1cefc 100644 --- a/src/components/settings/ProviderManager.tsx +++ b/src/components/settings/ProviderManager.tsx @@ -26,10 +26,20 @@ import { findMatchingPreset, type QuickPreset, } from "./provider-presets"; -import type { ApiProvider } from "@/types"; +import type { ApiProvider, ProviderModelGroup } from "@/types"; import { useTranslation } from "@/hooks/useTranslation"; +import type { TranslationKey } from "@/i18n"; import Anthropic from "@lobehub/icons/es/Anthropic"; import { ProviderOptionsSection } from "./ProviderOptionsSection"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; // --------------------------------------------------------------------------- // Main component @@ -59,6 +69,11 @@ export function ProviderManager() { // Doctor dialog state const [doctorOpen, setDoctorOpen] = useState(false); + // Global default model state + const [providerGroups, setProviderGroups] = useState([]); + const [globalDefaultModel, setGlobalDefaultModel] = useState(''); + const [globalDefaultProvider, setGlobalDefaultProvider] = useState(''); + const fetchProviders = useCallback(async () => { try { setError(null); @@ -76,6 +91,33 @@ export function ProviderManager() { useEffect(() => { fetchProviders(); }, [fetchProviders]); + // Fetch all provider models for the global default model selector + const fetchModels = useCallback(() => { + fetch('/api/providers/models') + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data?.groups) setProviderGroups(data.groups); + }) + .catch(() => {}); + // Load current global default model + fetch('/api/providers/options?providerId=__global__') + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data?.options?.default_model) { + setGlobalDefaultModel(data.options.default_model); + setGlobalDefaultProvider(data.options.default_model_provider || ''); + } + }) + .catch(() => {}); + }, []); + + useEffect(() => { + fetchModels(); + const handler = () => fetchModels(); + window.addEventListener('provider-changed', handler); + return () => window.removeEventListener('provider-changed', handler); + }, [fetchModels]); + const handleEdit = (provider: ApiProvider) => { // Try to match provider to a quick preset for a cleaner edit experience const matchedPreset = findMatchingPreset(provider); @@ -124,27 +166,6 @@ export function ProviderManager() { const newProvider: ApiProvider = result.provider; setProviders((prev) => [...prev, newProvider]); - // Auto-set as default if this is the first provider - // Otherwise ask the user if they want to switch - if (newProvider.id) { - const isFirst = providers.length === 0; - const shouldSwitch = isFirst || window.confirm( - isZh - ? `已添加「${newProvider.name}」。是否将其设为默认服务商?\n(当前新对话将使用此服务商)` - : `Added "${newProvider.name}". Set as default provider?\n(New conversations will use this provider)` - ); - if (shouldSwitch) { - try { - await fetch('/api/providers/set-default', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider_id: newProvider.id }), - }); - localStorage.setItem('codepilot:last-provider-id', newProvider.id); - } catch { /* best effort */ } - } - } - window.dispatchEvent(new Event("provider-changed")); }; @@ -196,6 +217,40 @@ export function ProviderManager() { const sorted = [...providers].sort((a, b) => a.sort_order - b.sort_order); + // Save global default model — also syncs default_provider_id for backend consumers + const handleGlobalDefaultModelChange = useCallback(async (compositeValue: string) => { + if (compositeValue === '__auto__') { + setGlobalDefaultModel(''); + setGlobalDefaultProvider(''); + // Clear both global default model AND legacy default_provider_id in one call + await fetch('/api/providers/options', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerId: '__global__', + options: { default_model: '', default_model_provider: '', legacy_default_provider_id: '' }, + }), + }).catch(() => {}); + } else { + // compositeValue format: "providerId::modelValue" + const sepIdx = compositeValue.indexOf('::'); + const pid = compositeValue.slice(0, sepIdx); + const model = compositeValue.slice(sepIdx + 2); + setGlobalDefaultModel(model); + setGlobalDefaultProvider(pid); + // Write global default model + sync legacy default_provider_id in one call + await fetch('/api/providers/options', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerId: '__global__', + options: { default_model: model, default_model_provider: pid, legacy_default_provider_id: pid }, + }), + }).catch(() => {}); + } + window.dispatchEvent(new Event('provider-changed')); + }, []); + return (
{/* Error */} @@ -205,7 +260,7 @@ export function ProviderManager() {
)} - {/* ─── Section 0: Troubleshooting ─── */} + {/* ─── Section 0: Troubleshooting + Default Model ─── */}
@@ -226,6 +281,49 @@ export function ProviderManager() { {isZh ? '运行诊断' : 'Run Diagnostics'}
+ + {/* Divider */} +
+ + {/* Global default model */} +
+
+

{t('settings.defaultModel' as TranslationKey)}

+

+ {t('settings.defaultModelDesc' as TranslationKey)} +

+
+ {providerGroups.length > 0 && ( + + )} +
{/* Loading */} @@ -250,9 +348,6 @@ export function ProviderManager() {
Claude Code - - {t('provider.default')} - {Object.keys(envDetected).length > 0 && ( ENV @@ -264,7 +359,7 @@ export function ProviderManager() {

{t('provider.ccSwitchHint')}

- +
{/* Connected provider list */} @@ -307,9 +402,12 @@ export function ProviderManager() {
- {/* Provider options (thinking mode + 1M context) — only for official Anthropic */} - {provider.base_url === 'https://api.anthropic.com' && ( - + {/* Provider options — thinking/1M for Anthropic-official only */} + {provider.provider_type !== 'gemini-image' && provider.base_url === 'https://api.anthropic.com' && ( + )} {/* Gemini Image model selector — capsule buttons */} {provider.provider_type === 'gemini-image' && ( diff --git a/src/components/settings/ProviderOptionsSection.tsx b/src/components/settings/ProviderOptionsSection.tsx index 474803bf..8cda41ce 100644 --- a/src/components/settings/ProviderOptionsSection.tsx +++ b/src/components/settings/ProviderOptionsSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { Switch } from "@/components/ui/switch"; import { Select, @@ -15,14 +15,15 @@ import type { ProviderOptions } from "@/types"; interface ProviderOptionsSectionProps { providerId: string; + /** Show thinking mode + 1M context options (only for Anthropic-compatible providers) */ + showThinkingOptions?: boolean; } /** * Per-provider options: thinking mode + 1M context toggle. - * Only rendered for providers that support these features - * (env / anthropic-official). + * Only rendered when `showThinkingOptions` is true. */ -export function ProviderOptionsSection({ providerId }: ProviderOptionsSectionProps) { +export function ProviderOptionsSection({ providerId, showThinkingOptions = false }: ProviderOptionsSectionProps) { const { t } = useTranslation(); const [options, setOptions] = useState({ thinking_mode: 'adaptive', @@ -32,16 +33,13 @@ export function ProviderOptionsSection({ providerId }: ProviderOptionsSectionPro useEffect(() => { let cancelled = false; - (async () => { - try { - const res = await fetch(`/api/providers/options?providerId=${encodeURIComponent(providerId)}`); - if (!cancelled && res.ok) { - const data = await res.json(); - setOptions(data.options || {}); - } - } catch { /* ignore */ } - if (!cancelled) setLoaded(true); - })(); + fetch(`/api/providers/options?providerId=${encodeURIComponent(providerId)}`) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (!cancelled && data) setOptions(data.options || {}); + if (!cancelled) setLoaded(true); + }) + .catch(() => { if (!cancelled) setLoaded(true); }); return () => { cancelled = true; }; }, [providerId]); @@ -57,7 +55,7 @@ export function ProviderOptionsSection({ providerId }: ProviderOptionsSectionPro } catch { /* ignore */ } }; - if (!loaded) return null; + if (!loaded || !showThinkingOptions) return null; return (
diff --git a/src/components/settings/provider-presets.tsx b/src/components/settings/provider-presets.tsx index 646bda91..fb6f7410 100644 --- a/src/components/settings/provider-presets.tsx +++ b/src/components/settings/provider-presets.tsx @@ -14,6 +14,7 @@ import Bedrock from "@lobehub/icons/es/Bedrock"; import Google from "@lobehub/icons/es/Google"; import Volcengine from "@lobehub/icons/es/Volcengine"; import Bailian from "@lobehub/icons/es/Bailian"; +import XiaomiMiMo from "@lobehub/icons/es/XiaomiMiMo"; // --------------------------------------------------------------------------- // Brand icon resolver @@ -34,6 +35,8 @@ export function getProviderIcon(name: string, baseUrl: string): ReactNode { return ; if (url.includes("dashscope") || lower.includes("bailian") || lower.includes("百炼") || lower.includes("aliyun")) return ; + if (url.includes("xiaomimimo") || lower.includes("mimo") || lower.includes("小米")) + return ; if (lower.includes("bedrock")) return ; if (lower.includes("vertex") || lower.includes("google")) return ; if (lower.includes("aws")) return ; @@ -171,8 +174,8 @@ export const QUICK_PRESETS: QuickPreset[] = [ icon: , provider_type: "anthropic", protocol: "anthropic", - base_url: "https://api.minimaxi.com/anthropic/v1", - extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_AUTH_TOKEN":"","ANTHROPIC_MODEL":"MiniMax-M2.5","ANTHROPIC_DEFAULT_SONNET_MODEL":"MiniMax-M2.5","ANTHROPIC_DEFAULT_OPUS_MODEL":"MiniMax-M2.5","ANTHROPIC_DEFAULT_HAIKU_MODEL":"MiniMax-M2.5"}', + base_url: "https://api.minimaxi.com/anthropic", + extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_AUTH_TOKEN":""}', fields: ["api_key"], }, { @@ -184,7 +187,7 @@ export const QUICK_PRESETS: QuickPreset[] = [ provider_type: "anthropic", protocol: "anthropic", base_url: "https://api.minimax.io/anthropic", - extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_AUTH_TOKEN":"","ANTHROPIC_MODEL":"MiniMax-M2.5","ANTHROPIC_DEFAULT_SONNET_MODEL":"MiniMax-M2.5","ANTHROPIC_DEFAULT_OPUS_MODEL":"MiniMax-M2.5","ANTHROPIC_DEFAULT_HAIKU_MODEL":"MiniMax-M2.5"}', + extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_AUTH_TOKEN":""}', fields: ["api_key"], }, { @@ -199,6 +202,18 @@ export const QUICK_PRESETS: QuickPreset[] = [ extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}', fields: ["api_key", "model_names"], }, + { + key: "xiaomi-mimo", + name: "Xiaomi MiMo", + description: "Xiaomi MiMo Coding Plan — MiMo-V2-Pro", + descriptionZh: "小米 MiMo 编程套餐 — MiMo-V2-Pro", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://api.xiaomimimo.com/anthropic", + extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}', + fields: ["api_key"], + }, { key: "bailian", name: "Aliyun Bailian", diff --git a/src/components/ui/icon.tsx b/src/components/ui/icon.tsx index 8621e501..fd993119 100644 --- a/src/components/ui/icon.tsx +++ b/src/components/ui/icon.tsx @@ -29,6 +29,7 @@ export { Columns, Copy, Desktop, + DotsThree, DotOutline, DownloadSimple, Eye, @@ -38,6 +39,7 @@ export { FloppyDisk, FileZip, Folder, + FolderMinus, FolderOpen, Funnel, GameController, diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 3d4bb51d..9cefff99 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -53,8 +53,8 @@ function SelectTrigger({ function SelectContent({ className, children, - position = "item-aligned", - align = "center", + position = "popper", + align = "start", ...props }: React.ComponentProps) { return ( @@ -62,9 +62,8 @@ function SelectContent({ {children} diff --git a/src/hooks/useAssistantTrigger.ts b/src/hooks/useAssistantTrigger.ts index c2522d52..9d3df9fc 100644 --- a/src/hooks/useAssistantTrigger.ts +++ b/src/hooks/useAssistantTrigger.ts @@ -166,7 +166,7 @@ export function useAssistantTrigger({ const today = getLocalDateString(); const needsOnboarding = !state.onboardingComplete; - const needsCheckIn = state.onboardingComplete && state.lastCheckInDate !== today; + const needsCheckIn = state.onboardingComplete && state.dailyCheckInEnabled === true && state.lastCheckInDate !== today; if (!needsOnboarding && !needsCheckIn) return; diff --git a/src/hooks/useBridgeStatus.ts b/src/hooks/useBridgeStatus.ts index 22e6aa5e..b198d603 100644 --- a/src/hooks/useBridgeStatus.ts +++ b/src/hooks/useBridgeStatus.ts @@ -22,7 +22,7 @@ export function useBridgeStatus(): { bridgeStatus: BridgeStatus | null; starting: boolean; stopping: boolean; - startBridge: () => Promise; + startBridge: () => Promise; stopBridge: () => Promise; refreshStatus: () => Promise; } { @@ -62,17 +62,22 @@ export function useBridgeStatus(): { }; }, [bridgeStatus?.running, refreshStatus]); - const startBridge = useCallback(async () => { + const startBridge = useCallback(async (): Promise => { setStarting(true); try { - await fetch("/api/bridge", { + const res = await fetch("/api/bridge", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "start" }), }); + const data = await res.json(); await refreshStatus(); + if (!data.ok && data.reason) { + return data.reason; + } + return null; } catch { - // ignore + return 'network_error'; } finally { setStarting(false); } diff --git a/src/hooks/useProviderModels.ts b/src/hooks/useProviderModels.ts index 5c1baddb..5d6ef500 100644 --- a/src/hooks/useProviderModels.ts +++ b/src/hooks/useProviderModels.ts @@ -13,6 +13,10 @@ export interface UseProviderModelsReturn { currentProviderIdValue: string; modelOptions: typeof DEFAULT_MODEL_OPTIONS; currentModelOption: (typeof DEFAULT_MODEL_OPTIONS)[number]; + /** Global default model (model value) */ + globalDefaultModel: string | undefined; + /** Global default model's provider ID */ + globalDefaultProvider: string | undefined; } export function useProviderModels( @@ -21,8 +25,10 @@ export function useProviderModels( ): UseProviderModelsReturn { const [providerGroups, setProviderGroups] = useState([]); const [defaultProviderId, setDefaultProviderId] = useState(''); + const [globalDefaultModel, setGlobalDefaultModel] = useState(); + const [globalDefaultProvider, setGlobalDefaultProvider] = useState(); - const fetchProviderModels = useCallback(() => { + const fetchAll = useCallback(() => { fetch('/api/providers/models') .then((r) => r.json()) .then((data) => { @@ -47,18 +53,28 @@ export function useProviderModels( }]); setDefaultProviderId(''); }); + + // Fetch global default model + fetch('/api/providers/options?providerId=__global__') + .then(r => r.ok ? r.json() : null) + .then(data => { + setGlobalDefaultModel(data?.options?.default_model || undefined); + setGlobalDefaultProvider(data?.options?.default_model_provider || undefined); + }) + .catch(() => {}); }, []); - // Load models on mount and listen for provider changes + // Load on mount and listen for provider changes useEffect(() => { - fetchProviderModels(); - const handler = () => fetchProviderModels(); + fetchAll(); + const handler = () => fetchAll(); window.addEventListener('provider-changed', handler); return () => window.removeEventListener('provider-changed', handler); - }, [fetchProviderModels]); + }, [fetchAll]); // Derive flat model list for current provider - const currentProviderIdValue = providerId || defaultProviderId || (providerGroups[0]?.provider_id ?? ''); + // Use globalDefaultProvider as fallback instead of the legacy default_provider_id + const currentProviderIdValue = providerId || globalDefaultProvider || defaultProviderId || (providerGroups[0]?.provider_id ?? ''); const currentGroup = providerGroups.find(g => g.provider_id === currentProviderIdValue) || providerGroups[0]; const modelOptions = (currentGroup?.models && currentGroup.models.length > 0) ? currentGroup.models @@ -75,5 +91,7 @@ export function useProviderModels( currentProviderIdValue, modelOptions, currentModelOption, + globalDefaultModel, + globalDefaultProvider, }; } diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts index a04b1a1b..79df5882 100644 --- a/src/hooks/useSSEStream.ts +++ b/src/hooks/useSSEStream.ts @@ -207,7 +207,7 @@ function handleSSEEvent( 'CLI_NOT_FOUND', 'UNSUPPORTED_FEATURE', ]); if (diagCategories.has(parsed.category)) { - errorDisplay += '\n\n💡 Go to **Settings → Providers → Run Diagnostics** for detailed troubleshooting.'; + errorDisplay += '\n\n💡 [Run Provider Diagnostics](/settings#providers) to troubleshoot, or check the [Provider Setup Guide](https://www.codepilot.sh/docs/providers).'; } } else { errorDisplay = event.data; diff --git a/src/hooks/useUpdate.ts b/src/hooks/useUpdate.ts index da1ed3ef..00f28439 100644 --- a/src/hooks/useUpdate.ts +++ b/src/hooks/useUpdate.ts @@ -9,11 +9,17 @@ export interface UpdateInfo { releaseName: string; releaseNotes: string; releaseUrl: string; + downloadUrl?: string; + downloadAssetName?: string; publishedAt: string; downloadProgress: number | null; readyToInstall: boolean; isNativeUpdate: boolean; lastError: string | null; + detectedPlatform?: string; + detectedArch?: string; + hostArch?: string; + runningUnderRosetta?: boolean; } export interface UpdateContextValue { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 479d50f5..7120b44f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -22,6 +22,12 @@ const en = { 'chatList.daysAgo': '{n}d', 'chatList.newConversation': 'New Conversation', 'chatList.delete': 'Delete', + 'chatList.deleteConversation': 'Delete Conversation', + 'chatList.copySessionId': 'Copy Conversation ID', + 'chatList.renameConversation': 'Rename Conversation', + 'chatList.removeProject': 'Remove Project', + 'chatList.openFolder': 'Open Folder', + 'chatList.copyFolderPath': 'Copy Folder Path', 'chatList.searchSessions': 'Search sessions...', 'chatList.noSessions': 'No sessions yet', 'chatList.importFromCli': 'Import from Claude Code', @@ -375,6 +381,9 @@ const en = { 'update.readyToInstall': 'CodePilot v{version} is ready — restart to update', 'update.installUpdate': 'Download & Install', 'update.later': 'Later', + 'update.rosettaWarning': 'CodePilot is running through Rosetta on this Apple Silicon Mac. Install the arm64 build for better responsiveness.', + 'update.recommendedAsset': 'Recommended download: {asset}', + 'update.getRecommendedBuild': 'Get Recommended Build', // ── Image Generation ────────────────────────────────────── 'imageGen.toggle': 'Image Generation', @@ -685,6 +694,15 @@ const en = { 'bridge.qqChannelDesc': 'Receive and respond to messages via QQ Bot (private chat)', 'bridge.qqSettings': 'QQ Settings', 'bridge.qqSettingsDesc': 'Configure QQ Bot credentials for bridge', + 'bridge.weixinChannel': 'WeChat', + 'bridge.weixinChannelDesc': 'Receive and respond to messages via WeChat (QR login)', + 'bridge.weixinSettings': 'WeChat Settings', + 'bridge.weixinSettingsDesc': 'Configure WeChat accounts for bridge', + 'bridge.errorNotEnabled': 'Bridge is not enabled. Turn on the toggle first.', + 'bridge.errorNoChannels': 'No channels enabled. Enable at least one channel (Telegram, Feishu, Discord, QQ, or WeChat).', + 'bridge.errorNoAdapters': 'No adapters started. Check channel configuration.', + 'bridge.errorAdapterConfig': 'Channel configuration is invalid. Check settings for each enabled channel.', + 'bridge.errorNetwork': 'Network error while starting bridge.', // ── Settings: Discord Bridge ───────────────────────────────── 'discord.credentials': 'Bot Credentials', @@ -752,6 +770,45 @@ const en = { 'qq.step4': 'Go back to the Bridge page, enable the QQ channel toggle, and start the bridge', 'qq.step5': 'Add your QQ bot as a friend and send it a message to start chatting', + // ── Settings: WeChat Bridge ────────────────────────────────── + 'weixin.accounts': 'Accounts', + 'weixin.accountsDesc': 'Manage your linked WeChat accounts', + 'weixin.addAccount': 'Add Account', + 'weixin.noAccounts': 'No WeChat accounts linked yet', + 'weixin.accountLabel': 'Label', + 'weixin.accountStatus': 'Status', + 'weixin.accountActive': 'Active', + 'weixin.accountPaused': 'Paused', + 'weixin.accountExpired': 'Session Expired', + 'weixin.accountUpdateFailed': 'Failed to update WeChat account', + 'weixin.accountUpdateSavedRestartFailed': 'Account updated, but bridge restart failed', + 'weixin.accountDeleteFailed': 'Failed to remove WeChat account', + 'weixin.accountDeleteSavedRestartFailed': 'Account removed, but bridge restart failed', + 'weixin.deleteAccount': 'Remove Account', + 'weixin.deleteConfirm': 'Are you sure you want to remove this WeChat account?', + 'weixin.allowedUsers': 'Allowed Users', + 'weixin.allowedUsersDesc': 'Comma-separated user IDs allowed to use this account', + 'weixin.allowedUsersHint': 'Leave empty to allow all users', + 'weixin.qrLogin': 'QR Code Login', + 'weixin.qrLoginDesc': 'Scan the QR code with your WeChat to link an account', + 'weixin.qrWaiting': 'Waiting for scan...', + 'weixin.qrScanned': 'Scanned! Confirm on your phone...', + 'weixin.qrConfirmed': 'Login successful!', + 'weixin.qrConfirmedRestartFailed': 'Account linked, but bridge restart failed', + 'weixin.qrExpired': 'QR code expired, refreshing...', + 'weixin.qrFailed': 'Login failed', + 'weixin.riskWarning': 'This feature uses the WeChat OpenClaw plugin protocol to connect to a non-OpenClaw product. This may violate WeChat\'s terms of service and could carry account risk. Use at your own discretion.', + 'weixin.setupGuide': 'Setup Guide', + 'weixin.step1': 'Click "Add Account" to generate a QR code', + 'weixin.step2': 'Open WeChat on your phone and scan the QR code', + 'weixin.step3': 'Confirm the login on your phone', + 'weixin.step4': 'Go back to the Bridge page, enable the WeChat channel toggle, and start the bridge', + 'weixin.step5': 'Send a message to the linked WeChat account to start chatting', + 'weixin.baseUrl': 'API Base URL', + 'weixin.cdnBaseUrl': 'CDN Base URL', + 'weixin.mediaEnabled': 'Media Support', + 'weixin.mediaEnabledDesc': 'Download and process media attachments from WeChat messages', + // ── Assistant Workspace ────────────────────────────── 'settings.assistant': 'Assistant', 'assistant.workspaceTitle': 'Assistant Workspace', @@ -917,6 +974,9 @@ const en = { 'mcp.noRuntimeStatus': 'No runtime status available', 'mcp.reconnect': 'Reconnect', 'mcp.enable': 'Enable', + 'mcp.enabled': 'Enabled', + 'mcp.disabled': 'Disabled', + 'mcp.managerDesc': 'Toggle controls CodePilot MCP injection. Servers in your Claude Code config may still be loaded by the SDK via its own settings sources.', // ── SDK Capabilities: Thinking ──────────────────────────── 'settings.thinkingMode': 'Thinking Mode', @@ -924,6 +984,9 @@ const en = { 'settings.thinkingAdaptive': 'Adaptive', 'settings.thinkingEnabled': 'Enabled', 'settings.thinkingDisabled': 'Disabled', + 'settings.defaultModel': 'Default Model', + 'settings.defaultModelDesc': 'Only affects new conversations. Existing conversations keep their selected model.', + 'settings.defaultModelAuto': 'Auto (first in list)', // ── SDK Capabilities: Account ───────────────────────────── 'settings.accountInfo': 'Account Information', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c239873d..3a87b977 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -19,6 +19,12 @@ const zh: Record = { 'chatList.daysAgo': '{n}天', 'chatList.newConversation': '新对话', 'chatList.delete': '删除', + 'chatList.deleteConversation': '删除对话', + 'chatList.copySessionId': '复制对话 ID', + 'chatList.renameConversation': '重命名对话', + 'chatList.removeProject': '移出项目', + 'chatList.openFolder': '打开文件夹', + 'chatList.copyFolderPath': '复制文件夹路径', 'chatList.searchSessions': '搜索会话...', 'chatList.noSessions': '暂无会话', 'chatList.importFromCli': '从 Claude Code 导入', @@ -372,6 +378,9 @@ const zh: Record = { 'update.readyToInstall': 'CodePilot v{version} 已就绪 — 重启以完成更新', 'update.installUpdate': '下载并安装', 'update.later': '稍后', + 'update.rosettaWarning': 'CodePilot 当前正通过 Rosetta 在这台 Apple Silicon Mac 上运行。安装 arm64 版本可获得更好的响应速度。', + 'update.recommendedAsset': '推荐下载:{asset}', + 'update.getRecommendedBuild': '下载推荐版本', // ── Image Generation ────────────────────────────────────── 'imageGen.toggle': '图片生成', @@ -682,6 +691,15 @@ const zh: Record = { 'bridge.qqChannelDesc': '通过 QQ 机器人接收和回复私聊消息', 'bridge.qqSettings': 'QQ 设置', 'bridge.qqSettingsDesc': '配置桥接使用的 QQ 机器人凭据', + 'bridge.weixinChannel': '微信', + 'bridge.weixinChannelDesc': '通过微信收发消息(扫码登录)', + 'bridge.weixinSettings': '微信设置', + 'bridge.weixinSettingsDesc': '配置微信账号用于 Bridge 桥接', + 'bridge.errorNotEnabled': '桥接未启用,请先打开桥接开关。', + 'bridge.errorNoChannels': '没有启用任何渠道,请至少启用一个渠道(Telegram、飞书、Discord、QQ 或微信)。', + 'bridge.errorNoAdapters': '没有适配器成功启动,请检查渠道配置。', + 'bridge.errorAdapterConfig': '渠道配置无效,请检查已启用渠道的设置。', + 'bridge.errorNetwork': '启动桥接时网络错误。', // ── Settings: Discord Bridge ───────────────────────────────── 'discord.credentials': 'Bot 凭据', @@ -749,6 +767,45 @@ const zh: Record = { 'qq.step4': '回到桥接主页,打开 QQ 渠道开关,启动桥接', 'qq.step5': '添加 QQ 机器人为好友并发送消息开始聊天', + // ── Settings: WeChat Bridge ────────────────────────────────── + 'weixin.accounts': '账号管理', + 'weixin.accountsDesc': '管理已关联的微信账号', + 'weixin.addAccount': '添加账号', + 'weixin.noAccounts': '暂未关联任何微信账号', + 'weixin.accountLabel': '标签', + 'weixin.accountStatus': '状态', + 'weixin.accountActive': '活跃', + 'weixin.accountPaused': '已暂停', + 'weixin.accountExpired': '会话过期', + 'weixin.accountUpdateFailed': '更新微信账号失败', + 'weixin.accountUpdateSavedRestartFailed': '账号已更新,但 Bridge 重启失败', + 'weixin.accountDeleteFailed': '移除微信账号失败', + 'weixin.accountDeleteSavedRestartFailed': '账号已移除,但 Bridge 重启失败', + 'weixin.deleteAccount': '移除账号', + 'weixin.deleteConfirm': '确定要移除此微信账号吗?', + 'weixin.allowedUsers': '允许的用户', + 'weixin.allowedUsersDesc': '允许使用此账号的用户 ID,逗号分隔', + 'weixin.allowedUsersHint': '留空表示允许所有用户', + 'weixin.qrLogin': '扫码登录', + 'weixin.qrLoginDesc': '使用微信扫描二维码以关联账号', + 'weixin.qrWaiting': '等待扫码...', + 'weixin.qrScanned': '已扫码!请在手机上确认...', + 'weixin.qrConfirmed': '登录成功!', + 'weixin.qrConfirmedRestartFailed': '账号已关联,但 Bridge 重启失败', + 'weixin.qrExpired': '二维码已过期,正在刷新...', + 'weixin.qrFailed': '登录失败', + 'weixin.riskWarning': '本功能通过微信 OpenClaw 插件协议连接非 OpenClaw 产品,严格来看可能违反微信使用协议,存在账号风险,请谨慎使用。', + 'weixin.setupGuide': '设置指南', + 'weixin.step1': '点击"添加账号"生成二维码', + 'weixin.step2': '打开手机微信扫描二维码', + 'weixin.step3': '在手机上确认登录', + 'weixin.step4': '返回 Bridge 页面,开启微信通道开关并启动 Bridge', + 'weixin.step5': '向已关联的微信账号发送消息即可开始聊天', + 'weixin.baseUrl': 'API 基础 URL', + 'weixin.cdnBaseUrl': 'CDN 基础 URL', + 'weixin.mediaEnabled': '媒体支持', + 'weixin.mediaEnabledDesc': '下载和处理微信消息中的媒体附件', + // ── Assistant Workspace ────────────────────────────── 'settings.assistant': '助理', 'assistant.workspaceTitle': '助理工作区', @@ -914,6 +971,9 @@ const zh: Record = { 'mcp.noRuntimeStatus': '暂无运行状态信息', 'mcp.reconnect': '重连', 'mcp.enable': '启用', + 'mcp.enabled': '已启用', + 'mcp.disabled': '已禁用', + 'mcp.managerDesc': '开关控制 CodePilot 注入的 MCP 服务。Claude Code 自身配置中的服务仍可能被 SDK 通过 settingSources 自动加载。', // ── SDK Capabilities: Thinking ──────────────────────────── 'settings.thinkingMode': '思考模式', @@ -921,6 +981,9 @@ const zh: Record = { 'settings.thinkingAdaptive': '自适应', 'settings.thinkingEnabled': '启用', 'settings.thinkingDisabled': '禁用', + 'settings.defaultModel': '默认模型', + 'settings.defaultModelDesc': '仅影响新对话,已有对话保持其已选模型不变', + 'settings.defaultModelAuto': '自动(列表中第一个)', // ── SDK Capabilities: Account ───────────────────────────── 'settings.accountInfo': '账户信息', diff --git a/src/lib/agent-sdk-capabilities.ts b/src/lib/agent-sdk-capabilities.ts index 81f9e504..c5aa8078 100644 --- a/src/lib/agent-sdk-capabilities.ts +++ b/src/lib/agent-sdk-capabilities.ts @@ -61,6 +61,18 @@ function getOrCreateCache(providerId: string): ProviderCapabilityCache { return cache; } +// ========================================== +// Cache freshness +// ========================================== + +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** Check if the capability cache for a provider is still fresh (within TTL). */ +export function isCacheFresh(providerId: string = 'env'): boolean { + const cache = getCacheMap().get(providerId); + return !!cache && cache.capturedAt > 0 && (Date.now() - cache.capturedAt) < CACHE_TTL_MS; +} + // ========================================== // Capture // ========================================== diff --git a/src/lib/assistant-workspace.ts b/src/lib/assistant-workspace.ts index aaad3199..f17dcf22 100644 --- a/src/lib/assistant-workspace.ts +++ b/src/lib/assistant-workspace.ts @@ -6,7 +6,8 @@ import { getLocalDateString } from '@/lib/utils'; const DEFAULT_STATE: AssistantWorkspaceState = { onboardingComplete: false, lastCheckInDate: null, - schemaVersion: 3, + schemaVersion: 4, + dailyCheckInEnabled: false, }; const STATE_DIR = '.assistant'; @@ -160,6 +161,28 @@ export function migrateStateV2ToV3(dir: string): void { saveState(dir, state); } +/** + * v3→v4 migration: reset dailyCheckInEnabled to false for all users. + * Previously the default was implicitly "enabled" (undefined treated as true). + * Now the default is explicitly false — users must opt-in. + */ +export function migrateStateV3ToV4(dir: string): void { + let state: AssistantWorkspaceState; + try { + const statePath = path.join(dir, STATE_DIR, STATE_FILE); + const raw = fs.readFileSync(statePath, 'utf-8'); + state = JSON.parse(raw) as AssistantWorkspaceState; + } catch { + return; + } + + if (state.schemaVersion >= 4) return; + + state.dailyCheckInEnabled = false; + state.schemaVersion = 4; + saveState(dir, state); +} + // ========================================== // Root Docs // ========================================== @@ -316,6 +339,7 @@ export function initializeWorkspace(dir: string): string[] { // Migrate existing state through all schema versions migrateStateV1ToV2(dir); migrateStateV2ToV3(dir); + migrateStateV3ToV4(dir); } // For existing directories, generate root docs and infer taxonomy @@ -511,6 +535,10 @@ export function loadState(dir: string): AssistantWorkspaceState { migrateStateV2ToV3(dir); migrated = true; } + if (state.schemaVersion < 4) { + migrateStateV3ToV4(dir); + migrated = true; + } if (migrated) { return loadState(dir); // Reload after migration } @@ -534,7 +562,7 @@ export function saveState(dir: string, state: AssistantWorkspaceState): void { export function needsDailyCheckIn(state: AssistantWorkspaceState, now?: Date): boolean { if (!state.onboardingComplete) return false; - if (state.dailyCheckInEnabled === false) return false; + if (state.dailyCheckInEnabled !== true) return false; const d = now ?? new Date(); const localToday = getLocalDateString(d); if (state.lastCheckInDate === localToday) return false; diff --git a/src/lib/bridge/adapters/index.ts b/src/lib/bridge/adapters/index.ts index d0cfdeb0..32e3bd45 100644 --- a/src/lib/bridge/adapters/index.ts +++ b/src/lib/bridge/adapters/index.ts @@ -13,3 +13,4 @@ import './telegram-adapter'; import './feishu-adapter'; import './discord-adapter'; import './qq-adapter'; +import './weixin-adapter'; diff --git a/src/lib/bridge/adapters/weixin-adapter.ts b/src/lib/bridge/adapters/weixin-adapter.ts new file mode 100644 index 00000000..0e77d0ae --- /dev/null +++ b/src/lib/bridge/adapters/weixin-adapter.ts @@ -0,0 +1,525 @@ +/** + * WeChat Adapter — implements BaseChannelAdapter for WeChat ilink bot API. + * + * Uses HTTP long-polling (one worker per enabled account) for real-time + * message consumption. Text-only outbound. No streaming preview. + * No inline buttons — permission handled via /perm text command. + * + * Multi-account: each QR-linked account runs its own poll loop. + * Synthetic chatId format: weixin:::: + */ + +import type { + ChannelType, + InboundMessage, + OutboundMessage, + SendResult, +} from '../types'; +import type { FileAttachment } from '@/types'; +import { BaseChannelAdapter, registerAdapterFactory } from '../channel-adapter'; +import { + listWeixinAccounts, + getWeixinAccount, + getWeixinContextToken, + upsertWeixinContextToken, + getChannelOffset, + setChannelOffset, + insertAuditLog, + getSetting, +} from '../../db'; +import type { WeixinAccountRow } from '../../db'; +import { getUpdates, sendTextMessage, sendTyping as apiSendTyping, getConfig } from './weixin/weixin-api'; +import type { WeixinCredentials, WeixinMessage, MessageItem, GetUpdatesResponse } from './weixin/weixin-types'; +import { MessageItemType, TypingStatus, ERRCODE_SESSION_EXPIRED } from './weixin/weixin-types'; +import { encodeWeixinChatId, decodeWeixinChatId } from './weixin/weixin-ids'; +import { downloadMediaFromItem } from './weixin/weixin-media'; +import { isPaused, setPaused, clearAllPauses } from './weixin/weixin-session-guard'; + +/** Max number of message_ids to keep for dedup per account. */ +const DEDUP_MAX = 500; + +/** Backoff after consecutive failures. */ +const BACKOFF_BASE_MS = 2_000; +const BACKOFF_MAX_MS = 30_000; + +export class WeixinAdapter extends BaseChannelAdapter { + readonly channelType: ChannelType = 'weixin'; + + private _running = false; + private queue: InboundMessage[] = []; + private waiters: Array<(msg: InboundMessage | null) => void> = []; + private pollAborts = new Map(); + private seenMessageIds = new Map>(); + private consecutiveFailures = new Map(); + private typingTickets = new Map(); + + // Per-batch cursor ack tracking: hold cursor in memory until all + // messages in the batch are acknowledged by bridge-manager. + private pendingCursors = new Map(); + private nextBatchId = 1; + + // ── Lifecycle ─────────────────────────────────────────────── + + async start(): Promise { + if (this._running) return; + this._running = true; + clearAllPauses(); + + const accounts = listWeixinAccounts().filter(a => a.enabled === 1); + if (accounts.length === 0) { + console.log('[weixin-adapter] No enabled accounts, adapter started but idle'); + } + + for (const account of accounts) { + this.startAccountWorker(account); + } + + console.log(`[weixin-adapter] Started with ${accounts.length} account(s)`); + } + + async stop(): Promise { + if (!this._running) return; + this._running = false; + + // Abort all poll loops + for (const [, controller] of this.pollAborts) { + controller.abort(); + } + this.pollAborts.clear(); + + // Wake up pending consumeOne() calls + for (const waiter of this.waiters) { + waiter(null); + } + this.waiters = []; + this.queue = []; + this.seenMessageIds.clear(); + this.consecutiveFailures.clear(); + this.typingTickets.clear(); + this.pendingCursors.clear(); + + console.log('[weixin-adapter] Stopped'); + } + + isRunning(): boolean { + return this._running; + } + + /** + * Called by bridge-manager after handleMessage completes for a message. + * Decrements the batch counter; when all messages in the batch are done, + * commits the poll cursor to DB. + */ + acknowledgeUpdate(updateId: number): void { + const batch = this.pendingCursors.get(updateId); + if (!batch) return; + batch.remaining = Math.max(0, batch.remaining - 1); + this.maybeCommitPendingCursor(updateId); + } + + // ── Message Queue ──────────────────────────────────────────── + + async consumeOne(): Promise { + if (this.queue.length > 0) { + return this.queue.shift()!; + } + if (!this._running) return null; + + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + private enqueue(msg: InboundMessage): void { + if (this.waiters.length > 0) { + const waiter = this.waiters.shift()!; + waiter(msg); + } else { + this.queue.push(msg); + } + } + + // ── Send ──────────────────────────────────────────────────── + + async send(message: OutboundMessage): Promise { + try { + const decoded = decodeWeixinChatId(message.address.chatId); + if (!decoded) { + return { ok: false, error: 'Invalid WeChat chatId format' }; + } + + const { accountId, peerUserId } = decoded; + const account = getWeixinAccount(accountId); + if (!account) { + return { ok: false, error: `WeChat account ${accountId} not found` }; + } + + const contextToken = getWeixinContextToken(accountId, peerUserId); + if (!contextToken) { + return { ok: false, error: `No context_token for peer ${peerUserId} on account ${accountId}` }; + } + + const creds = this.accountToCreds(account); + + // Strip HTML/Markdown — WeChat only supports plain text + let content = message.text; + if (message.parseMode === 'HTML') { + content = content.replace(/<[^>]+>/g, ''); + } + // Simple markdown strip + content = content + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/__(.*?)__/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/_(.*?)_/g, '$1') + .replace(/`{3}[\s\S]*?`{3}/g, (m) => m.replace(/`{3}\w*\n?/g, '').replace(/`{3}/g, '')) + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); + + // sendTextMessage returns local clientId; HTTP errors throw + const { clientId } = await sendTextMessage(creds, peerUserId, content, contextToken); + + return { ok: true, messageId: clientId }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + // ── Typing ──────────────────────────────────────────────── + + onMessageStart(chatId: string): void { + this.sendTypingIndicator(chatId, TypingStatus.TYPING).catch(() => {}); + } + + onMessageEnd(chatId: string): void { + this.sendTypingIndicator(chatId, TypingStatus.CANCEL).catch(() => {}); + } + + private async sendTypingIndicator(chatId: string, status: number): Promise { + const decoded = decodeWeixinChatId(chatId); + if (!decoded) return; + + const { accountId, peerUserId } = decoded; + const account = getWeixinAccount(accountId); + if (!account) return; + + const contextToken = getWeixinContextToken(accountId, peerUserId); + if (!contextToken) return; + + const creds = this.accountToCreds(account); + + // Get or fetch typing ticket (per-user, requires context_token) + const ticketKey = `${accountId}:${peerUserId}`; + let ticket = this.typingTickets.get(ticketKey); + if (!ticket) { + try { + const config = await getConfig(creds, peerUserId, contextToken); + if (config.typing_ticket) { + ticket = config.typing_ticket; + this.typingTickets.set(ticketKey, ticket); + } + } catch { + return; // Best effort + } + } + if (!ticket) return; + + await apiSendTyping(creds, peerUserId, ticket, status); + } + + // ── Config & Auth ────────────────────────────────────────── + + validateConfig(): string | null { + const accounts = listWeixinAccounts().filter(a => a.enabled === 1); + if (accounts.length === 0) { + return 'No enabled WeChat accounts. Add an account via QR code login first.'; + } + // Check that at least one account has a token + const hasToken = accounts.some(a => a.token && a.token.length > 0); + if (!hasToken) { + return 'No WeChat accounts have valid tokens. Re-login via QR code.'; + } + return null; + } + + isAuthorized(_userId: string, chatId: string): boolean { + // Decode the synthetic chatId to get accountId + const decoded = decodeWeixinChatId(chatId); + if (!decoded) return false; + + // Check per-account allowed_users (stored in settings) + // For now, all users are allowed (WeChat bot already restricts to paired users) + return true; + } + + // ── Per-Account Poll Loop ────────────────────────────────── + + private startAccountWorker(account: WeixinAccountRow): void { + const controller = new AbortController(); + this.pollAborts.set(account.account_id, controller); + this.seenMessageIds.set(account.account_id, new Set()); + this.consecutiveFailures.set(account.account_id, 0); + + // Fire-and-forget async poll loop + this.runPollLoop(account.account_id, this.accountToCreds(account), controller.signal); + } + + private async runPollLoop( + accountId: string, + creds: WeixinCredentials, + signal: AbortSignal, + ): Promise { + console.log(`[weixin-adapter] Poll loop started for account ${accountId}`); + + while (this._running && !signal.aborted) { + // Check pause state + if (isPaused(accountId)) { + await this.sleep(10_000, signal); + continue; + } + + try { + // Read persisted poll cursor (default '0' from db means no cursor yet) + const offsetKey = `weixin:${accountId}`; + const rawOffset = getChannelOffset(offsetKey); + const buf = rawOffset === '0' ? '' : rawOffset; + + const resp: GetUpdatesResponse = await getUpdates(creds, buf); + + // Check for session expiry + if (resp.errcode === ERRCODE_SESSION_EXPIRED) { + setPaused(accountId, 'Session expired (errcode -14)'); + console.warn(`[weixin-adapter] Account ${accountId} session expired, pausing`); + continue; + } + + // Check for other API errors + if (resp.errcode && resp.errcode !== 0) { + throw new Error(`API error: ${resp.errcode} ${resp.errmsg || ''}`); + } + + // Process messages — assign a batch ID so the cursor is only + // committed after bridge-manager finishes handleMessage for ALL + // messages in this batch (via acknowledgeUpdate). + let batchId: number | undefined; + let batchCompleted = false; + + if (resp.msgs && resp.msgs.length > 0 && resp.get_updates_buf) { + batchId = this.nextBatchId++; + this.pendingCursors.set(batchId, { + offsetKey: `weixin:${accountId}`, + cursor: resp.get_updates_buf, + remaining: 0, + sealed: false, + }); + for (const msg of resp.msgs) { + await this.processMessage(accountId, creds, msg, batchId); + } + batchCompleted = true; + } else if (resp.msgs && resp.msgs.length > 0) { + // No new cursor — process without batch tracking + for (const msg of resp.msgs) { + await this.processMessage(accountId, creds, msg); + } + } + + // Only seal successful batches. If processing failed mid-batch, drop the + // pending cursor so the upstream cursor is not advanced and messages can + // be retried on the next poll. + if (batchId !== undefined && resp.get_updates_buf) { + const batch = this.pendingCursors.get(batchId); + if (batchCompleted && batch) { + batch.sealed = true; + this.maybeCommitPendingCursor(batchId); + } else if (!batchCompleted) { + this.pendingCursors.delete(batchId); + } + } + + // Reset failure counter on success + this.consecutiveFailures.set(accountId, 0); + + } catch (err) { + if (signal.aborted) break; + + const failures = (this.consecutiveFailures.get(accountId) || 0) + 1; + this.consecutiveFailures.set(accountId, failures); + + const backoff = Math.min( + BACKOFF_BASE_MS * Math.pow(2, failures - 1), + BACKOFF_MAX_MS, + ); + + console.error( + `[weixin-adapter] Poll error for ${accountId} (failure ${failures}):`, + err instanceof Error ? err.message : err, + ); + + await this.sleep(backoff, signal); + } + } + + console.log(`[weixin-adapter] Poll loop ended for account ${accountId}`); + } + + private async processMessage( + accountId: string, + creds: WeixinCredentials, + msg: WeixinMessage, + batchId?: number, + ): Promise { + if (!msg.from_user_id) return; + + // Dedup by message_id or seq + const msgKey = msg.message_id || `seq_${msg.seq}`; + const seen = this.seenMessageIds.get(accountId); + if (seen?.has(msgKey)) return; + seen?.add(msgKey); + + // Trim dedup set + if (seen && seen.size > DEDUP_MAX) { + const arr = Array.from(seen); + for (let i = 0; i < arr.length - DEDUP_MAX; i++) { + seen.delete(arr[i]); + } + } + + // Persist context_token + if (msg.context_token) { + upsertWeixinContextToken(accountId, msg.from_user_id, msg.context_token); + } + + // Extract text from item_list + let text = ''; + const items = msg.item_list || []; + for (const item of items) { + if (item.type === MessageItemType.TEXT && item.text_item?.text) { + text += item.text_item.text; + } + } + + // Handle quoted/referenced messages + if (msg.ref_message) { + const refParts: string[] = []; + if (msg.ref_message.title) refParts.push(msg.ref_message.title); + if (msg.ref_message.content) refParts.push(msg.ref_message.content); + if (refParts.length > 0) { + text = `[引用: ${refParts.join(' | ')}]\n${text}`; + } + } + + // Build synthetic chatId + const chatId = encodeWeixinChatId(accountId, msg.from_user_id); + + // Download media attachments + let attachments: FileAttachment[] | undefined; + const mediaEnabled = getSetting('bridge_weixin_media_enabled') !== 'false'; + if (mediaEnabled) { + attachments = await this.downloadMediaItems(items, creds.cdnBaseUrl, accountId); + } + + const inbound: InboundMessage = { + messageId: msg.message_id || `weixin_${accountId}_${msg.seq || Date.now()}`, + address: { + channelType: 'weixin', + chatId, + userId: msg.from_user_id, + displayName: msg.from_user_id.slice(0, 8), + }, + text: text.trim(), + timestamp: msg.create_time ? msg.create_time * 1000 : Date.now(), + attachments, + raw: { accountId, originalMessage: msg }, + updateId: batchId, + }; + + // Only enqueue if we have text or attachments + if (inbound.text || (inbound.attachments && inbound.attachments.length > 0)) { + if (batchId !== undefined) { + const batch = this.pendingCursors.get(batchId); + if (batch) { + batch.remaining++; + } + } + this.enqueue(inbound); + } + + insertAuditLog({ + channelType: 'weixin', + chatId, + direction: 'inbound', + messageId: inbound.messageId, + summary: text.slice(0, 200), + }); + } + + private async downloadMediaItems( + items: MessageItem[], + cdnBaseUrl: string, + accountId: string, + ): Promise { + const results: FileAttachment[] = []; + + for (const item of items) { + if (item.type === MessageItemType.TEXT) continue; + + try { + const media = await downloadMediaFromItem(item, cdnBaseUrl); + if (media) { + const id = `weixin_${accountId}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + results.push({ + id, + name: media.filename, + type: media.mimeType, + size: media.data.length, + data: media.data.toString('base64'), + }); + } + } catch (err) { + console.error(`[weixin-adapter] Media download failed:`, err instanceof Error ? err.message : err); + // Continue processing other items + } + } + + return results; + } + + // ── Helpers ──────────────────────────────────────────────── + + private accountToCreds(account: WeixinAccountRow): WeixinCredentials { + return { + botToken: account.token, + ilinkBotId: account.account_id, + baseUrl: account.base_url || 'https://ilinkai.weixin.qq.com', + cdnBaseUrl: account.cdn_base_url || 'https://novac2c.cdn.weixin.qq.com/c2c', + }; + } + + private sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + }); + } + + private maybeCommitPendingCursor(updateId: number): void { + const batch = this.pendingCursors.get(updateId); + if (!batch || !batch.sealed || batch.remaining > 0) { + return; + } + setChannelOffset(batch.offsetKey, batch.cursor); + this.pendingCursors.delete(updateId); + } +} + +// ── Self-Registration ────────────────────────────────────── + +registerAdapterFactory('weixin', () => new WeixinAdapter()); diff --git a/src/lib/bridge/adapters/weixin/weixin-api.ts b/src/lib/bridge/adapters/weixin/weixin-api.ts new file mode 100644 index 00000000..49bc38e3 --- /dev/null +++ b/src/lib/bridge/adapters/weixin/weixin-api.ts @@ -0,0 +1,268 @@ +/** + * WeChat HTTP protocol client. + * + * Pure protocol layer — no business logic or state management. + * Derived from OpenClaw weixin plugin reference (protocol only, not runtime dependency). + */ + +import crypto from 'crypto'; +import type { + WeixinCredentials, + GetUpdatesResponse, + GetUploadUrlResponse, + GetConfigResponse, + MessageItem, + QrCodeStartResponse, + QrCodeStatusResponse, +} from './weixin-types'; +import { + DEFAULT_BASE_URL, + MessageType, + MessageState, + MessageItemType, +} from './weixin-types'; + +const CHANNEL_VERSION = 'codepilot-weixin-bridge/1.0'; +const LONG_POLL_TIMEOUT_MS = 35_000; +const API_TIMEOUT_MS = 15_000; +const CONFIG_TIMEOUT_MS = 10_000; + +/** + * Generate X-WECHAT-UIN header value: random uint32 encoded as base64. + */ +function generateWechatUin(): string { + const buf = crypto.randomBytes(4); + return buf.toString('base64'); +} + +/** + * Build auth headers for WeChat ilink bot API. + */ +function buildHeaders(creds: WeixinCredentials, routeTag?: string): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'AuthorizationType': 'ilink_bot_token', + 'Authorization': `Bearer ${creds.botToken}`, + 'X-WECHAT-UIN': generateWechatUin(), + }; + if (routeTag) { + headers['SKRouteTag'] = routeTag; + } + return headers; +} + +/** + * Core HTTP request helper. + */ +async function weixinRequest( + creds: WeixinCredentials, + endpoint: string, + body: unknown, + timeoutMs: number = API_TIMEOUT_MS, + routeTag?: string, +): Promise { + const baseUrl = creds.baseUrl || DEFAULT_BASE_URL; + const url = `${baseUrl}/ilink/bot/${endpoint}`; + + const res = await fetch(url, { + method: 'POST', + headers: buildHeaders(creds, routeTag), + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + + if (!res.ok) { + throw new Error(`WeChat API error: ${res.status} ${res.statusText}`); + } + + const rawText = await res.text(); + if (!rawText.trim()) { + return {} as T; + } + + try { + return JSON.parse(rawText) as T; + } catch (err) { + throw new Error( + `WeChat API returned non-JSON body for ${endpoint}: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +/** + * Long-poll for updates. + */ +export async function getUpdates( + creds: WeixinCredentials, + getUpdatesBuf: string, + timeoutMs: number = LONG_POLL_TIMEOUT_MS, +): Promise { + try { + return await weixinRequest( + creds, + 'getupdates', + { + get_updates_buf: getUpdatesBuf ?? '', + base_info: { channel_version: CHANNEL_VERSION }, + }, + timeoutMs + 5_000, // client timeout slightly longer than server timeout + ); + } catch (err) { + // Timeout is normal for long-polling — return empty response + if (err instanceof Error && err.name === 'TimeoutError') { + return { msgs: [], get_updates_buf: getUpdatesBuf }; + } + throw err; + } +} + +/** + * Generate a unique client_id for outbound messages. + */ +function generateClientId(): string { + return `codepilot-wx-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; +} + +/** + * Send a message using the correct protocol structure. + * Body: { msg: WeixinMessage, base_info } + * Returns the local client_id as messageId (server response is empty). + */ +export async function sendMessage( + creds: WeixinCredentials, + toUserId: string, + items: MessageItem[], + contextToken: string, +): Promise<{ clientId: string }> { + const clientId = generateClientId(); + await weixinRequest>(creds, 'sendmessage', { + msg: { + from_user_id: '', + to_user_id: toUserId, + client_id: clientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + item_list: items.length > 0 ? items : undefined, + context_token: contextToken || undefined, + }, + base_info: { channel_version: CHANNEL_VERSION }, + }); + return { clientId }; +} + +/** + * Send a text message (convenience wrapper). + */ +export async function sendTextMessage( + creds: WeixinCredentials, + toUserId: string, + text: string, + contextToken: string, +): Promise<{ clientId: string }> { + return sendMessage(creds, toUserId, [ + { type: MessageItemType.TEXT, text_item: { text } }, + ], contextToken); +} + +/** + * Get CDN upload URL. + */ +export async function getUploadUrl( + creds: WeixinCredentials, + fileKey: string, + fileType: number, + fileSize: number, + fileMd5: string, + cipherFileSize: number, +): Promise { + return weixinRequest(creds, 'getuploadurl', { + file_key: fileKey, + file_type: fileType, + file_size: fileSize, + file_md5: fileMd5, + cipher_file_size: cipherFileSize, + }); +} + +/** + * Get account configuration (typing ticket, route tag, etc.). + * Requires ilink_user_id + context_token per reference implementation. + */ +export async function getConfig( + creds: WeixinCredentials, + ilinkUserId?: string, + contextToken?: string, +): Promise { + return weixinRequest( + creds, + 'getconfig', + { + ilink_user_id: ilinkUserId, + context_token: contextToken, + base_info: { channel_version: CHANNEL_VERSION }, + }, + CONFIG_TIMEOUT_MS, + ); +} + +/** + * Send typing indicator. + * Uses ilink_user_id + typing_ticket + status per reference implementation. + */ +export async function sendTyping( + creds: WeixinCredentials, + ilinkUserId: string, + typingTicket: string, + typingStatus: number, +): Promise { + try { + await weixinRequest>( + creds, + 'sendtyping', + { + ilink_user_id: ilinkUserId, + typing_ticket: typingTicket, + status: typingStatus, + base_info: { channel_version: CHANNEL_VERSION }, + }, + CONFIG_TIMEOUT_MS, + ); + } catch { + // Typing indicator is best-effort — never block the main flow + } +} + +// ── QR Login API ───────────────────────────────────────────── + +const QR_LOGIN_BASE_URL = 'https://ilinkai.weixin.qq.com'; +const QR_LOGIN_TIMEOUT_MS = 40_000; + +/** + * Start QR code login — returns base64 QR image and qrcode identifier. + */ +export async function startLoginQr(): Promise { + const url = `${QR_LOGIN_BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`; + const res = await fetch(url, { + method: 'GET', + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); + if (!res.ok) { + throw new Error(`QR login start failed: ${res.status}`); + } + return (await res.json()) as QrCodeStartResponse; +} + +/** + * Poll QR code login status. + */ +export async function pollLoginQrStatus(qrcode: string): Promise { + const url = `${QR_LOGIN_BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`; + const res = await fetch(url, { + method: 'GET', + signal: AbortSignal.timeout(QR_LOGIN_TIMEOUT_MS), + }); + if (!res.ok) { + throw new Error(`QR status poll failed: ${res.status}`); + } + return (await res.json()) as QrCodeStatusResponse; +} diff --git a/src/lib/bridge/adapters/weixin/weixin-auth.ts b/src/lib/bridge/adapters/weixin/weixin-auth.ts new file mode 100644 index 00000000..8162039a --- /dev/null +++ b/src/lib/bridge/adapters/weixin/weixin-auth.ts @@ -0,0 +1,173 @@ +/** + * WeChat QR code login flow. + * + * Manages QR code generation, status polling, and credential persistence. + * Active login sessions are stored in globalThis to survive Next.js HMR. + */ + +import QRCode from 'qrcode'; +import { startLoginQr, pollLoginQrStatus } from './weixin-api'; +import { upsertWeixinAccount } from '../../../db'; +import type { QrCodeStatusResponse } from './weixin-types'; +import { DEFAULT_BASE_URL, DEFAULT_CDN_BASE_URL } from './weixin-types'; + +export interface QrLoginSession { + qrcode: string; + qrImage: string; // base64 + startedAt: number; + refreshCount: number; + status: 'waiting' | 'scanned' | 'confirmed' | 'expired' | 'failed'; + accountId?: string; + error?: string; +} + +const MAX_REFRESHES = 3; +const QR_TTL_MS = 5 * 60_000; + +// Use globalThis to store active login sessions, surviving HMR +const GLOBAL_KEY = '__weixin_login_sessions__'; + +function getLoginSessions(): Map { + const g = globalThis as Record; + if (!g[GLOBAL_KEY]) { + g[GLOBAL_KEY] = new Map(); + } + return g[GLOBAL_KEY] as Map; +} + +/** + * Start a new QR login session. + * Returns a session ID that can be used to poll status. + */ +export async function startQrLoginSession(): Promise<{ sessionId: string; qrImage: string }> { + const resp = await startLoginQr(); + + if (!resp.qrcode || !resp.qrcode_img_content) { + throw new Error('Failed to get QR code from WeChat server'); + } + + // qrcode_img_content is a URL, generate a data URL for the frontend + const qrDataUrl = await QRCode.toDataURL(resp.qrcode_img_content, { width: 256, margin: 2 }); + + const sessionId = `qr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const session: QrLoginSession = { + qrcode: resp.qrcode, + qrImage: qrDataUrl, + startedAt: Date.now(), + refreshCount: 0, + status: 'waiting', + }; + + getLoginSessions().set(sessionId, session); + + // Auto-cleanup after 10 minutes + setTimeout(() => { + getLoginSessions().delete(sessionId); + }, 10 * 60_000); + + return { sessionId, qrImage: qrDataUrl }; +} + +/** + * Poll the status of a QR login session. + */ +export async function pollQrLoginStatus(sessionId: string): Promise { + const sessions = getLoginSessions(); + const session = sessions.get(sessionId); + if (!session) { + return { qrcode: '', qrImage: '', startedAt: 0, refreshCount: 0, status: 'failed', error: 'Session not found' }; + } + + if (session.status === 'confirmed' || session.status === 'failed') { + return session; + } + + // Check if QR has expired (5 minutes) + if (Date.now() - session.startedAt > QR_TTL_MS) { + if (session.refreshCount >= MAX_REFRESHES) { + session.status = 'failed'; + session.error = 'QR code expired after maximum refreshes'; + return session; + } + + // Refresh QR code + try { + const resp = await startLoginQr(); + if (resp.qrcode && resp.qrcode_img_content) { + session.qrcode = resp.qrcode; + session.qrImage = await QRCode.toDataURL(resp.qrcode_img_content, { width: 256, margin: 2 }); + session.startedAt = Date.now(); + session.refreshCount++; + session.status = 'waiting'; + } + } catch (err) { + session.status = 'failed'; + session.error = `QR refresh failed: ${err instanceof Error ? err.message : String(err)}`; + } + return session; + } + + // Poll WeChat server for QR status + try { + const resp: QrCodeStatusResponse = await pollLoginQrStatus(session.qrcode); + + switch (resp.status) { + case 'wait': + session.status = 'waiting'; + break; + + case 'scaned': + session.status = 'scanned'; + break; + + case 'confirmed': { + session.status = 'confirmed'; + + if (resp.bot_token && resp.ilink_bot_id) { + // Normalize account ID (replace unsafe chars) + const accountId = (resp.ilink_bot_id || '').replace(/[@.]/g, '-'); + session.accountId = accountId; + + // Persist to database + upsertWeixinAccount({ + accountId, + userId: resp.ilink_user_id || '', + baseUrl: resp.baseurl || DEFAULT_BASE_URL, + cdnBaseUrl: DEFAULT_CDN_BASE_URL, + token: resp.bot_token, + name: accountId, + enabled: true, + }); + + console.log(`[weixin-auth] Login successful, account ${accountId} saved`); + } + break; + } + + case 'expired': + session.status = 'expired'; + // Will be refreshed on next poll + session.startedAt = 0; // Force refresh on next poll + break; + + default: + // Unknown status — keep waiting + break; + } + } catch (err) { + // Timeout on poll is normal — just return current status + if (err instanceof Error && err.name === 'TimeoutError') { + return session; + } + console.error(`[weixin-auth] Poll error:`, err); + } + + return session; +} + +/** + * Cancel and cleanup a login session. + */ +export function cancelQrLoginSession(sessionId: string): void { + getLoginSessions().delete(sessionId); +} diff --git a/src/lib/bridge/adapters/weixin/weixin-ids.ts b/src/lib/bridge/adapters/weixin/weixin-ids.ts new file mode 100644 index 00000000..1296dec9 --- /dev/null +++ b/src/lib/bridge/adapters/weixin/weixin-ids.ts @@ -0,0 +1,29 @@ +/** + * Synthetic chatId encode/decode for WeChat multi-account isolation. + * + * Format: weixin:::: + * This ensures each (account, peer) pair maps to a unique channel_bindings row + * without modifying the existing schema. + */ + +const WEIXIN_PREFIX = 'weixin::'; +const SEPARATOR = '::'; + +export function encodeWeixinChatId(accountId: string, peerUserId: string): string { + return `${WEIXIN_PREFIX}${accountId}${SEPARATOR}${peerUserId}`; +} + +export function decodeWeixinChatId(chatId: string): { accountId: string; peerUserId: string } | null { + if (!chatId.startsWith(WEIXIN_PREFIX)) return null; + const rest = chatId.slice(WEIXIN_PREFIX.length); + const sepIdx = rest.indexOf(SEPARATOR); + if (sepIdx < 0) return null; + const accountId = rest.slice(0, sepIdx); + const peerUserId = rest.slice(sepIdx + SEPARATOR.length); + if (!accountId || !peerUserId) return null; + return { accountId, peerUserId }; +} + +export function isWeixinChatId(chatId: string): boolean { + return chatId.startsWith(WEIXIN_PREFIX) && decodeWeixinChatId(chatId) !== null; +} diff --git a/src/lib/bridge/adapters/weixin/weixin-media.ts b/src/lib/bridge/adapters/weixin/weixin-media.ts new file mode 100644 index 00000000..c5f93bfe --- /dev/null +++ b/src/lib/bridge/adapters/weixin/weixin-media.ts @@ -0,0 +1,194 @@ +/** + * WeChat media handling — AES-128-ECB encryption/decryption for CDN. + * + * WeChat CDN requires media to be encrypted before upload and + * decrypted after download using AES-128-ECB with PKCS7 padding. + */ + +import crypto from 'crypto'; +import type { WeixinCredentials, MessageItem, CDNMedia } from './weixin-types'; +import { MessageItemType, UploadMediaType } from './weixin-types'; +import { getUploadUrl } from './weixin-api'; + +const MAX_MEDIA_SIZE = 100 * 1024 * 1024; // 100 MB + +/** + * Generate a random 16-byte AES key. + */ +export function generateMediaKey(): Buffer { + return crypto.randomBytes(16); +} + +/** + * AES-128-ECB encrypt with PKCS7 padding. + */ +export function encryptMedia(data: Buffer, key: Buffer): Buffer { + const cipher = crypto.createCipheriv('aes-128-ecb', key, null); + return Buffer.concat([cipher.update(data), cipher.final()]); +} + +/** + * AES-128-ECB decrypt with PKCS7 unpadding. + */ +export function decryptMedia(data: Buffer, key: Buffer): Buffer { + const decipher = crypto.createDecipheriv('aes-128-ecb', key, null); + return Buffer.concat([decipher.update(data), decipher.final()]); +} + +/** + * Compute padded ciphertext size for AES-128-ECB. + */ +export function aesEcbPaddedSize(plaintextSize: number): number { + return Math.ceil((plaintextSize + 1) / 16) * 16; +} + +/** + * Parse AES key from a message item. Handles both hex (aeskey field) + * and base64 (media.aes_key field) formats. + */ +function parseAesKey(item: { aeskey?: string; media?: CDNMedia }): Buffer | null { + // Prefer aeskey (hex format) if available + if (item.aeskey && item.aeskey.length === 32) { + return Buffer.from(item.aeskey, 'hex'); + } + // Fallback to media.aes_key (base64 format) + if (item.media?.aes_key) { + return Buffer.from(item.media.aes_key, 'base64'); + } + return null; +} + +/** + * Download and decrypt media from CDN. + */ +export async function downloadAndDecryptMedia( + cdnUrl: string, + aesKey: Buffer, + label: string = 'media', +): Promise { + const res = await fetch(cdnUrl, { + signal: AbortSignal.timeout(60_000), + }); + + if (!res.ok) { + throw new Error(`CDN download failed for ${label}: ${res.status}`); + } + + const encrypted = Buffer.from(await res.arrayBuffer()); + + if (encrypted.length > MAX_MEDIA_SIZE) { + throw new Error(`Media too large: ${encrypted.length} bytes (max ${MAX_MEDIA_SIZE})`); + } + + return decryptMedia(encrypted, aesKey); +} + +/** + * Extract downloadable media from a message item. + * Returns null if no media is present or key is missing. + */ +export async function downloadMediaFromItem( + item: MessageItem, + cdnBaseUrl: string, +): Promise<{ data: Buffer; mimeType: string; filename: string } | null> { + let encryptParam: string | undefined; + let aesKey: Buffer | null = null; + let mimeType = 'application/octet-stream'; + let filename = 'file'; + + switch (item.type) { + case MessageItemType.IMAGE: + if (item.image_item) { + encryptParam = item.image_item.media?.encrypt_query_param; + aesKey = parseAesKey(item.image_item as { aeskey?: string; media?: CDNMedia }); + mimeType = 'image/jpeg'; + filename = `image_${Date.now()}.jpg`; + } + break; + + case MessageItemType.VOICE: + if (item.voice_item) { + encryptParam = item.voice_item.media?.encrypt_query_param; + aesKey = parseAesKey(item.voice_item as { aeskey?: string; media?: CDNMedia }); + mimeType = 'audio/silk'; + filename = `voice_${Date.now()}.silk`; + } + break; + + case MessageItemType.FILE: + if (item.file_item) { + encryptParam = item.file_item.media?.encrypt_query_param; + aesKey = parseAesKey(item.file_item as { aeskey?: string; media?: CDNMedia }); + filename = item.file_item.file_name || `file_${Date.now()}`; + // Try to guess MIME from filename + const ext = filename.split('.').pop()?.toLowerCase(); + if (ext) { + const mimeMap: Record = { + pdf: 'application/pdf', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + txt: 'text/plain', zip: 'application/zip', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', + }; + mimeType = mimeMap[ext] || mimeType; + } + } + break; + + case MessageItemType.VIDEO: + if (item.video_item) { + encryptParam = item.video_item.media?.encrypt_query_param; + aesKey = parseAesKey(item.video_item as { aeskey?: string; media?: CDNMedia }); + mimeType = 'video/mp4'; + filename = `video_${Date.now()}.mp4`; + } + break; + } + + if (!encryptParam || !aesKey) return null; + + const cdnUrl = `${cdnBaseUrl}?${encryptParam}`; + const data = await downloadAndDecryptMedia(cdnUrl, aesKey, filename); + return { data, mimeType, filename }; +} + +/** + * Encrypt and upload media to CDN. + * Returns the download reference parameters for inclusion in sendMessage. + */ +export async function uploadMediaToCdn( + creds: WeixinCredentials, + data: Buffer, + filename: string, + mediaType: number, +): Promise<{ encryptQueryParam: string; aesKeyBase64: string; cipherSize: number }> { + const plainMd5 = crypto.createHash('md5').update(data).digest('hex'); + const aesKey = generateMediaKey(); + const fileKey = crypto.randomBytes(16).toString('hex'); + const cipherSize = aesEcbPaddedSize(data.length); + + // Get pre-signed upload URL + const urlResp = await getUploadUrl(creds, fileKey, mediaType, data.length, plainMd5, cipherSize); + if (!urlResp.upload_param) { + throw new Error('Failed to get upload URL from WeChat'); + } + + // Encrypt + const encrypted = encryptMedia(data, aesKey); + + // Upload to CDN + const cdnUrl = `${creds.cdnBaseUrl}?${urlResp.upload_param}`; + const uploadRes = await fetch(cdnUrl, { + method: 'PUT', + body: new Uint8Array(encrypted), + signal: AbortSignal.timeout(60_000), + }); + + if (!uploadRes.ok) { + throw new Error(`CDN upload failed: ${uploadRes.status}`); + } + + return { + encryptQueryParam: urlResp.upload_param, + aesKeyBase64: aesKey.toString('base64'), + cipherSize: encrypted.length, + }; +} diff --git a/src/lib/bridge/adapters/weixin/weixin-session-guard.ts b/src/lib/bridge/adapters/weixin/weixin-session-guard.ts new file mode 100644 index 00000000..89b5afd4 --- /dev/null +++ b/src/lib/bridge/adapters/weixin/weixin-session-guard.ts @@ -0,0 +1,71 @@ +/** + * Session guard for WeChat accounts. + * + * Tracks pause state per account (e.g., after errcode -14 session expired). + * Pause durations are per-account and stored in memory. + */ + +const PAUSE_DURATION_MS = 60 * 60 * 1000; // 60 minutes + +interface AccountPauseState { + pausedAt: number; + resumeAt: number; + reason: string; +} + +const pauseStates = new Map(); + +/** + * Check if an account is currently paused. + */ +export function isPaused(accountId: string): boolean { + const state = pauseStates.get(accountId); + if (!state) return false; + if (Date.now() >= state.resumeAt) { + // Pause expired — auto-clear + pauseStates.delete(accountId); + return false; + } + return true; +} + +/** + * Get remaining pause time in milliseconds, or 0 if not paused. + */ +export function getPauseRemainingMs(accountId: string): number { + const state = pauseStates.get(accountId); + if (!state) return 0; + const remaining = state.resumeAt - Date.now(); + if (remaining <= 0) { + pauseStates.delete(accountId); + return 0; + } + return remaining; +} + +/** + * Pause an account for the default duration (60 minutes). + */ +export function setPaused(accountId: string, reason: string = 'Session expired'): void { + const now = Date.now(); + pauseStates.set(accountId, { + pausedAt: now, + resumeAt: now + PAUSE_DURATION_MS, + reason, + }); + console.log(`[weixin-session-guard] Account ${accountId} paused for 60 min: ${reason}`); +} + +/** + * Clear pause state for an account. + */ +export function clearPause(accountId: string): void { + pauseStates.delete(accountId); +} + +/** + * Clear all pause states. + */ +export function clearAllPauses(): void { + pauseStates.clear(); +} diff --git a/src/lib/bridge/adapters/weixin/weixin-types.ts b/src/lib/bridge/adapters/weixin/weixin-types.ts new file mode 100644 index 00000000..db917c7a --- /dev/null +++ b/src/lib/bridge/adapters/weixin/weixin-types.ts @@ -0,0 +1,188 @@ +/** + * WeChat protocol types — derived from OpenClaw weixin plugin reference. + * Used as protocol specification only, not runtime dependency. + */ + +export const MessageType = { + NONE: 0, + USER: 1, + BOT: 2, +} as const; + +export const MessageItemType = { + TEXT: 1, + IMAGE: 2, + VOICE: 3, + FILE: 4, + VIDEO: 5, +} as const; + +export const MessageState = { + NEW: 0, + GENERATING: 1, + FINISH: 2, +} as const; + +export const TypingStatus = { + TYPING: 1, + CANCEL: 2, +} as const; + +export const UploadMediaType = { + IMAGE: 1, + VIDEO: 2, + FILE: 3, + VOICE: 4, +} as const; + +export interface CDNMedia { + encrypt_query_param: string; + aes_key: string; // base64 encoded + encrypt_type: number; +} + +export interface TextItem { + text: string; +} + +export interface ImageItem { + media?: CDNMedia; + aeskey?: string; // hex + mid_size?: number; +} + +export interface VoiceItem { + media?: CDNMedia; + voice_length_ms?: number; +} + +export interface FileItem { + media?: CDNMedia; + file_name?: string; + file_size?: number; +} + +export interface VideoItem { + media?: CDNMedia; + video_length_s?: number; +} + +export interface MessageItem { + type: number; + text_item?: TextItem; + image_item?: ImageItem; + voice_item?: VoiceItem; + file_item?: FileItem; + video_item?: VideoItem; +} + +export interface WeixinMessage { + seq?: number; + message_id?: string; + msg_type?: number; + from_user_id: string; + to_user_id?: string; + item_list?: MessageItem[]; + context_token?: string; + create_time?: number; + state?: number; + ref_message?: { + title?: string; + content?: string; + item_list?: MessageItem[]; + }; +} + +export interface GetUpdatesRequest { + get_updates_buf?: string; + timeout_ms?: number; +} + +export interface GetUpdatesResponse { + errcode?: number; + errmsg?: string; + msgs?: WeixinMessage[]; + get_updates_buf?: string; + longpolling_timeout_ms?: number; +} + +/** SendMessage request: wraps a single WeixinMessage + base_info. */ +export interface SendMessageRequest { + msg: { + from_user_id: string; + to_user_id: string; + client_id: string; + message_type: number; + message_state: number; + item_list?: MessageItem[]; + context_token?: string; + }; + base_info?: { channel_version?: string }; +} + +/** SendMessage response is effectively empty on success. */ +export interface SendMessageResponse { + errcode?: number; + errmsg?: string; +} + +export interface GetUploadUrlRequest { + file_key: string; + file_type: number; + file_size: number; + file_md5: string; + cipher_file_size: number; +} + +export interface GetUploadUrlResponse { + errcode?: number; + errmsg?: string; + upload_param?: string; +} + +export interface GetConfigResponse { + errcode?: number; + errmsg?: string; + typing_ticket?: string; + route_tag?: string; +} + +/** SendTyping request: uses ilink_user_id + typing_ticket + status. */ +export interface SendTypingRequest { + ilink_user_id: string; + typing_ticket: string; + status: number; +} + +export interface QrCodeStartResponse { + errcode?: number; + errmsg?: string; + qrcode?: string; + qrcode_img_content?: string; // URL to QR code image +} + +export interface QrCodeStatusResponse { + errcode?: number; + errmsg?: string; + status?: 'wait' | 'scaned' | 'confirmed' | 'expired'; + bot_token?: string; + ilink_bot_id?: string; + baseurl?: string; + ilink_user_id?: string; +} + +export interface WeixinCredentials { + botToken: string; + ilinkBotId: string; + baseUrl: string; + cdnBaseUrl: string; +} + +/** Error code indicating the session has expired */ +export const ERRCODE_SESSION_EXPIRED = -14; + +/** Default base URL */ +export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'; + +/** Default CDN base URL */ +export const DEFAULT_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'; diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts index d3d8fbe7..6c9b6fdf 100644 --- a/src/lib/bridge/bridge-manager.ts +++ b/src/lib/bridge/bridge-manager.ts @@ -162,6 +162,27 @@ async function deliverResponse( } return { ok: true }; } + if (adapter.channelType === 'weixin') { + // WeChat plain text only, 4096 chars per chunk, max 5 chunks. + const WEIXIN_MAX_CHUNKS = 5; + const limit = limits.weixin || 4096; + const chunks = chunkText(responseText, limit); + + const effectiveChunks = chunks.length > WEIXIN_MAX_CHUNKS + ? [...chunks.slice(0, WEIXIN_MAX_CHUNKS - 1), chunks.slice(WEIXIN_MAX_CHUNKS - 1).join('\n').slice(0, limit - 30) + '\n\n[... response truncated]'] + : chunks; + + for (let i = 0; i < effectiveChunks.length; i++) { + const result = await deliver(adapter, { + address, + text: effectiveChunks[i], + parseMode: 'plain', + replyToMessageId, + }, { sessionId }); + if (!result.ok) return result; + } + return { ok: true }; + } // Generic fallback: deliver as plain text return deliver(adapter, { @@ -228,24 +249,33 @@ function processWithSessionLock(sessionId: string, fn: () => Promise): Pro return current; } +export interface StartResult { + started: boolean; + reason?: string; +} + /** * Start the bridge system. * Checks feature flags, registers enabled adapters, starts polling loops. + * Returns a result indicating success/failure with a reason. */ -export async function start(): Promise { +export async function start(): Promise { const state = getState(); - if (state.running) return; + if (state.running) return { started: true }; const bridgeEnabled = getSetting('remote_bridge_enabled') === 'true'; if (!bridgeEnabled) { console.log('[bridge-manager] Bridge not enabled (remote_bridge_enabled != true)'); - return; + return { started: false, reason: 'bridge_not_enabled' }; } // Iterate all registered adapter types and create those that are enabled + const configErrors: string[] = []; + let enabledCount = 0; for (const channelType of getRegisteredTypes()) { const settingKey = `bridge_${channelType}_enabled`; if (getSetting(settingKey) !== 'true') continue; + enabledCount++; const adapter = createAdapter(channelType); if (!adapter) continue; @@ -255,9 +285,14 @@ export async function start(): Promise { registerAdapter(adapter); } else { console.warn(`[bridge-manager] ${channelType} adapter not valid:`, configError); + configErrors.push(`${channelType}: ${configError}`); } } + if (enabledCount === 0) { + return { started: false, reason: 'no_channels_enabled' }; + } + // Start all registered adapters, track how many succeeded let startedCount = 0; for (const [type, adapter] of state.adapters) { @@ -275,7 +310,10 @@ export async function start(): Promise { console.warn('[bridge-manager] No adapters started successfully, bridge not activated'); state.adapters.clear(); state.adapterMeta.clear(); - return; + const reason = configErrors.length > 0 + ? `adapter_config_invalid: ${configErrors.join('; ')}` + : 'no_adapters_started'; + return { started: false, reason }; } // Mark running BEFORE starting consumer loops — runAdapterLoop checks @@ -294,6 +332,7 @@ export async function start(): Promise { } console.log(`[bridge-manager] Bridge started with ${startedCount} adapter(s)`); + return { started: true }; } /** @@ -333,6 +372,18 @@ export async function stop(): Promise { console.log('[bridge-manager] Bridge stopped'); } +/** + * Restart the bridge (stop + start). Useful when adapter config changes + * at runtime, e.g. a new WeChat account is linked while the bridge is running. + */ +export async function restart(): Promise { + const state = getState(); + if (state.running) { + await stop(); + } + return start(); +} + /** * Lazy auto-start: checks bridge_auto_start setting once and starts if enabled. * Called from POST /api/bridge with action 'auto-start' (triggered by Electron on startup). diff --git a/src/lib/bridge/channel-router.ts b/src/lib/bridge/channel-router.ts index b1ec5aa4..d1c49212 100644 --- a/src/lib/bridge/channel-router.ts +++ b/src/lib/bridge/channel-router.ts @@ -15,18 +15,69 @@ import { createSession, getSetting, updateSessionProviderId, + updateSessionWorkingDirectory, + updateSdkSessionId, } from '../db'; +import { resolveWorkingDirectory } from '../working-directory'; + +function shouldResetResumeForSource(source: string): boolean { + return source === 'setting' || source === 'home' || source === 'process'; +} /** * Resolve an inbound address to a ChannelBinding. * If no binding exists, auto-creates a new session and binding. + * Self-heals stale workingDirectory / sdkSessionId in existing bindings. */ export function resolve(address: ChannelAddress): ChannelBinding { const existing = getChannelBinding(address.channelType, address.chatId); if (existing) { - // Verify the linked session still exists; if not, create a new one const session = getSession(existing.codepilotSessionId); - if (session) return existing; + if (session) { + const resolved = resolveWorkingDirectory([ + { path: session.sdk_cwd, source: 'session_sdk_cwd' }, + { path: existing.workingDirectory, source: 'binding' }, + { path: session.working_directory, source: 'session_working_directory' }, + { path: getSetting('bridge_default_work_dir'), source: 'setting' }, + ]); + const shouldResetResume = shouldResetResumeForSource(resolved.source); + const updates: Partial> = {}; + + if (resolved.invalidCandidates.length > 0) { + console.warn('[channel-router] Healed invalid bridge working directory', { + channelType: existing.channelType, + chatId: existing.chatId, + sessionId: existing.codepilotSessionId, + selected: resolved.path, + source: resolved.source, + invalidCandidates: resolved.invalidCandidates, + }); + } + + if (existing.workingDirectory !== resolved.path) { + updates.workingDirectory = resolved.path; + existing.workingDirectory = resolved.path; + } + + if (shouldResetResume && existing.sdkSessionId) { + updates.sdkSessionId = ''; + existing.sdkSessionId = ''; + } + + if (Object.keys(updates).length > 0) { + updateChannelBinding(existing.id, updates); + } + + if (session.working_directory !== resolved.path || session.sdk_cwd !== resolved.path) { + updateSessionWorkingDirectory(session.id, resolved.path); + } + + if (shouldResetResume && session.sdk_session_id) { + updateSdkSessionId(session.id, ''); + } + + return existing; + } // Session was deleted — recreate return createBinding(address); } @@ -40,10 +91,11 @@ export function createBinding( address: ChannelAddress, workingDirectory?: string, ): ChannelBinding { - const defaultCwd = workingDirectory - || getSetting('bridge_default_work_dir') - || process.env.HOME - || ''; + const resolved = resolveWorkingDirectory([ + { path: workingDirectory, source: 'requested' }, + { path: getSetting('bridge_default_work_dir'), source: 'setting' }, + ]); + const defaultCwd = resolved.path; const defaultModel = getSetting('bridge_default_model') || ''; const defaultProviderId = getSetting('bridge_default_provider_id') || ''; @@ -81,12 +133,22 @@ export function bindToSession( const session = getSession(codepilotSessionId); if (!session) return null; + const resolved = resolveWorkingDirectory([ + { path: session.sdk_cwd, source: 'session_sdk_cwd' }, + { path: session.working_directory, source: 'session_working_directory' }, + { path: getSetting('bridge_default_work_dir'), source: 'setting' }, + ]); + + if (session.working_directory !== resolved.path || session.sdk_cwd !== resolved.path) { + updateSessionWorkingDirectory(session.id, resolved.path); + } + return upsertChannelBinding({ channelType: address.channelType, chatId: address.chatId, codepilotSessionId, sdkSessionId: '', - workingDirectory: session.working_directory, + workingDirectory: resolved.path, model: session.model, mode: 'code', }); diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts index 005b31ea..ce036ccc 100644 --- a/src/lib/bridge/conversation-engine.ts +++ b/src/lib/bridge/conversation-engine.ts @@ -10,7 +10,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import type { ChannelBinding } from './types'; -import type { SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, MCPServerConfig } from '@/types'; +import type { SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types'; import { streamClaude } from '../claude-client'; import { addMessage, @@ -26,42 +26,10 @@ import { getSetting, } from '../db'; import { resolveProvider as resolveProviderUnified } from '../provider-resolver'; +import { loadCodePilotMcpServers } from '../mcp-loader'; +import { assembleContext } from '../context-assembler'; import crypto from 'crypto'; -/** Read MCP server configs from ~/.claude.json and ~/.claude/settings.json */ -function loadMcpServers(): Record | undefined { - try { - const readJson = (p: string): Record => { - if (!fs.existsSync(p)) return {}; - try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return {}; } - }; - const userConfig = readJson(path.join(os.homedir(), '.claude.json')); - const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')); - // Also read project-level .mcp.json - const projectMcp = readJson(path.join(process.cwd(), '.mcp.json')); - const merged = { - ...((userConfig.mcpServers || {}) as Record), - ...((settings.mcpServers || {}) as Record), - ...((projectMcp.mcpServers || {}) as Record), - }; - // Resolve ${...} placeholders in env values against DB settings - for (const server of Object.values(merged)) { - if (server.env) { - for (const [key, value] of Object.entries(server.env)) { - if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) { - const settingKey = value.slice(2, -1); - const resolved = getSetting(settingKey); - server.env[key] = resolved || ''; - } - } - } - } - return Object.keys(merged).length > 0 ? merged : undefined; - } catch { - return undefined; - } -} - export interface PermissionRequestInfo { permissionRequestId: string; toolName: string; @@ -100,6 +68,17 @@ export interface ConversationResult { sdkSessionId: string | null; } +/** + * Resolve and validate working directory from multiple candidates. + * Returns the first existing directory, or HOME as last resort. + */ +function resolveWorkingDirectory(...candidates: (string | undefined | null)[]): string { + for (const dir of candidates) { + if (dir && fs.existsSync(dir)) return dir; + } + return os.homedir(); +} + /** * Process an inbound message: send to Claude, consume the response stream, * save to DB, and return the result. @@ -212,17 +191,42 @@ export async function processMessage( } } - // Load MCP servers from Claude config files so the SDK has access to - // user-level MCP tools, matching the desktop chat route behavior. - const mcpServers = loadMcpServers(); + // Load only MCP servers needing CodePilot-specific processing (${...} env placeholders). + // All other MCP servers are auto-loaded by the SDK via settingSources. + const mcpServers = loadCodePilotMcpServers(); + + // Unified context assembly — adds CLI tools context (and workspace prompt if applicable) + const assembled = await assembleContext({ + session: session!, + entryPoint: 'bridge', + userPrompt: text, + conversationHistory: historyMsgs, + }); + + // Resolve a valid working directory from multiple candidates + const effectiveCwd = resolveWorkingDirectory( + binding.workingDirectory, + session?.working_directory, + getSetting('bridge_default_work_dir'), + ); + + // If the effective cwd differs from what the binding/session had, the + // original directory is gone — clear sdkSessionId to prevent stale resume. + const originalCwd = binding.workingDirectory || session?.working_directory; + const cwdChanged = originalCwd && effectiveCwd !== originalCwd; + const effectiveSdkSessionId = cwdChanged ? undefined : (binding.sdkSessionId || undefined); + + if (cwdChanged) { + console.log(`[conversation-engine] CWD changed from "${originalCwd}" to "${effectiveCwd}", clearing sdkSessionId`); + } const stream = streamClaude({ prompt: text, sessionId, - sdkSessionId: binding.sdkSessionId || undefined, + sdkSessionId: effectiveSdkSessionId, model: effectiveModel, - systemPrompt: session?.system_prompt || undefined, - workingDirectory: binding.workingDirectory || session?.working_directory || undefined, + systemPrompt: assembled.systemPrompt, + workingDirectory: effectiveCwd, abortController, permissionMode, provider: resolvedProvider, @@ -231,6 +235,12 @@ export async function processMessage( conversationHistory: historyMsgs, files, bypassPermissions, + // Bridge-specific SDK options + thinking: { type: 'disabled' as const }, + effort: 'medium' as const, + generativeUI: false, + enableFileCheckpointing: false, + context1m: false, onRuntimeStatusChange: (status: string) => { try { setSessionRuntimeStatus(sessionId, status); } catch { /* best effort */ } }, @@ -385,10 +395,17 @@ async function consumeStream( break; } - case 'error': + case 'error': { hasError = true; - errorMessage = event.data || 'Unknown error'; + // Parse structured error JSON to extract a user-friendly message + try { + const errObj = JSON.parse(event.data); + errorMessage = errObj.userMessage || errObj._formattedMessage || errObj.message || event.data; + } catch { + errorMessage = event.data || 'Unknown error'; + } break; + } case 'result': { try { diff --git a/src/lib/bridge/permission-broker.ts b/src/lib/bridge/permission-broker.ts index 395a1245..2e343a1d 100644 --- a/src/lib/bridge/permission-broker.ts +++ b/src/lib/bridge/permission-broker.ts @@ -68,7 +68,7 @@ export async function forwardPermissionRequest( // Channels without inline button support (e.g. QQ) need text-based // permission commands. Check if the adapter ignores inlineButtons. - const supportsButtons = adapter.channelType !== 'qq'; + const supportsButtons = !['qq', 'weixin'].includes(adapter.channelType); const textLines = [ `Permission Required`, diff --git a/src/lib/bridge/types.ts b/src/lib/bridge/types.ts index 69b780f1..37dc9eda 100644 --- a/src/lib/bridge/types.ts +++ b/src/lib/bridge/types.ts @@ -173,4 +173,5 @@ export const PLATFORM_LIMITS: Record = { slack: 40000, feishu: 30000, qq: 2000, + weixin: 4096, }; diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 9979e91e..d31b7ac8 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -17,12 +17,13 @@ import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, Permis import { isImageFile } from '@/types'; import { registerPendingPermission } from './permission-registry'; import { registerConversation, unregisterConversation } from './conversation-registry'; -import { captureCapabilities, setCachedPlugins } from './agent-sdk-capabilities'; +import { captureCapabilities, isCacheFresh, setCachedPlugins } from './agent-sdk-capabilities'; import { getSetting, updateSdkSessionId, createPermissionRequest } from './db'; import { resolveForClaudeCode, toClaudeCodeEnv } from './provider-resolver'; import { findClaudeBinary, findGitBash, getExpandedPath, invalidateClaudePathCache } from './platform'; import { notifyPermissionRequest, notifyGeneric } from './telegram-bot'; import { classifyError, formatClassifiedError } from './error-classifier'; +import { resolveWorkingDirectory } from './working-directory'; import os from 'os'; import fs from 'fs'; import path from 'path'; @@ -409,6 +410,16 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { const widgetKeywords = /可视化|图表|流程图|时间线|架构图|对比|visualiz|diagram|chart|flowchart|timeline|infographic|interactive|widget|show-widget|hierarchy|dashboard/i; - // Check current prompt + // Check current user prompt if (widgetKeywords.test(prompt)) return true; // Check if conversation already has widgets (resume context) if (conversationHistory?.some(m => m.content.includes('show-widget'))) return true; - // Check system prompt for image/widget agent mode - if (systemPrompt && widgetKeywords.test(systemPrompt)) return true; + // Check explicit widget/image-agent mode + if (imageAgentMode) return true; return false; })(); @@ -542,9 +553,10 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream 0) { - const workDir = workingDirectory || os.homedir(); + const workDir = resolvedWorkingDirectory.path; const savedPaths = getUploadedFilePaths(nonImageFiles, workDir); const fileReferences = savedPaths .map((p, i) => `[User attached file: ${p} (${nonImageFiles[i].name})]`) @@ -716,7 +739,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { - const workDir = workingDirectory || os.homedir(); + const workDir = resolvedWorkingDirectory.path; const imagePaths = getUploadedFilePaths(imageFiles, workDir); const imageReferences = imagePaths .map((p, i) => `[User attached image: ${p} (${imageFiles[i].name})]`) @@ -814,12 +837,10 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { - console.warn('[claude-client] Capability capture failed:', err); - }); + let capturePending = !isCacheFresh(capProviderId); let tokenUsage: TokenUsage | null = null; // Track pending TodoWrite tool_use_ids so we can sync after successful execution @@ -831,6 +852,13 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { + console.warn('[claude-client] Deferred capability capture failed:', err); + }); + } const assistantMsg = message as SDKAssistantMessage; // Text deltas are handled by stream_event for real-time streaming. // Here we only process tool_use blocks. diff --git a/src/lib/context-assembler.ts b/src/lib/context-assembler.ts new file mode 100644 index 00000000..01807347 --- /dev/null +++ b/src/lib/context-assembler.ts @@ -0,0 +1,230 @@ +/** + * Context Assembler — unified system prompt assembly for all entry points. + * + * Extracts the 5-layer prompt assembly logic from route.ts into a pure async + * function. Both browser chat (route.ts) and bridge (conversation-engine.ts) + * call this, ensuring consistent context regardless of entry point. + * + * Layer injection is controlled by entry point type: + * Desktop: workspace + session + assistant instructions + CLI tools + widget + * Bridge: workspace + session + assistant instructions + CLI tools (no widget) + */ + +import type { ChatSession, SearchResult } from '@/types'; +import { getSetting } from '@/lib/db'; + +// ── Types ──────────────────────────────────────────────────────────── + +export interface ContextAssemblyConfig { + /** The session from DB */ + session: ChatSession; + /** Entry point: controls which layers are injected */ + entryPoint: 'desktop' | 'bridge'; + /** Current user prompt (used for workspace retrieval + widget keyword detection) */ + userPrompt: string; + /** Per-request system prompt append (e.g., skill injection for image generation) */ + systemPromptAppend?: string; + /** Conversation history (for widget keyword detection in resume context) */ + conversationHistory?: Array<{ role: string; content: string }>; + /** Whether this is an image agent mode call */ + imageAgentMode?: boolean; +} + +export interface AssembledContext { + /** Final assembled system prompt string, or undefined if no layers produced content */ + systemPrompt: string | undefined; + /** Whether generative UI is enabled (affects widget MCP server + streamClaude param) */ + generativeUIEnabled: boolean; + /** Whether widget MCP server should be registered (keyword-gated) */ + needsWidgetMcp: boolean; + /** Onboarding/checkin instructions (route.ts uses this for server-side completion detection) */ + assistantProjectInstructions: string; + /** Whether this session is in the assistant workspace */ + isAssistantProject: boolean; +} + +// ── Main function ──────────────────────────────────────────────────── + +export async function assembleContext(config: ContextAssemblyConfig): Promise { + const { session, entryPoint, userPrompt, systemPromptAppend, conversationHistory, imageAgentMode } = config; + const t0 = Date.now(); + + let workspacePrompt = ''; + let assistantProjectInstructions = ''; + let isAssistantProject = false; + + // ── Layer 1: Workspace prompt (if assistant project session) ────── + try { + const workspacePath = getSetting('assistant_workspace_path'); + if (workspacePath) { + const sessionWd = session.working_directory || ''; + isAssistantProject = sessionWd === workspacePath; + + if (isAssistantProject) { + const { loadWorkspaceFiles, assembleWorkspacePrompt, loadState, needsDailyCheckIn } = + await import('@/lib/assistant-workspace'); + + // Incremental reindex BEFORE search so current turn sees latest content + try { + const { indexWorkspace } = await import('@/lib/workspace-indexer'); + indexWorkspace(workspacePath); + } catch { + // indexer not available, skip + } + + const files = loadWorkspaceFiles(workspacePath); + + // Retrieval: search workspace index for relevant context + let retrievalResults: SearchResult[] | undefined; + try { + const { searchWorkspace, updateHotset } = await import('@/lib/workspace-retrieval'); + if (userPrompt.length > 10) { + retrievalResults = searchWorkspace(workspacePath, userPrompt, { limit: 5 }); + if (retrievalResults.length > 0) { + updateHotset(workspacePath, retrievalResults.map(r => r.path)); + } + } + } catch { + // retrieval module not available, skip + } + + workspacePrompt = assembleWorkspacePrompt(files, retrievalResults); + + const state = loadState(workspacePath); + + if (!state.onboardingComplete) { + assistantProjectInstructions = buildOnboardingInstructions(); + } else if (needsDailyCheckIn(state)) { + assistantProjectInstructions = buildCheckinInstructions(); + } + } + } + } catch (e) { + console.warn('[context-assembler] Failed to load assistant workspace:', e); + } + + // ── Layer 2: Session prompt + per-request append ────────────────── + let finalSystemPrompt: string | undefined = session.system_prompt || undefined; + if (systemPromptAppend) { + finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + systemPromptAppend; + } + + // Workspace prompt goes first (base personality), session prompt after (task override) + if (workspacePrompt) { + finalSystemPrompt = workspacePrompt + '\n\n' + (finalSystemPrompt || ''); + } + + // ── Layer 3: Assistant project instructions ─────────────────────── + if (assistantProjectInstructions) { + finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + assistantProjectInstructions; + } + + // ── Layer 4: CLI tools context (always, both desktop and bridge) ── + const t1 = Date.now(); + try { + const { buildCliToolsContext } = await import('@/lib/cli-tools-context'); + const cliToolsCtx = await buildCliToolsContext(); + const t2 = Date.now(); + console.log(`[context-assembler] CLI tools detection: ${t2 - t1}ms`); + if (cliToolsCtx) { + finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + cliToolsCtx; + } + } catch { + // CLI tools context injection failed — don't block + } + + // ── Layer 5: Widget system prompt (desktop only) ────────────────── + const generativeUISetting = getSetting('generative_ui_enabled'); + const generativeUIEnabled = entryPoint === 'desktop' && generativeUISetting !== 'false'; + + if (generativeUIEnabled) { + try { + const { WIDGET_SYSTEM_PROMPT } = await import('@/lib/widget-guidelines'); + finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + WIDGET_SYSTEM_PROMPT; + } catch { + // Widget prompt injection failed — don't block + } + } + + // ── Widget MCP keyword detection (desktop only) ─────────────────── + let needsWidgetMcp = false; + if (generativeUIEnabled) { + const widgetKeywords = /可视化|图表|流程图|时间线|架构图|对比|visualiz|diagram|chart|flowchart|timeline|infographic|interactive|widget|show-widget|hierarchy|dashboard/i; + if (widgetKeywords.test(userPrompt)) needsWidgetMcp = true; + else if (conversationHistory?.some(m => m.content.includes('show-widget'))) needsWidgetMcp = true; + else if (imageAgentMode) needsWidgetMcp = true; + } + + console.log(`[context-assembler] total: ${Date.now() - t0}ms (entry=${entryPoint}, prompt=${finalSystemPrompt?.length ?? 0} chars)`); + + return { + systemPrompt: finalSystemPrompt, + generativeUIEnabled, + needsWidgetMcp, + assistantProjectInstructions, + isAssistantProject, + }; +} + +// ── Instruction templates ──────────────────────────────────────────── + +function buildOnboardingInstructions(): string { + return ` +You are now in the assistant workspace onboarding session. Your task is to interview the user to build their profile. + +Ask the following 13 questions ONE AT A TIME. Wait for the user's answer before asking the next question. Be conversational and friendly. + +1. How should I address you? +2. What name should I use for myself? +3. Do you prefer "concise and direct" or "detailed explanations"? +4. Do you prefer "minimal interruptions" or "proactive suggestions"? +5. What are your three hard boundaries? +6. What are your three most important current goals? +7. Do you prefer output as "lists", "reports", or "conversation summaries"? +8. What information may be written to long-term memory? +9. What information must never be written to long-term memory? +10. What three things should I do first when entering a project? +11. How do you organize your materials? (by project / time / topic / mixed) +12. Where should new information go by default? +13. How should completed tasks be archived? + +After the user answers the LAST question (Q13), you MUST immediately output the completion block below. Do NOT wait for the user to say anything else. Do NOT ask for confirmation. Just output the block right after your response to Q13. + +CRITICAL FORMATTING RULES for the completion block: +- Each value must be a single line (replace any newlines with spaces) +- Escape all double quotes inside values with backslash: \\" +- Do NOT use single quotes for JSON keys or values +- Do NOT add trailing commas +- The JSON must be on a SINGLE line + +\`\`\`onboarding-complete +{"q1":"answer1","q2":"answer2","q3":"answer3","q4":"answer4","q5":"answer5","q6":"answer6","q7":"answer7","q8":"answer8","q9":"answer9","q10":"answer10","q11":"answer11","q12":"answer12","q13":"answer13"} +\`\`\` + +After outputting the completion block, tell the user that the setup is complete and the system is now initializing their workspace. Keep this message brief and friendly. + +Do NOT try to write files yourself. The system will automatically generate soul.md, user.md, claude.md, memory.md, config.json, and taxonomy.json from your collected answers. + +Start by greeting the user and asking the first question. +`; +} + +function buildCheckinInstructions(): string { + return ` +You are now in the assistant workspace daily check-in session. Ask the user these 3 questions ONE AT A TIME: + +1. What did you work on or accomplish today? +2. Any changes to your current priorities or goals? +3. Anything you'd like me to remember going forward? + +After collecting all 3 answers, output a summary in exactly this format: + +\`\`\`checkin-complete +{"q1":"answer1","q2":"answer2","q3":"answer3"} +\`\`\` + +Do NOT try to write files yourself. The system will automatically write a daily memory entry and update user.md from your collected answers. + +Start by greeting the user and asking the first question. +`; +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 7329197b..f6d83b58 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -711,6 +711,33 @@ function migrateDb(db: Database.Database): void { UNIQUE(channel_type, account_id) ); `); + + // WeChat: bot accounts for multi-account support + db.exec(` + CREATE TABLE IF NOT EXISTS weixin_accounts ( + account_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL DEFAULT '', + base_url TEXT NOT NULL DEFAULT '', + cdn_base_url TEXT NOT NULL DEFAULT '', + token TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + last_login_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + + // WeChat: per-peer context token persistence + db.exec(` + CREATE TABLE IF NOT EXISTS weixin_context_tokens ( + account_id TEXT NOT NULL, + peer_user_id TEXT NOT NULL, + context_token TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY(account_id, peer_user_id) + ); + `); } // ========================================== @@ -804,17 +831,29 @@ export function updateSessionProviderId(id: string, providerId: string): void { } export function getDefaultProviderId(): string | undefined { + // Primary source: derived from global default model's provider + const globalProvider = getSetting('global_default_model_provider'); + if (globalProvider) return globalProvider; + // Legacy fallback: old default_provider_id setting (for migration) return getSetting('default_provider_id') || undefined; } export function setDefaultProviderId(id: string): void { + // Write legacy setting setSetting('default_provider_id', id); + // Also write the primary key so getDefaultProviderId() sees the change. + // Clear global_default_model at the same time — the old model belonged to + // the previous provider and is no longer valid. The UI will fall back to + // the provider's first model until the user picks a new default. + setSetting('global_default_model_provider', id); + setSetting('global_default_model', ''); } export function updateSessionWorkingDirectory(id: string, workingDirectory: string): void { const db = getDb(); const projectName = path.basename(workingDirectory); - db.prepare('UPDATE chat_sessions SET working_directory = ?, project_name = ? WHERE id = ?').run(workingDirectory, projectName, id); + // Sync sdk_cwd + clear sdk_session_id — old session context is invalid + db.prepare('UPDATE chat_sessions SET working_directory = ?, sdk_cwd = ?, project_name = ?, sdk_session_id = ? WHERE id = ?').run(workingDirectory, workingDirectory, projectName, '', id); } export function updateSessionMode(id: string, mode: string): void { @@ -1157,10 +1196,21 @@ export function deleteProvider(id: string): boolean { * For DB providers, reads from options_json column. */ export function getProviderOptions(providerId: string): import('@/types').ProviderOptions { + if (providerId === '__global__') { + const defaultModel = getSetting('global_default_model') || undefined; + const defaultModelProvider = getSetting('global_default_model_provider') || undefined; + return { + ...(defaultModel ? { default_model: defaultModel } : {}), + ...(defaultModelProvider ? { default_model_provider: defaultModelProvider } : {}), + }; + } if (providerId === 'env') { const thinkingMode = getSetting('thinking_mode') || 'adaptive'; const context1m = getSetting('context_1m') === 'true'; - return { thinking_mode: thinkingMode as 'adaptive' | 'enabled' | 'disabled', context_1m: context1m }; + return { + thinking_mode: thinkingMode as 'adaptive' | 'enabled' | 'disabled', + context_1m: context1m, + }; } const provider = getProvider(providerId); if (!provider) return {}; @@ -1174,6 +1224,15 @@ export function getProviderOptions(providerId: string): import('@/types').Provid * For DB providers, writes to options_json column. */ export function setProviderOptions(providerId: string, options: import('@/types').ProviderOptions): void { + if (providerId === '__global__') { + if (options.default_model !== undefined) setSetting('global_default_model', options.default_model); + if (options.default_model_provider !== undefined) setSetting('global_default_model_provider', options.default_model_provider); + // Sync legacy default_provider_id so backend consumers (doctor, repair, etc.) stay consistent + if ((options as Record).legacy_default_provider_id !== undefined) { + setSetting('default_provider_id', (options as Record).legacy_default_provider_id as string); + } + return; + } if (providerId === 'env') { if (options.thinking_mode !== undefined) setSetting('thinking_mode', options.thinking_mode); if (options.context_1m !== undefined) setSetting('context_1m', options.context_1m ? 'true' : ''); @@ -2094,6 +2153,112 @@ export function markPermissionLinkResolved(permissionRequestId: string): boolean return result.changes > 0; } +// ========================================== +// WeChat Account Operations +// ========================================== + +export interface WeixinAccountRow { + account_id: string; + user_id: string; + base_url: string; + cdn_base_url: string; + token: string; + name: string; + enabled: number; + last_login_at: string | null; + created_at: string; + updated_at: string; +} + +export function listWeixinAccounts(): WeixinAccountRow[] { + const db = getDb(); + return db.prepare('SELECT * FROM weixin_accounts ORDER BY created_at DESC').all() as WeixinAccountRow[]; +} + +export function getWeixinAccount(accountId: string): WeixinAccountRow | undefined { + const db = getDb(); + return db.prepare('SELECT * FROM weixin_accounts WHERE account_id = ?').get(accountId) as WeixinAccountRow | undefined; +} + +export function upsertWeixinAccount(params: { + accountId: string; + userId?: string; + baseUrl?: string; + cdnBaseUrl?: string; + token?: string; + name?: string; + enabled?: boolean; +}): void { + const db = getDb(); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + db.prepare(` + INSERT INTO weixin_accounts (account_id, user_id, base_url, cdn_base_url, token, name, enabled, last_login_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + user_id = COALESCE(excluded.user_id, weixin_accounts.user_id), + base_url = COALESCE(excluded.base_url, weixin_accounts.base_url), + cdn_base_url = COALESCE(excluded.cdn_base_url, weixin_accounts.cdn_base_url), + token = COALESCE(excluded.token, weixin_accounts.token), + name = COALESCE(excluded.name, weixin_accounts.name), + enabled = excluded.enabled, + last_login_at = excluded.last_login_at, + updated_at = excluded.updated_at + `).run( + params.accountId, + params.userId || '', + params.baseUrl || '', + params.cdnBaseUrl || '', + params.token || '', + params.name || '', + params.enabled !== false ? 1 : 0, + now, + now, + now, + ); +} + +export function deleteWeixinAccount(accountId: string): boolean { + const db = getDb(); + // Also clean up context tokens and offsets + db.prepare('DELETE FROM weixin_context_tokens WHERE account_id = ?').run(accountId); + db.prepare('DELETE FROM channel_offsets WHERE channel_type = ?').run(`weixin:${accountId}`); + const result = db.prepare('DELETE FROM weixin_accounts WHERE account_id = ?').run(accountId); + return result.changes > 0; +} + +export function setWeixinAccountEnabled(accountId: string, enabled: boolean): void { + const db = getDb(); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + db.prepare( + 'UPDATE weixin_accounts SET enabled = ?, updated_at = ? WHERE account_id = ?' + ).run(enabled ? 1 : 0, now, accountId); +} + +export function getWeixinContextToken(accountId: string, peerUserId: string): string | undefined { + const db = getDb(); + const row = db.prepare( + 'SELECT context_token FROM weixin_context_tokens WHERE account_id = ? AND peer_user_id = ?' + ).get(accountId, peerUserId) as { context_token: string } | undefined; + return row?.context_token; +} + +export function upsertWeixinContextToken(accountId: string, peerUserId: string, contextToken: string): void { + const db = getDb(); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + db.prepare(` + INSERT INTO weixin_context_tokens (account_id, peer_user_id, context_token, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(account_id, peer_user_id) DO UPDATE SET + context_token = excluded.context_token, + updated_at = excluded.updated_at + `).run(accountId, peerUserId, contextToken, now); +} + +export function deleteWeixinContextTokensByAccount(accountId: string): void { + const db = getDb(); + db.prepare('DELETE FROM weixin_context_tokens WHERE account_id = ?').run(accountId); +} + // ========================================== // Graceful Shutdown // ========================================== diff --git a/src/lib/error-classifier.ts b/src/lib/error-classifier.ts index 89bb5879..c2e4e50a 100644 --- a/src/lib/error-classifier.ts +++ b/src/lib/error-classifier.ts @@ -23,6 +23,7 @@ export type ClaudeErrorCategory = | 'CLI_INSTALL_CONFLICT' | 'MISSING_GIT_BASH' | 'RESUME_FAILED' + | 'SESSION_STATE_ERROR' | 'PROVIDER_NOT_APPLIED' | 'PROCESS_CRASH' | 'UNKNOWN'; @@ -201,14 +202,47 @@ const ERROR_PATTERNS: ErrorPattern[] = [ }, // ── Resume failed ── + // Must be before PROCESS_CRASH so session-related crashes don't get + // misclassified as provider configuration problems. { category: 'RESUME_FAILED', - patterns: ['resume failed', 'session not found', 'invalid session', 'session expired'], + patterns: [ + 'resume failed', + 'session not found', + 'invalid session', + 'session expired', + 'could not resume', + 'failed to resume', + 'resume_failed', + 'conversation not found', + /session\s+id\s+.*\s*(invalid|expired|not found|missing)/, + ], userMessage: () => 'Failed to resume previous conversation.', actionHint: () => 'The conversation will start fresh automatically. No action needed.', retryable: false, }, + // ── Session state error ── + // Catches stale/corrupt session state that causes CLI crashes. + // Must be before PROCESS_CRASH to prevent misclassification as provider issues. + { + category: 'SESSION_STATE_ERROR', + patterns: [ + 'stale session', + 'stale sdk_session', + 'session state', + 'session_state', + 'corrupt session', + 'session mismatch', + 'session context', + /sdk_session_id.*(?:invalid|stale|expired|corrupt|mismatch)/, + /(?:invalid|stale|expired|corrupt)\s*(?:session|sdk_session)/, + ], + userMessage: () => 'Session state is invalid or corrupted.', + actionHint: () => 'The stored session state has become stale. Please start a new conversation, or retry — the session will be automatically reset.', + retryable: true, + }, + // ── Process crash (exit code) ── { category: 'PROCESS_CRASH', @@ -228,6 +262,14 @@ const ERROR_PATTERNS: ErrorPattern[] = [ }, ]; +// ── Session-related keywords for PROCESS_CRASH refinement ──── +// When a process crash stderr contains these keywords, the error is likely +// a session state issue rather than a provider configuration problem. +const SESSION_RELATED_KEYWORDS = [ + 'resume', 'session', 'sdk_session', 'conversation_id', + 'stale', 'corrupt', 'session_id', +]; + // ── Classifier ────────────────────────────────────────────────── /** @@ -260,6 +302,25 @@ export function classifyError(ctx: ErrorContext): ClassifiedError { }); if (matched) { + // Refinement: if matched as PROCESS_CRASH but stderr contains + // session-related keywords, reclassify as SESSION_STATE_ERROR + // to avoid misleading "check API Key / Base URL" prompts. + if (pattern.category === 'PROCESS_CRASH') { + const hasSessionKeywords = SESSION_RELATED_KEYWORDS.some(kw => + searchText.includes(kw), + ); + if (hasSessionKeywords) { + return { + category: 'SESSION_STATE_ERROR', + userMessage: 'Session state is invalid or corrupted.', + actionHint: 'The stored session state has become stale. Please start a new conversation, or retry — the session will be automatically reset.', + rawMessage, + providerName: ctx.providerName, + details: extraDetail || undefined, + retryable: true, + }; + } + } return buildResult(pattern, ctx, rawMessage, extraDetail); } } diff --git a/src/lib/mcp-loader.ts b/src/lib/mcp-loader.ts new file mode 100644 index 00000000..b3b2360b --- /dev/null +++ b/src/lib/mcp-loader.ts @@ -0,0 +1,136 @@ +/** + * MCP Server Loader — shared module for loading MCP server configurations. + * + * The SDK auto-loads MCP servers from settingSources (['user', 'project', 'local']). + * We only manually pass servers that need CodePilot-specific processing: + * ${...} env placeholder resolution from the CodePilot DB. + * + * This eliminates redundant config passing and reduces initialization overhead. + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import type { MCPServerConfig } from '@/types'; +import { getSetting } from '@/lib/db'; + +// ── Cache ──────────────────────────────────────────────────────────── + +interface CachedMcpConfig { + allServers: Record; + codepilotServers: Record; // Only servers with resolved ${...} placeholders + timestamp: number; +} + +const CACHE_TTL_MS = 30_000; // 30 seconds +let _cache: CachedMcpConfig | null = null; + +/** Invalidate the cache (e.g., after adding/removing a server via UI). */ +export function invalidateMcpCache(): void { + _cache = null; +} + +// ── Internal helpers ───────────────────────────────────────────────── + +function readJson(p: string): Record { + if (!fs.existsSync(p)) return {}; + try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return {}; } +} + +function loadAndMerge(): CachedMcpConfig { + // Check cache + if (_cache && Date.now() - _cache.timestamp < CACHE_TTL_MS) { + return _cache; + } + + const userConfig = readJson(path.join(os.homedir(), '.claude.json')); + const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')); + const projectMcp = readJson(path.join(process.cwd(), '.mcp.json')); + + const merged: Record = { + ...((userConfig.mcpServers || {}) as Record), + ...((settings.mcpServers || {}) as Record), + ...((projectMcp.mcpServers || {}) as Record), + }; + + // Apply persistent enabled overrides for project-level servers + const settingsOverrides = (settings.mcpServerOverrides || {}) as Record; + for (const [name, override] of Object.entries(settingsOverrides)) { + if (merged[name] && override.enabled !== undefined) { + merged[name] = { ...merged[name], enabled: override.enabled }; + } + } + + // Resolve ${...} placeholders and track which servers needed resolution + const codepilotServers: Record = {}; + + for (const [name, server] of Object.entries(merged)) { + if (server.env) { + let hasPlaceholder = false; + for (const [key, value] of Object.entries(server.env)) { + if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) { + hasPlaceholder = true; + const settingKey = value.slice(2, -1); + const resolved = getSetting(settingKey); + server.env[key] = resolved || ''; + } + } + // Only include in codepilotServers if it had placeholders + if (hasPlaceholder && server.enabled !== false) { + codepilotServers[name] = server; + } + } + } + + // Filter out persistently disabled servers from allServers + for (const [name, server] of Object.entries(merged)) { + if (server.enabled === false) { + delete merged[name]; + } + } + + _cache = { + allServers: merged, + codepilotServers, + timestamp: Date.now(), + }; + + return _cache; +} + +// ── Public API ─────────────────────────────────────────────────────── + +/** + * Load MCP servers that need CodePilot-specific processing. + * + * Returns only servers with ${...} env placeholders that were resolved + * against the CodePilot DB. Returns undefined when no such servers exist + * (the common case), letting the SDK load everything natively. + * + * Used by: route.ts, conversation-engine.ts — passed to streamClaude(). + */ +export function loadCodePilotMcpServers(): Record | undefined { + try { + const { codepilotServers } = loadAndMerge(); + return Object.keys(codepilotServers).length > 0 ? codepilotServers : undefined; + } catch { + return undefined; + } +} + +/** + * Load ALL MCP servers (for UI display in MCP Manager). + * + * Returns the full merged config from all sources with overrides applied. + * NOT intended for passing to the SDK — use loadCodePilotMcpServers() instead. + * + * Used by: MCP Manager UI, diagnostics. + */ +export function loadAllMcpServers(): Record | undefined { + try { + const { allServers } = loadAndMerge(); + return Object.keys(allServers).length > 0 ? allServers : undefined; + } catch { + return undefined; + } +} diff --git a/src/lib/onboarding-processor.ts b/src/lib/onboarding-processor.ts index 5dfb81c2..400fb1f1 100644 --- a/src/lib/onboarding-processor.ts +++ b/src/lib/onboarding-processor.ts @@ -171,6 +171,6 @@ Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaTe const state = loadState(workspacePath); state.onboardingComplete = true; state.lastCheckInDate = today; - state.schemaVersion = 3; + state.schemaVersion = 4; saveState(workspacePath, state); } diff --git a/src/lib/platform.ts b/src/lib/platform.ts index b3e90126..7f2b8762 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -9,6 +9,13 @@ const execFileAsync = promisify(execFile); export const isWindows = process.platform === 'win32'; export const isMac = process.platform === 'darwin'; +export interface RuntimeArchitectureInfo { + platform: NodeJS.Platform; + processArch: string; + hostArch: string; + runningUnderRosetta: boolean; +} + /** * Whether the given binary path requires shell execution. * On Windows, .cmd/.bat files cannot be executed directly by execFileSync. @@ -17,6 +24,40 @@ function needsShell(binPath: string): boolean { return isWindows && /\.(cmd|bat)$/i.test(binPath); } +function readSysctlValue(name: string): string | null { + try { + return execFileSync('/usr/sbin/sysctl', ['-in', name], { + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return null; + } +} + +export function getRuntimeArchitectureInfo(): RuntimeArchitectureInfo { + const processArch = process.arch; + let hostArch = processArch; + let runningUnderRosetta = false; + + if (isMac) { + const armCapable = readSysctlValue('hw.optional.arm64'); + if (armCapable === '1') { + hostArch = 'arm64'; + } + + runningUnderRosetta = readSysctlValue('sysctl.proc_translated') === '1'; + } + + return { + platform: process.platform, + processArch, + hostArch, + runningUnderRosetta, + }; +} + /** * Extra PATH directories to search for Claude CLI and other tools. */ diff --git a/src/lib/provider-catalog.ts b/src/lib/provider-catalog.ts index e8d97fa0..236ff332 100644 --- a/src/lib/provider-catalog.ts +++ b/src/lib/provider-catalog.ts @@ -251,19 +251,21 @@ export const VENDOR_PRESETS: VendorPreset[] = [ descriptionZh: 'MiniMax 编程套餐 — 中国区', protocol: 'anthropic', authStyle: 'auth_token', - baseUrl: 'https://api.minimaxi.com/anthropic/v1', + baseUrl: 'https://api.minimaxi.com/anthropic', defaultEnvOverrides: { API_TIMEOUT_MS: '3000000', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', ANTHROPIC_AUTH_TOKEN: '', - ANTHROPIC_MODEL: 'MiniMax-M2.5', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'MiniMax-M2.5', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'MiniMax-M2.5', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'MiniMax-M2.5', }, defaultModels: [ - { modelId: 'sonnet', displayName: 'MiniMax-M2.5', role: 'default' }, + { modelId: 'sonnet', upstreamModelId: 'MiniMax-M2.7', displayName: 'MiniMax-M2.7', role: 'default' }, ], + defaultRoleModels: { + default: 'MiniMax-M2.7', + sonnet: 'MiniMax-M2.7', + opus: 'MiniMax-M2.7', + haiku: 'MiniMax-M2.7', + }, fields: ['api_key'], iconKey: 'minimax', sdkProxyOnly: true, @@ -282,14 +284,16 @@ export const VENDOR_PRESETS: VendorPreset[] = [ API_TIMEOUT_MS: '3000000', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', ANTHROPIC_AUTH_TOKEN: '', - ANTHROPIC_MODEL: 'MiniMax-M2.5', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'MiniMax-M2.5', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'MiniMax-M2.5', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'MiniMax-M2.5', }, defaultModels: [ - { modelId: 'sonnet', displayName: 'MiniMax-M2.5', role: 'default' }, + { modelId: 'sonnet', upstreamModelId: 'MiniMax-M2.7', displayName: 'MiniMax-M2.7', role: 'default' }, ], + defaultRoleModels: { + default: 'MiniMax-M2.7', + sonnet: 'MiniMax-M2.7', + opus: 'MiniMax-M2.7', + haiku: 'MiniMax-M2.7', + }, fields: ['api_key'], iconKey: 'minimax', sdkProxyOnly: true, @@ -311,6 +315,32 @@ export const VENDOR_PRESETS: VendorPreset[] = [ sdkProxyOnly: true, }, + // ── Xiaomi MiMo ── + { + key: 'xiaomi-mimo', + name: 'Xiaomi MiMo', + description: 'Xiaomi MiMo Coding Plan — MiMo-V2-Pro', + descriptionZh: '小米 MiMo 编程套餐 — MiMo-V2-Pro', + protocol: 'anthropic', + authStyle: 'auth_token', + baseUrl: 'https://api.xiaomimimo.com/anthropic', + defaultEnvOverrides: { + ANTHROPIC_AUTH_TOKEN: '', + }, + defaultModels: [ + { modelId: 'sonnet', upstreamModelId: 'mimo-v2-pro', displayName: 'MiMo-V2-Pro', role: 'default' }, + ], + defaultRoleModels: { + default: 'mimo-v2-pro', + sonnet: 'mimo-v2-pro', + opus: 'mimo-v2-pro', + haiku: 'mimo-v2-pro', + }, + fields: ['api_key'], + iconKey: 'xiaomi-mimo', + sdkProxyOnly: true, + }, + // ── Aliyun Bailian ── { key: 'bailian', @@ -460,6 +490,7 @@ export function inferProtocolFromLegacy( 'minimaxi.com', 'minimax.io', // MiniMax 'volces.com', 'volcengine.com', // Volcengine 'dashscope.aliyuncs.com', // Bailian + 'xiaomimimo.com', // Xiaomi MiMo ]; const urlLower = baseUrl.toLowerCase(); if (anthropicUrls.some(u => urlLower.includes(u))) { @@ -497,8 +528,11 @@ export function inferAuthStyleFromLegacy( /** * Find a matching vendor preset for a legacy provider. * Matches by base_url first, then by provider_type. + * When `protocol` is provided, fuzzy (hostname) matching is restricted to + * presets with the same protocol to avoid misclassifying cross-protocol + * providers that share the same host (e.g. dashscope OpenAI-compatible vs Bailian Anthropic). */ -export function findPresetForLegacy(baseUrl: string, providerType: string): VendorPreset | undefined { +export function findPresetForLegacy(baseUrl: string, providerType: string, protocol?: Protocol): VendorPreset | undefined { // Exact base_url match (most specific) if (baseUrl) { const match = VENDOR_PRESETS.find(p => p.baseUrl === baseUrl); @@ -509,6 +543,7 @@ export function findPresetForLegacy(baseUrl: string, providerType: string): Vend const urlLower = baseUrl.toLowerCase(); const fuzzy = VENDOR_PRESETS.find(p => { if (!p.baseUrl) return false; + if (protocol && p.protocol !== protocol) return false; try { const presetHost = new URL(p.baseUrl).hostname; return urlLower.includes(presetHost); @@ -538,7 +573,7 @@ export function getDefaultModelsForProvider( protocol: Protocol, baseUrl: string, ): CatalogModel[] { - // Try to find a preset by base_url + // Try to find a preset by exact base_url const preset = VENDOR_PRESETS.find(p => p.baseUrl && p.baseUrl === baseUrl); if (preset) { // Preset matched — return its models even if empty (e.g. Volcengine @@ -546,6 +581,22 @@ export function getDefaultModelsForProvider( return preset.defaultModels; } + // Fuzzy match: legacy providers may have old URLs (e.g. minimaxi.com/anthropic/v1 + // before the /v1 suffix was removed). Match by domain substring against presets, + // but only when the protocol matches to avoid misclassifying custom OpenAI-compatible + // providers that share the same host (e.g. dashscope.aliyuncs.com/compatible-mode/v1). + if (baseUrl) { + const urlLower = baseUrl.toLowerCase(); + const fuzzy = VENDOR_PRESETS.find(p => { + if (!p.baseUrl || p.protocol !== protocol) return false; + try { + const presetHost = new URL(p.baseUrl).hostname; + return urlLower.includes(presetHost); + } catch { return false; } + }); + if (fuzzy) return fuzzy.defaultModels; + } + // Protocol-based defaults (only when no preset matched) if (protocol === 'anthropic' || protocol === 'openrouter' || protocol === 'bedrock' || protocol === 'vertex') { return ANTHROPIC_DEFAULT_MODELS; diff --git a/src/lib/provider-doctor.ts b/src/lib/provider-doctor.ts index b1619553..987600bc 100644 --- a/src/lib/provider-doctor.ts +++ b/src/lib/provider-doctor.ts @@ -11,8 +11,9 @@ import { findAllClaudeBinaries, isWindows, findGitBash, + getExpandedPath, } from '@/lib/platform'; -import { resolveProvider } from '@/lib/provider-resolver'; +import { resolveProvider, resolveForClaudeCode, toClaudeCodeEnv } from '@/lib/provider-resolver'; import { getAllProviders, getDefaultProviderId, @@ -23,8 +24,15 @@ import { import { getDefaultModelsForProvider, inferProtocolFromLegacy, + findPresetForLegacy, type Protocol, } from '@/lib/provider-catalog'; +import { classifyError, type ClassifiedError } from '@/lib/error-classifier'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import type { Options, SDKResultSuccess } from '@anthropic-ai/claude-agent-sdk'; +import os from 'os'; +import path from 'path'; +import fs from 'fs'; // ── Types ─────────────────────────────────────────────────────── @@ -371,6 +379,44 @@ async function runProviderProbe(): Promise { detail: `Provider ID: ${p.id}. This provider's catalog has no default models. Add at least one model via role_models_json.default or provider model settings.`, }); } + + // Check A: Third-party Anthropic provider without explicit model + if ( + protocol === 'anthropic' && + p.base_url && + p.base_url !== 'https://api.anthropic.com' && + !hasRoleDefault && + !hasEnvModel + ) { + // Check if a matched preset provides its own model names (not ANTHROPIC_DEFAULT_MODELS). + // If the preset has sdkProxyOnly or has its own models, the preset itself handles naming. + // But for generic anthropic-thirdparty or unmatched presets, warn. + const matchedPreset = findPresetForLegacy(p.base_url, p.provider_type, protocol as Protocol); + const presetHandlesModels = matchedPreset && ( + matchedPreset.key === 'anthropic-official' || + matchedPreset.defaultRoleModels?.default || + matchedPreset.defaultEnvOverrides?.ANTHROPIC_MODEL + ); + if (!presetHandlesModels) { + findings.push({ + severity: 'warn', + code: 'provider.no-explicit-model', + message: `Provider "${p.name}" uses a third-party Anthropic endpoint but relies on default model names (sonnet/opus/haiku) which may not be supported. Set an explicit model name in provider settings.`, + detail: `Provider ID: ${p.id}. Base URL: ${p.base_url}. Third-party endpoints often use different model identifiers. Configure role_models_json.default or set ANTHROPIC_MODEL in env overrides.`, + }); + } + } + + // Check B: sdkProxyOnly provider warning + const matchedPreset = findPresetForLegacy(p.base_url, p.provider_type, protocol as Protocol); + if (matchedPreset?.sdkProxyOnly) { + findings.push({ + severity: 'ok', + code: 'provider.sdk-proxy-only', + message: `Provider "${p.name}" uses an Anthropic-compatible proxy. Some Claude Code features (thinking, context1m, code mode) may not be fully supported.`, + detail: `Matched preset: ${matchedPreset.name}. This provider proxies requests through the Anthropic wire protocol but the upstream model may not support all features.`, + }); + } } // Check resolve path @@ -379,11 +425,20 @@ async function runProviderProbe(): Promise { const label = resolved.provider ? `"${resolved.provider.name}" (${resolved.protocol})` : 'environment variables'; + const isEnvMode = !resolved.provider; + const isOfficialAnthropic = resolved.provider?.base_url === 'https://api.anthropic.com'; + // Warn about missing model for non-env, non-official-Anthropic providers + const modelMissingSeverity: Severity = + !resolved.model && !isEnvMode && !isOfficialAnthropic ? 'warn' : 'ok'; findings.push({ - severity: 'ok', + severity: modelMissingSeverity, code: 'provider.resolve-ok', message: `Provider resolution path: ${label}`, - detail: resolved.model ? `Model: ${resolved.model}` : 'No model selected', + detail: resolved.model + ? `Model: ${resolved.model}` + : isEnvMode || isOfficialAnthropic + ? 'No model selected (will use provider defaults)' + : 'No model selected — third-party providers may require an explicit model name', }); } catch (err) { findings.push({ @@ -576,6 +631,232 @@ async function runNetworkProbe(): Promise { }; } +// ── Live Probe ────────────────────────────────────────────────── + +/** Last classified error from the live probe, exposed for the export route. */ +let lastLiveProbeError: ClassifiedError | null = null; + +export function getLastLiveProbeError(): ClassifiedError | null { + return lastLiveProbeError; +} + +/** Cached last diagnosis result so export doesn't re-run (especially the live probe). */ +let lastDiagnosisResult: DiagnosisResult | null = null; + +export function getLastDiagnosisResult(): DiagnosisResult | null { + return lastDiagnosisResult; +} + +export function setLastDiagnosisResult(result: DiagnosisResult): void { + lastDiagnosisResult = result; +} + +/** + * Sanitize env values: strip control chars and drop non-string values. + */ +function sanitizeEnvForProbe(env: Record): Record { + const clean: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string') { + clean[key] = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + } + } + return clean; +} + +/** + * On Windows, resolve .cmd wrapper to the underlying .js script. + */ +function resolveScriptFromCmd(cmdPath: string): string | undefined { + try { + const content = fs.readFileSync(cmdPath, 'utf-8'); + const cmdDir = path.dirname(cmdPath); + const patterns = [ + /"%~dp0\\([^"]*claude[^"]*\.js)"/i, + /%~dp0\\(\S*claude\S*\.js)/i, + /"%dp0%\\([^"]*claude[^"]*\.js)"/i, + ]; + for (const re of patterns) { + const m = content.match(re); + if (m) { + const resolved = path.normalize(path.join(cmdDir, m[1])); + if (fs.existsSync(resolved)) return resolved; + } + } + } catch { + // ignore read errors + } + return undefined; +} + +/** + * Live probe — spawns a minimal Claude Code process to verify the + * provider actually works at runtime, not just in config. + */ +async function runLiveProbe(): Promise { + const findings: Finding[] = []; + const start = Date.now(); + lastLiveProbeError = null; + + // 1. Resolve the current provider + let resolved; + try { + resolved = resolveForClaudeCode(); + } catch (err) { + findings.push({ + severity: 'warn', + code: 'live.resolve-failed', + message: 'Live probe skipped — could not resolve provider', + detail: err instanceof Error ? err.message : String(err), + }); + return { probe: 'live', severity: probeSeverity(findings), findings, durationMs: Date.now() - start }; + } + + // 2. Skip if no credentials + if (!resolved.hasCredentials) { + findings.push({ + severity: 'ok', + code: 'live.skipped', + message: 'Live probe skipped — no credentials configured', + }); + return { probe: 'live', severity: probeSeverity(findings), findings, durationMs: Date.now() - start }; + } + + // 3. Skip if no CLI binary + const claudePath = findClaudeBinary(); + if (!claudePath) { + findings.push({ + severity: 'warn', + code: 'live.no-cli', + message: 'Live probe skipped — Claude CLI binary not found', + }); + return { probe: 'live', severity: probeSeverity(findings), findings, durationMs: Date.now() - start }; + } + + // 4. Build env + const sdkEnv: Record = { ...process.env as Record }; + if (!sdkEnv.HOME) sdkEnv.HOME = os.homedir(); + if (!sdkEnv.USERPROFILE) sdkEnv.USERPROFILE = os.homedir(); + sdkEnv.PATH = getExpandedPath(); + delete sdkEnv.CLAUDECODE; + + if (process.platform === 'win32' && !process.env.CLAUDE_CODE_GIT_BASH_PATH) { + const gitBashPath = findGitBash(); + if (gitBashPath) sdkEnv.CLAUDE_CODE_GIT_BASH_PATH = gitBashPath; + } + + const resolvedEnv = toClaudeCodeEnv(sdkEnv, resolved); + Object.assign(sdkEnv, resolvedEnv); + + // 5. Build query options + const LIVE_PROBE_TIMEOUT = 15_000; + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), LIVE_PROBE_TIMEOUT); + + // Capture stderr (last 500 chars) + let stderrBuf = ''; + const stderrCallback = (data: string) => { + stderrBuf += data; + if (stderrBuf.length > 500) { + stderrBuf = stderrBuf.slice(-500); + } + }; + + const queryOptions: Options = { + cwd: os.tmpdir(), + abortController, + permissionMode: 'default', + env: sanitizeEnvForProbe(sdkEnv), + maxTurns: 1, + stderr: stderrCallback, + }; + + // Resolve executable path (handle Windows .cmd wrappers) + const ext = path.extname(claudePath).toLowerCase(); + if (ext === '.cmd' || ext === '.bat') { + const scriptPath = resolveScriptFromCmd(claudePath); + if (scriptPath) queryOptions.pathToClaudeCodeExecutable = scriptPath; + } else { + queryOptions.pathToClaudeCodeExecutable = claudePath; + } + + // 6. Run the probe + try { + const conversation = query({ + prompt: 'Say OK', + options: queryOptions, + }); + + let gotResult = false; + for await (const msg of conversation) { + if (msg.type === 'result' && 'result' in msg) { + const result = (msg as SDKResultSuccess).result || ''; + gotResult = !!result; + } + } + + clearTimeout(timeoutId); + + if (gotResult) { + findings.push({ + severity: 'ok', + code: 'live.passed', + message: 'Live test passed — model responded', + detail: resolved.provider + ? `Provider: "${resolved.provider.name}" (${resolved.protocol})` + : `Environment mode (${resolved.protocol})`, + }); + } else { + findings.push({ + severity: 'warn', + code: 'live.empty-response', + message: 'Live test completed but model returned empty response', + detail: stderrBuf ? `stderr: ${stderrBuf}` : undefined, + }); + } + } catch (err) { + clearTimeout(timeoutId); + + // Check if it was our timeout + const wasTimeout = abortController.signal.aborted; + + if (wasTimeout) { + findings.push({ + severity: 'warn', + code: 'live.timeout', + message: `Live probe timed out after ${LIVE_PROBE_TIMEOUT / 1000}s`, + detail: stderrBuf ? `stderr: ${stderrBuf}` : 'The provider may be slow or unresponsive', + }); + } else { + // Classify the error + const classified = classifyError({ + error: err, + stderr: stderrBuf, + providerName: resolved.provider?.name, + baseUrl: resolved.provider?.base_url, + }); + lastLiveProbeError = classified; + + findings.push({ + severity: 'error', + code: 'live.failed', + message: `Live test failed — ${classified.category}: ${classified.userMessage}`, + detail: [ + classified.actionHint, + stderrBuf ? `stderr: ${stderrBuf}` : '', + ].filter(Boolean).join('\n'), + }); + } + } + + return { + probe: 'live', + severity: probeSeverity(findings), + findings, + durationMs: Date.now() - start, + }; +} + // ── Repair Actions ────────────────────────────────────────────── const REPAIR_ACTIONS: RepairAction[] = [ @@ -703,6 +984,13 @@ function attachRepairsToFindings(probes: ProbeResult[]): void { /** * Run all diagnostic probes and return a unified diagnosis. */ +/** + * Run all diagnostic probes and return a unified diagnosis. + * + * The live probe (real CLI spawn) is run separately and NOT included by + * default because it takes up to 15s and would block the Doctor UI. + * Call runDiagnosisWithLiveProbe() or runLiveProbe() separately if needed. + */ export async function runDiagnosis(): Promise { const start = Date.now(); @@ -720,15 +1008,22 @@ export async function runDiagnosis(): Promise { } const repairs = computeRepairs(probes); - - // Attach repair actions to individual findings for frontend rendering attachRepairsToFindings(probes); - return { + const result: DiagnosisResult = { overallSeverity, probes, repairs, timestamp: new Date().toISOString(), durationMs: Date.now() - start, }; + + lastDiagnosisResult = result; + return result; } + +/** + * Run the live probe separately. Returns the probe result which can be + * appended to an existing diagnosis. Does NOT re-run the other probes. + */ +export { runLiveProbe }; diff --git a/src/lib/provider-resolver.ts b/src/lib/provider-resolver.ts index f8da6e9e..1511e89d 100644 --- a/src/lib/provider-resolver.ts +++ b/src/lib/provider-resolver.ts @@ -23,6 +23,7 @@ import { getActiveProvider, getSetting, getModelsForProvider, + getProviderOptions, } from './db'; // ── Resolution result ─────────────────────────────────────────── @@ -461,7 +462,13 @@ function buildResolution( process.env.ANTHROPIC_AUTH_TOKEN || getSetting('anthropic_auth_token') ); - const model = opts.model || opts.sessionModel || getSetting('default_model') || undefined; + // Read user-configured global default model — only use it if it's an env-provider model + const globalDefaultModel = getSetting('global_default_model') || undefined; + const globalDefaultProvider = getSetting('global_default_model_provider') || undefined; + // Only apply global default when it belongs to the env provider (or no provider is specified) + const applicableGlobalDefault = (globalDefaultModel && (!globalDefaultProvider || globalDefaultProvider === 'env')) + ? globalDefaultModel : undefined; + const model = opts.model || opts.sessionModel || applicableGlobalDefault || getSetting('default_model') || undefined; // Env mode uses short aliases (sonnet/opus/haiku) in the UI. // Map them to full Anthropic model IDs so toAiSdkConfig can resolve correctly. @@ -499,6 +506,17 @@ function buildResolution( const envOverrides = safeParseJson(provider.env_overrides_json || provider.extra_env); let roleModels = safeParseJson(provider.role_models_json) as RoleModels; + // Fall back to catalog preset's defaultRoleModels when DB has no role mappings. + // This ensures sdkProxyOnly providers (MiniMax, Xiaomi MiMo, etc.) get correct + // ANTHROPIC_MODEL / ANTHROPIC_DEFAULT_*_MODEL env vars even when role_models_json + // was saved as '{}' by the preset connect dialog. + if (!roleModels.default && !roleModels.sonnet) { + const preset = findPresetForLegacy(provider.base_url, provider.provider_type, protocol); + if (preset?.defaultRoleModels) { + roleModels = { ...preset.defaultRoleModels, ...roleModels }; + } + } + // Get available models: DB provider_models take priority, then catalog defaults let availableModels = getDefaultModelsForProvider(protocol, provider.base_url); try { @@ -517,12 +535,22 @@ function buildResolution( } } catch { /* provider_models table may not exist in old DBs */ } + // Read per-provider options + const providerOpts = getProviderOptions(provider.id); + + // Read global default model — only use it if it belongs to THIS provider + const globalDefaultModel = getSetting('global_default_model') || undefined; + const globalDefaultProvider = getSetting('global_default_model_provider') || undefined; + const applicableGlobalDefault = (globalDefaultModel && globalDefaultProvider === provider.id) + ? globalDefaultModel : undefined; + // Resolve model — priority: // 1. Explicit request model (opts.model) // 2. Session's stored model (opts.sessionModel) - // 3. Provider's roleModels.default (configured per-provider default, e.g. "ark-code-latest") - // 4. Global default_model setting - const requestedModel = opts.model || opts.sessionModel || roleModels.default || getSetting('default_model') || undefined; + // 3. Global default model (only if it belongs to this provider) + // 4. Provider's roleModels.default (preset default, e.g. "ark-code-latest") + // 5. Global default_model setting (legacy) + const requestedModel = opts.model || opts.sessionModel || applicableGlobalDefault || roleModels.default || getSetting('default_model') || undefined; let model = requestedModel; let upstreamModel: string | undefined; let modelDisplayName: string | undefined; @@ -591,8 +619,9 @@ function inferProtocolFromProvider(provider: ApiProvider): Protocol { } function inferAuthStyleFromProvider(provider: ApiProvider): AuthStyle { - // Check preset match first - const preset = findPresetForLegacy(provider.base_url, provider.provider_type); + // Check preset match first — pass protocol to avoid cross-protocol fuzzy mismatches + const protocol = inferProtocolFromProvider(provider); + const preset = findPresetForLegacy(provider.base_url, provider.provider_type, protocol); if (preset) return preset.authStyle; return inferAuthStyleFromLegacy(provider.provider_type, provider.extra_env); diff --git a/src/lib/update-release.ts b/src/lib/update-release.ts new file mode 100644 index 00000000..6956cc83 --- /dev/null +++ b/src/lib/update-release.ts @@ -0,0 +1,76 @@ +import type { RuntimeArchitectureInfo } from './platform'; + +export interface ReleaseAsset { + name: string; + browser_download_url: string; +} + +function normalizeArch(value: string | undefined): string { + if (!value) return ''; + const normalized = value.toLowerCase(); + if (normalized === 'aarch64') return 'arm64'; + if (normalized === 'amd64' || normalized === 'x86_64') return 'x64'; + return normalized; +} + +function scoreMacAsset(name: string, targetArch: string): number { + if (!name.endsWith('.dmg') && !name.endsWith('.zip')) return -1; + + let score = name.endsWith('.dmg') ? 40 : 20; + if (name.includes(`-${targetArch}.`)) score += 100; + else if (name.includes(targetArch)) score += 50; + + if (targetArch === 'arm64' && name.includes('universal')) score += 80; + return score; +} + +function scoreWindowsAsset(name: string): number { + return name.endsWith('.exe') ? 100 : -1; +} + +function scoreLinuxAsset(name: string, targetArch: string): number { + if (!name.endsWith('.appimage') && !name.endsWith('.deb') && !name.endsWith('.rpm')) { + return -1; + } + + let score = 0; + if (name.endsWith('.appimage')) score += 40; + else if (name.endsWith('.deb')) score += 30; + else if (name.endsWith('.rpm')) score += 20; + + if (name.includes(targetArch)) score += 100; + return score; +} + +export function selectRecommendedReleaseAsset( + assets: ReleaseAsset[], + runtime: Pick, +): ReleaseAsset | null { + const targetArch = normalizeArch(runtime.hostArch || runtime.processArch); + const normalizedAssets = assets.filter( + (asset) => typeof asset.name === 'string' && typeof asset.browser_download_url === 'string', + ); + + let best: ReleaseAsset | null = null; + let bestScore = -1; + + for (const asset of normalizedAssets) { + const name = asset.name.toLowerCase(); + let score = -1; + + if (runtime.platform === 'darwin') { + score = scoreMacAsset(name, targetArch); + } else if (runtime.platform === 'win32') { + score = scoreWindowsAsset(name); + } else if (runtime.platform === 'linux') { + score = scoreLinuxAsset(name, targetArch); + } + + if (score > bestScore) { + best = asset; + bestScore = score; + } + } + + return bestScore >= 0 ? best : null; +} diff --git a/src/lib/working-directory.ts b/src/lib/working-directory.ts new file mode 100644 index 00000000..8c7f5ff4 --- /dev/null +++ b/src/lib/working-directory.ts @@ -0,0 +1,73 @@ +import fs from 'fs'; +import os from 'os'; + +export type WorkingDirectorySource = + | 'requested' + | 'binding' + | 'session_sdk_cwd' + | 'session_working_directory' + | 'setting' + | 'home' + | 'process'; + +export interface WorkingDirectoryCandidate { + path?: string | null; + source: Exclude; +} + +export interface ResolvedWorkingDirectory { + path: string; + source: WorkingDirectorySource; + invalidCandidates: Array<{ + source: WorkingDirectoryCandidate['source']; + path: string; + }>; +} + +export function isExistingDirectory(pathValue?: string | null): pathValue is string { + if (typeof pathValue !== 'string') return false; + const trimmed = pathValue.trim(); + if (!trimmed) return false; + + try { + return fs.statSync(trimmed).isDirectory(); + } catch { + return false; + } +} + +export function resolveWorkingDirectory( + candidates: WorkingDirectoryCandidate[], +): ResolvedWorkingDirectory { + const invalidCandidates: ResolvedWorkingDirectory['invalidCandidates'] = []; + + for (const candidate of candidates) { + const value = typeof candidate.path === 'string' ? candidate.path.trim() : ''; + if (!value) continue; + + if (isExistingDirectory(value)) { + return { + path: value, + source: candidate.source, + invalidCandidates, + }; + } + + invalidCandidates.push({ source: candidate.source, path: value }); + } + + const homeDir = os.homedir(); + if (isExistingDirectory(homeDir)) { + return { + path: homeDir, + source: 'home', + invalidCandidates, + }; + } + + return { + path: process.cwd(), + source: 'process', + invalidCandidates, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 424f08ff..33d1cda7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -249,10 +249,14 @@ export interface UpdateProviderRequest { sort_order?: number; } -/** Per-provider options stored in options_json */ +/** Provider options stored in options_json (per-provider) or settings (global) */ export interface ProviderOptions { thinking_mode?: 'adaptive' | 'enabled' | 'disabled'; context_1m?: boolean; + /** Global default model ID — used for new sessions */ + default_model?: string; + /** Global default model's provider ID — which provider the default model belongs to */ + default_model_provider?: string; } export interface ProvidersResponse { @@ -536,6 +540,8 @@ export interface MCPServerConfig { type?: 'stdio' | 'sse' | 'http'; url?: string; headers?: Record; + /** Persistent enable/disable. undefined or true = enabled; false = disabled. */ + enabled?: boolean; } export interface MCPConfig { @@ -1080,3 +1086,27 @@ export interface GitWorktree { bare: boolean; dirty: boolean; } + +// ========================================== +// WeChat Bridge Types +// ========================================== + +export interface WeixinAccount { + accountId: string; + userId: string; + baseUrl: string; + cdnBaseUrl: string; + token: string; + name: string; + enabled: boolean; + lastLoginAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface WeixinContextTokenRecord { + accountId: string; + peerUserId: string; + contextToken: string; + updatedAt: string; +} diff --git a/tsconfig.json b/tsconfig.json index d3205693..edb04df8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,15 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules", "electron", "dist-electron", "scripts", "release", "apps", "packages"] + "exclude": [ + "node_modules", + "electron", + "dist-electron", + "scripts", + "release", + "apps", + "packages", + ".claude", + "资料" + ] } diff --git "a/\350\265\204\346\226\231/weixin-openclaw-cli/openclaw-weixin-cli-1.0.2.tgz" "b/\350\265\204\346\226\231/weixin-openclaw-cli/openclaw-weixin-cli-1.0.2.tgz" new file mode 100644 index 00000000..bf0a3ae4 Binary files /dev/null and "b/\350\265\204\346\226\231/weixin-openclaw-cli/openclaw-weixin-cli-1.0.2.tgz" differ diff --git "a/\350\265\204\346\226\231/weixin-openclaw-cli/package/LICENSE" "b/\350\265\204\346\226\231/weixin-openclaw-cli/package/LICENSE" new file mode 100644 index 00000000..6fb845f1 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-cli/package/LICENSE" @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tencent Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git "a/\350\265\204\346\226\231/weixin-openclaw-cli/package/cli.mjs" "b/\350\265\204\346\226\231/weixin-openclaw-cli/package/cli.mjs" new file mode 100755 index 00000000..217dda67 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-cli/package/cli.mjs" @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +import { execSync, spawnSync } from "node:child_process"; + +const PLUGIN_SPEC = "@tencent-weixin/openclaw-weixin"; +const CHANNEL_ID = "openclaw-weixin"; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function log(msg) { + console.log(`\x1b[36m[openclaw-weixin]\x1b[0m ${msg}`); +} + +function error(msg) { + console.error(`\x1b[31m[openclaw-weixin]\x1b[0m ${msg}`); +} + +function run(cmd, { silent = true } = {}) { + const stdio = silent ? ["pipe", "pipe", "pipe"] : "inherit"; + const result = spawnSync(cmd, { shell: true, stdio }); + if (result.status !== 0) { + const err = new Error(`Command failed with exit code ${result.status}: ${cmd}`); + err.stderr = silent ? (result.stderr || "").toString() : ""; + throw err; + } + return silent ? (result.stdout || "").toString().trim() : ""; +} + +function which(bin) { + try { + return execSync(`which ${bin}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); + } catch { + return null; + } +} + +// ── commands ───────────────────────────────────────────────────────────────── + +function install() { + // 1. Check openclaw is installed + if (!which("openclaw")) { + error("未找到 openclaw,请先安装:"); + console.log(" npm install -g openclaw"); + console.log(" 详见 https://docs.openclaw.ai/install"); + process.exit(1); + } + log("已找到本地安装的 openclaw"); + + // 2. Install plugin via openclaw + log("正在安装插件..."); + try { + const installOut = run(`openclaw plugins install "${PLUGIN_SPEC}"`); + if (installOut) log(installOut); + } catch (installErr) { + if (installErr.stderr && installErr.stderr.includes("already exists")) { + log("检测到本地已安装,正在更新..."); + try { + const updateOut = run(`openclaw plugins update "${CHANNEL_ID}"`); + if (updateOut) log(updateOut); + } catch (updateErr) { + error("插件更新失败,请手动执行:"); + if (updateErr.stderr) console.error(updateErr.stderr); + console.log(` openclaw plugins update "${CHANNEL_ID}"`); + process.exit(1); + } + } else { + error("插件安装失败,请手动执行:"); + if (installErr.stderr) console.error(installErr.stderr); + console.log(` openclaw plugins install "${PLUGIN_SPEC}"`); + process.exit(1); + } + } + + // 3. Login (interactive QR scan) + log("插件就绪,开始首次连接..."); + try { + run(`openclaw channels login --channel ${CHANNEL_ID}`, { silent: false }); + } catch { + console.log(); + error("首次连接未完成,可稍后手动重试:"); + console.log(` openclaw channels login --channel ${CHANNEL_ID}`); + } + + // 4. Restart gateway so it picks up the new account + log("正在重启 OpenClaw Gateway..."); + try { + run(`openclaw gateway restart`, { silent: false }); + } catch { + error("重启失败,可手动执行:"); + console.log(` openclaw gateway restart`); + } + +} + +function help() { + console.log(` + 用法: npx -y @tencent-weixin/openclaw-weixin-cli <命令> + + 命令: + install 安装微信插件并扫码连接 + help 显示帮助信息 +`); +} + +// ── main ───────────────────────────────────────────────────────────────────── + +const command = process.argv[2]; + +switch (command) { + case "install": + install(); + break; + case "help": + case "--help": + case "-h": + help(); + break; + default: + if (command) { + error(`未知命令: ${command}`); + } + help(); + process.exit(command ? 1 : 0); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-cli/package/package.json" "b/\350\265\204\346\226\231/weixin-openclaw-cli/package/package.json" new file mode 100644 index 00000000..3cfad757 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-cli/package/package.json" @@ -0,0 +1,17 @@ +{ + "name": "@tencent-weixin/openclaw-weixin-cli", + "version": "1.0.2", + "description": "Lightweight installer for the OpenClaw Weixin channel plugin", + "license": "MIT", + "author": "Tencent", + "type": "module", + "bin": { + "weixin-installer": "./cli.mjs" + }, + "files": [ + "cli.mjs" + ], + "engines": { + "node": ">=22" + } +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/openclaw-weixin-1.0.2.tgz" "b/\350\265\204\346\226\231/weixin-openclaw-package/openclaw-weixin-1.0.2.tgz" new file mode 100644 index 00000000..50141bc2 Binary files /dev/null and "b/\350\265\204\346\226\231/weixin-openclaw-package/openclaw-weixin-1.0.2.tgz" differ diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/CHANGELOG.md" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/CHANGELOG.md" new file mode 100644 index 00000000..b13322f0 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/CHANGELOG.md" @@ -0,0 +1,5 @@ +# Changelog + +[简体中文](CHANGELOG.zh_CN.md) + +This project follows the [Keep a Changelog](https://keepachangelog.com/) format. diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/CHANGELOG.zh_CN.md" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/CHANGELOG.zh_CN.md" new file mode 100644 index 00000000..3c9f87af --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/CHANGELOG.zh_CN.md" @@ -0,0 +1,3 @@ +# 变更日志 + +本项目遵循 [Keep a Changelog](https://keepachangelog.com/) 格式。 diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/LICENSE" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/LICENSE" new file mode 100644 index 00000000..6fb845f1 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/LICENSE" @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tencent Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/README.md" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/README.md" new file mode 100644 index 00000000..4d3dca50 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/README.md" @@ -0,0 +1,271 @@ +# WeChat + +[简体中文](./README.zh_CN.md) + +OpenClaw's WeChat channel plugin, supporting login authorization via QR code scanning. + +## Prerequisites + +[OpenClaw](https://docs.openclaw.ai/install) must be installed (the `openclaw` CLI needs to be available). + +## Quick Install + +```bash +npx -y @tencent-weixin/openclaw-weixin-cli install +``` + +## Manual Installation + +If the quick install doesn't work, follow these steps manually: + +### 1. Install the plugin + +```bash +openclaw plugins install "@tencent-weixin/openclaw-weixin" +``` + +### 2. Enable the plugin + +```bash +openclaw config set plugins.entries.openclaw-weixin.enabled true +``` + +### 3. QR code login + +```bash +openclaw channels login --channel openclaw-weixin +``` + +A QR code will appear in the terminal. Scan it with your phone and confirm the authorization. Once confirmed, the login credentials will be saved locally automatically — no further action is needed. + +### 4. Restart the gateway + +```bash +openclaw gateway restart +``` + +## Adding More WeChat Accounts + +```bash +openclaw channels login --channel openclaw-weixin +``` + +Each QR code login creates a new account entry, supporting multiple WeChat accounts online simultaneously. + +## Multi-Account Context Isolation + +By default, all channels share the same AI conversation context. To isolate conversation context for each WeChat account: + +```bash +openclaw config set agents.mode per-channel-per-peer +``` + +This gives each "WeChat account + message sender" combination its own independent AI memory, preventing context cross-talk between accounts. + +## Backend API Protocol + +This plugin communicates with the backend gateway via HTTP JSON API. Developers integrating with their own backend need to implement the following interfaces. + +All endpoints use `POST` with JSON request and response bodies. Common request headers: + +| Header | Description | +|--------|-------------| +| `Content-Type` | `application/json` | +| `AuthorizationType` | Fixed value `ilink_bot_token` | +| `Authorization` | `Bearer ` (obtained after login) | +| `X-WECHAT-UIN` | Base64-encoded random uint32 | + +### Endpoint List + +| Endpoint | Path | Description | +|----------|------|-------------| +| getUpdates | `getupdates` | Long-poll for new messages | +| sendMessage | `sendmessage` | Send a message (text/image/video/file) | +| getUploadUrl | `getuploadurl` | Get CDN upload pre-signed URL | +| getConfig | `getconfig` | Get account config (typing ticket, etc.) | +| sendTyping | `sendtyping` | Send/cancel typing status indicator | + +### getUpdates + +Long-polling endpoint. The server responds when new messages arrive or on timeout. + +**Request body:** + +```json +{ + "get_updates_buf": "" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `get_updates_buf` | `string` | Sync cursor from the previous response; empty string for the first request | + +**Response body:** + +```json +{ + "ret": 0, + "msgs": [...], + "get_updates_buf": "", + "longpolling_timeout_ms": 35000 +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `ret` | `number` | Return code, `0` = success | +| `errcode` | `number?` | Error code (e.g., `-14` = session timeout) | +| `errmsg` | `string?` | Error description | +| `msgs` | `WeixinMessage[]` | Message list (structure below) | +| `get_updates_buf` | `string` | New sync cursor to pass in the next request | +| `longpolling_timeout_ms` | `number?` | Server-suggested long-poll timeout for the next request (ms) | + +### sendMessage + +Send a message to a user. + +**Request body:** + +```json +{ + "msg": { + "to_user_id": "", + "context_token": "", + "item_list": [ + { + "type": 1, + "text_item": { "text": "Hello" } + } + ] + } +} +``` + +### getUploadUrl + +Get CDN upload pre-signed parameters. Call this endpoint before uploading a file to obtain `upload_param` and `thumb_upload_param`. + +**Request body:** + +```json +{ + "filekey": "", + "media_type": 1, + "to_user_id": "", + "rawsize": 12345, + "rawfilemd5": "", + "filesize": 12352, + "thumb_rawsize": 1024, + "thumb_rawfilemd5": "<thumbnail plaintext MD5>", + "thumb_filesize": 1040 +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `media_type` | `number` | `1` = IMAGE, `2` = VIDEO, `3` = FILE | +| `rawsize` | `number` | Original file plaintext size | +| `rawfilemd5` | `string` | Original file plaintext MD5 | +| `filesize` | `number` | Ciphertext size after AES-128-ECB encryption | +| `thumb_rawsize` | `number?` | Thumbnail plaintext size (required for IMAGE/VIDEO) | +| `thumb_rawfilemd5` | `string?` | Thumbnail plaintext MD5 (required for IMAGE/VIDEO) | +| `thumb_filesize` | `number?` | Thumbnail ciphertext size (required for IMAGE/VIDEO) | + +**Response body:** + +```json +{ + "upload_param": "<original image upload encrypted parameters>", + "thumb_upload_param": "<thumbnail upload encrypted parameters>" +} +``` + +### getConfig + +Get account configuration, including the typing ticket. + +**Request body:** + +```json +{ + "ilink_user_id": "<user ID>", + "context_token": "<optional, conversation context token>" +} +``` + +**Response body:** + +```json +{ + "ret": 0, + "typing_ticket": "<base64-encoded typing ticket>" +} +``` + +### sendTyping + +Send or cancel the typing status indicator. + +**Request body:** + +```json +{ + "ilink_user_id": "<user ID>", + "typing_ticket": "<obtained from getConfig>", + "status": 1 +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `status` | `number` | `1` = typing, `2` = cancel typing | + +### Message Structure + +#### WeixinMessage + +| Field | Type | Description | +|-------|------|-------------| +| `seq` | `number?` | Message sequence number | +| `message_id` | `number?` | Unique message ID | +| `from_user_id` | `string?` | Sender ID | +| `to_user_id` | `string?` | Receiver ID | +| `create_time_ms` | `number?` | Creation timestamp (ms) | +| `session_id` | `string?` | Session ID | +| `message_type` | `number?` | `1` = USER, `2` = BOT | +| `message_state` | `number?` | `0` = NEW, `1` = GENERATING, `2` = FINISH | +| `item_list` | `MessageItem[]?` | Message content list | +| `context_token` | `string?` | Conversation context token, must be passed back when replying | + +#### MessageItem + +| Field | Type | Description | +|-------|------|-------------| +| `type` | `number` | `1` TEXT, `2` IMAGE, `3` VOICE, `4` FILE, `5` VIDEO | +| `text_item` | `{ text: string }?` | Text content | +| `image_item` | `ImageItem?` | Image (with CDN reference and AES key) | +| `voice_item` | `VoiceItem?` | Voice (SILK encoded) | +| `file_item` | `FileItem?` | File attachment | +| `video_item` | `VideoItem?` | Video | +| `ref_msg` | `RefMessage?` | Referenced message | + +#### CDN Media Reference (CDNMedia) + +All media types (image/voice/file/video) are transferred via CDN using AES-128-ECB encryption: + +| Field | Type | Description | +|-------|------|-------------| +| `encrypt_query_param` | `string?` | Encrypted parameters for CDN download/upload | +| `aes_key` | `string?` | Base64-encoded AES-128 key | + +### CDN Upload Flow + +1. Calculate the file's plaintext size, MD5, and ciphertext size after AES-128-ECB encryption +2. If a thumbnail is needed (image/video), calculate the thumbnail's plaintext and ciphertext parameters as well +3. Call `getUploadUrl` to get `upload_param` (and `thumb_upload_param`) +4. Encrypt the file content with AES-128-ECB and PUT upload to the CDN URL +5. Encrypt and upload the thumbnail in the same way +6. Use the returned `encrypt_query_param` to construct a `CDNMedia` reference, include it in the `MessageItem`, and send + +> For complete type definitions, see [`src/api/types.ts`](src/api/types.ts). For API call implementations, see [`src/api/api.ts`](src/api/api.ts). diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/README.zh_CN.md" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/README.zh_CN.md" new file mode 100644 index 00000000..576284fb --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/README.zh_CN.md" @@ -0,0 +1,271 @@ +# 微信 + +[English](./README.md) + +OpenClaw 的微信渠道插件,支持通过扫码完成登录授权。 + +## 前提条件 + +已安装 [OpenClaw](https://docs.openclaw.ai/install)(需要 `openclaw` CLI 可用)。 + +## 一键安装 + +```bash +npx -y @tencent-weixin/openclaw-weixin-cli install +``` + +## 手动安装 + +如果一键安装不适用,可以按以下步骤手动操作: + +### 1. 安装插件 + +```bash +openclaw plugins install "@tencent-weixin/openclaw-weixin" +``` + +### 2. 启用插件 + +```bash +openclaw config set plugins.entries.openclaw-weixin.enabled true +``` + +### 3. 扫码登录 + +```bash +openclaw channels login --channel openclaw-weixin +``` + +终端会显示一个二维码,用手机扫码并在手机上确认授权。确认后,登录凭证会自动保存到本地,无需额外操作。 + +### 4. 重启 gateway + +```bash +openclaw gateway restart +``` + +## 添加更多微信账号 + +```bash +openclaw channels login --channel openclaw-weixin +``` + +每次扫码登录都会创建一个新的账号条目,支持多个微信号同时在线。 + +## 多账号上下文隔离 + +默认情况下,所有渠道的 AI 会话共享同一个上下文。如果希望每个微信账号的对话上下文相互隔离: + +```bash +openclaw config set agents.mode per-channel-per-peer +``` + +这样每个「微信账号 + 发消息用户」组合都会拥有独立的 AI 记忆,账号之间不会串台。 + +## 后端 API 协议 + +本插件通过 HTTP JSON API 与后端网关通信。二次开发者若需对接自有后端,需实现以下接口。 + +所有接口均为 `POST`,请求和响应均为 JSON。通用请求头: + +| Header | 说明 | +|--------|------| +| `Content-Type` | `application/json` | +| `AuthorizationType` | 固定值 `ilink_bot_token` | +| `Authorization` | `Bearer <token>`(登录后获取) | +| `X-WECHAT-UIN` | 随机 uint32 的 base64 编码 | + +### 接口列表 + +| 接口 | 路径 | 说明 | +|------|------|------| +| getUpdates | `getupdates` | 长轮询获取新消息 | +| sendMessage | `sendmessage` | 发送消息(文本/图片/视频/文件) | +| getUploadUrl | `getuploadurl` | 获取 CDN 上传预签名 URL | +| getConfig | `getconfig` | 获取账号配置(typing ticket 等) | +| sendTyping | `sendtyping` | 发送/取消输入状态指示 | + +### getUpdates + +长轮询接口。服务端在有新消息或超时后返回。 + +**请求体:** + +```json +{ + "get_updates_buf": "" +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `get_updates_buf` | `string` | 上次响应返回的同步游标,首次请求传空字符串 | + +**响应体:** + +```json +{ + "ret": 0, + "msgs": [...], + "get_updates_buf": "<新游标>", + "longpolling_timeout_ms": 35000 +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ret` | `number` | 返回码,`0` = 成功 | +| `errcode` | `number?` | 错误码(如 `-14` = 会话超时) | +| `errmsg` | `string?` | 错误描述 | +| `msgs` | `WeixinMessage[]` | 消息列表(结构见下方) | +| `get_updates_buf` | `string` | 新的同步游标,下次请求时回传 | +| `longpolling_timeout_ms` | `number?` | 服务端建议的下次长轮询超时(ms) | + +### sendMessage + +发送一条消息给用户。 + +**请求体:** + +```json +{ + "msg": { + "to_user_id": "<目标用户 ID>", + "context_token": "<会话上下文令牌>", + "item_list": [ + { + "type": 1, + "text_item": { "text": "你好" } + } + ] + } +} +``` + +### getUploadUrl + +获取 CDN 上传预签名参数。上传文件前需先调用此接口获取 `upload_param` 和 `thumb_upload_param`。 + +**请求体:** + +```json +{ + "filekey": "<文件标识>", + "media_type": 1, + "to_user_id": "<目标用户 ID>", + "rawsize": 12345, + "rawfilemd5": "<明文 MD5>", + "filesize": 12352, + "thumb_rawsize": 1024, + "thumb_rawfilemd5": "<缩略图明文 MD5>", + "thumb_filesize": 1040 +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `media_type` | `number` | `1` = IMAGE, `2` = VIDEO, `3` = FILE | +| `rawsize` | `number` | 原文件明文大小 | +| `rawfilemd5` | `string` | 原文件明文 MD5 | +| `filesize` | `number` | AES-128-ECB 加密后的密文大小 | +| `thumb_rawsize` | `number?` | 缩略图明文大小(IMAGE/VIDEO 时必填) | +| `thumb_rawfilemd5` | `string?` | 缩略图明文 MD5(IMAGE/VIDEO 时必填) | +| `thumb_filesize` | `number?` | 缩略图密文大小(IMAGE/VIDEO 时必填) | + +**响应体:** + +```json +{ + "upload_param": "<原图上传加密参数>", + "thumb_upload_param": "<缩略图上传加密参数>" +} +``` + +### getConfig + +获取账号配置,包括 typing ticket。 + +**请求体:** + +```json +{ + "ilink_user_id": "<用户 ID>", + "context_token": "<可选,会话上下文令牌>" +} +``` + +**响应体:** + +```json +{ + "ret": 0, + "typing_ticket": "<base64 编码的 typing ticket>" +} +``` + +### sendTyping + +发送或取消输入状态指示。 + +**请求体:** + +```json +{ + "ilink_user_id": "<用户 ID>", + "typing_ticket": "<从 getConfig 获取>", + "status": 1 +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `status` | `number` | `1` = 正在输入,`2` = 取消输入 | + +### 消息结构 + +#### WeixinMessage + +| 字段 | 类型 | 说明 | +|------|------|------| +| `seq` | `number?` | 消息序列号 | +| `message_id` | `number?` | 消息唯一 ID | +| `from_user_id` | `string?` | 发送者 ID | +| `to_user_id` | `string?` | 接收者 ID | +| `create_time_ms` | `number?` | 创建时间戳(ms) | +| `session_id` | `string?` | 会话 ID | +| `message_type` | `number?` | `1` = USER, `2` = BOT | +| `message_state` | `number?` | `0` = NEW, `1` = GENERATING, `2` = FINISH | +| `item_list` | `MessageItem[]?` | 消息内容列表 | +| `context_token` | `string?` | 会话上下文令牌,回复时需回传 | + +#### MessageItem + +| 字段 | 类型 | 说明 | +|------|------|------| +| `type` | `number` | `1` TEXT, `2` IMAGE, `3` VOICE, `4` FILE, `5` VIDEO | +| `text_item` | `{ text: string }?` | 文本内容 | +| `image_item` | `ImageItem?` | 图片(含 CDN 引用和 AES 密钥) | +| `voice_item` | `VoiceItem?` | 语音(SILK 编码) | +| `file_item` | `FileItem?` | 文件附件 | +| `video_item` | `VideoItem?` | 视频 | +| `ref_msg` | `RefMessage?` | 引用消息 | + +#### CDN 媒体引用 (CDNMedia) + +所有媒体类型(图片/语音/文件/视频)通过 CDN 传输,使用 AES-128-ECB 加密: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `encrypt_query_param` | `string?` | CDN 下载/上传的加密参数 | +| `aes_key` | `string?` | base64 编码的 AES-128 密钥 | + +### CDN 上传流程 + +1. 计算文件明文大小、MD5,以及 AES-128-ECB 加密后的密文大小 +2. 如需缩略图(图片/视频),同样计算缩略图的明文和密文参数 +3. 调用 `getUploadUrl` 获取 `upload_param`(和 `thumb_upload_param`) +4. 使用 AES-128-ECB 加密文件内容,PUT 上传到 CDN URL +5. 缩略图同理加密并上传 +6. 使用返回的 `encrypt_query_param` 构造 `CDNMedia` 引用,放入 `MessageItem` 发送 + +> 完整的类型定义见 [`src/api/types.ts`](src/api/types.ts),API 调用实现见 [`src/api/api.ts`](src/api/api.ts)。 diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/index.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/index.ts" new file mode 100644 index 00000000..046a8210 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/index.ts" @@ -0,0 +1,27 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { buildChannelConfigSchema } from "openclaw/plugin-sdk"; + +import { weixinPlugin } from "./src/channel.js"; +import { WeixinConfigSchema } from "./src/config/config-schema.js"; +import { registerWeixinCli } from "./src/log-upload.js"; +import { setWeixinRuntime } from "./src/runtime.js"; + +const plugin = { + id: "openclaw-weixin", + name: "Weixin", + description: "Weixin channel (getUpdates long-poll + sendMessage)", + configSchema: buildChannelConfigSchema(WeixinConfigSchema), + register(api: OpenClawPluginApi) { + if (!api?.runtime) { + throw new Error("[weixin] api.runtime is not available in register()"); + } + setWeixinRuntime(api.runtime); + + api.registerChannel({ plugin: weixinPlugin }); + api.registerCli(({ program, config }) => registerWeixinCli({ program, config }), { + commands: ["openclaw-weixin"], + }); + }, +}; + +export default plugin; diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/openclaw.plugin.json" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/openclaw.plugin.json" new file mode 100644 index 00000000..88e6cf53 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/openclaw.plugin.json" @@ -0,0 +1,9 @@ +{ + "id": "openclaw-weixin", + "channels": ["openclaw-weixin"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/package.json" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/package.json" new file mode 100644 index 00000000..81257c62 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/package.json" @@ -0,0 +1,55 @@ +{ + "name": "@tencent-weixin/openclaw-weixin", + "version": "1.0.2", + "description": "OpenClaw Weixin channel", + "license": "MIT", + "author": "Tencent", + "type": "module", + "files": [ + "src/", + "!src/**/*.test.ts", + "!src/**/node_modules/", + "index.ts", + "openclaw.plugin.json", + "README.md", + "README.zh_CN.md", + "CHANGELOG.md", + "CHANGELOG.zh_CN.md" + ], + "scripts": { + "test": "vitest run --coverage", + "typecheck": "tsc --noEmit", + "build": "tsc", + "prepublishOnly": "npm run typecheck && npm run build" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "qrcode-terminal": "0.12.0", + "zod": "4.3.6" + }, + "devDependencies": { + "@vitest/coverage-v8": "^3.1.0", + "typescript": "^5.8.0", + "vitest": "^3.1.0" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "openclaw-weixin", + "label": "openclaw-weixin", + "selectionLabel": "openclaw-weixin", + "docsPath": "/channels/openclaw-weixin", + "docsLabel": "openclaw-weixin", + "blurb": "Weixin channel", + "order": 75 + }, + "install": { + "npmSpec": "@tencent-weixin/openclaw-weixin", + "defaultChoice": "npm" + } + } +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/api.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/api.ts" new file mode 100644 index 00000000..8e2a6c76 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/api.ts" @@ -0,0 +1,240 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { loadConfigRouteTag } from "../auth/accounts.js"; +import { logger } from "../util/logger.js"; +import { redactBody, redactUrl } from "../util/redact.js"; + +import type { + BaseInfo, + GetUploadUrlReq, + GetUploadUrlResp, + GetUpdatesReq, + GetUpdatesResp, + SendMessageReq, + SendTypingReq, + GetConfigResp, +} from "./types.js"; + +export type WeixinApiOptions = { + baseUrl: string; + token?: string; + timeoutMs?: number; + /** Long-poll timeout for getUpdates (server may hold the request up to this). */ + longPollTimeoutMs?: number; +}; + +// --------------------------------------------------------------------------- +// BaseInfo — attached to every outgoing CGI request +// --------------------------------------------------------------------------- + +function readChannelVersion(): string { + try { + const dir = path.dirname(fileURLToPath(import.meta.url)); + const pkgPath = path.resolve(dir, "..", "..", "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string }; + return pkg.version ?? "unknown"; + } catch { + return "unknown"; + } +} + +const CHANNEL_VERSION = readChannelVersion(); + +/** Build the `base_info` payload included in every API request. */ +export function buildBaseInfo(): BaseInfo { + return { channel_version: CHANNEL_VERSION }; +} + +/** Default timeout for long-poll getUpdates requests. */ +const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000; +/** Default timeout for regular API requests (sendMessage, getUploadUrl). */ +const DEFAULT_API_TIMEOUT_MS = 15_000; +/** Default timeout for lightweight API requests (getConfig, sendTyping). */ +const DEFAULT_CONFIG_TIMEOUT_MS = 10_000; + +function ensureTrailingSlash(url: string): string { + return url.endsWith("/") ? url : `${url}/`; +} + +/** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */ +function randomWechatUin(): string { + const uint32 = crypto.randomBytes(4).readUInt32BE(0); + return Buffer.from(String(uint32), "utf-8").toString("base64"); +} + +function buildHeaders(opts: { token?: string; body: string }): Record<string, string> { + const headers: Record<string, string> = { + "Content-Type": "application/json", + AuthorizationType: "ilink_bot_token", + "Content-Length": String(Buffer.byteLength(opts.body, "utf-8")), + "X-WECHAT-UIN": randomWechatUin(), + }; + if (opts.token?.trim()) { + headers.Authorization = `Bearer ${opts.token.trim()}`; + } + const routeTag = loadConfigRouteTag(); + if (routeTag) { + headers.SKRouteTag = routeTag; + } + logger.debug( + `requestHeaders: ${JSON.stringify({ ...headers, Authorization: headers.Authorization ? "Bearer ***" : undefined })}`, + ); + return headers; +} + +/** + * Common fetch wrapper: POST JSON to a Weixin API endpoint with timeout + abort. + * Returns the raw response text on success; throws on HTTP error or timeout. + */ +async function apiFetch(params: { + baseUrl: string; + endpoint: string; + body: string; + token?: string; + timeoutMs: number; + label: string; +}): Promise<string> { + const base = ensureTrailingSlash(params.baseUrl); + const url = new URL(params.endpoint, base); + const hdrs = buildHeaders({ token: params.token, body: params.body }); + logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`); + + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), params.timeoutMs); + try { + const res = await fetch(url.toString(), { + method: "POST", + headers: hdrs, + body: params.body, + signal: controller.signal, + }); + clearTimeout(t); + const rawText = await res.text(); + logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`); + if (!res.ok) { + throw new Error(`${params.label} ${res.status}: ${rawText}`); + } + return rawText; + } catch (err) { + clearTimeout(t); + throw err; + } +} + +/** + * Long-poll getUpdates. Server should hold the request until new messages or timeout. + * + * On client-side timeout (no server response within timeoutMs), returns an empty response + * with ret=0 so the caller can simply retry. This is normal for long-poll. + */ +export async function getUpdates( + params: GetUpdatesReq & { + baseUrl: string; + token?: string; + timeoutMs?: number; + }, +): Promise<GetUpdatesResp> { + const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS; + try { + const rawText = await apiFetch({ + baseUrl: params.baseUrl, + endpoint: "ilink/bot/getupdates", + body: JSON.stringify({ + get_updates_buf: params.get_updates_buf ?? "", + base_info: buildBaseInfo(), + }), + token: params.token, + timeoutMs: timeout, + label: "getUpdates", + }); + const resp: GetUpdatesResp = JSON.parse(rawText); + return resp; + } catch (err) { + // Long-poll timeout is normal; return empty response so caller can retry + if (err instanceof Error && err.name === "AbortError") { + logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`); + return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf }; + } + throw err; + } +} + +/** Get a pre-signed CDN upload URL for a file. */ +export async function getUploadUrl( + params: GetUploadUrlReq & WeixinApiOptions, +): Promise<GetUploadUrlResp> { + const rawText = await apiFetch({ + baseUrl: params.baseUrl, + endpoint: "ilink/bot/getuploadurl", + body: JSON.stringify({ + filekey: params.filekey, + media_type: params.media_type, + to_user_id: params.to_user_id, + rawsize: params.rawsize, + rawfilemd5: params.rawfilemd5, + filesize: params.filesize, + thumb_rawsize: params.thumb_rawsize, + thumb_rawfilemd5: params.thumb_rawfilemd5, + thumb_filesize: params.thumb_filesize, + no_need_thumb: params.no_need_thumb, + aeskey: params.aeskey, + base_info: buildBaseInfo(), + }), + token: params.token, + timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS, + label: "getUploadUrl", + }); + const resp: GetUploadUrlResp = JSON.parse(rawText); + return resp; +} + +/** Send a single message downstream. */ +export async function sendMessage( + params: WeixinApiOptions & { body: SendMessageReq }, +): Promise<void> { + await apiFetch({ + baseUrl: params.baseUrl, + endpoint: "ilink/bot/sendmessage", + body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }), + token: params.token, + timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS, + label: "sendMessage", + }); +} + +/** Fetch bot config (includes typing_ticket) for a given user. */ +export async function getConfig( + params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string }, +): Promise<GetConfigResp> { + const rawText = await apiFetch({ + baseUrl: params.baseUrl, + endpoint: "ilink/bot/getconfig", + body: JSON.stringify({ + ilink_user_id: params.ilinkUserId, + context_token: params.contextToken, + base_info: buildBaseInfo(), + }), + token: params.token, + timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS, + label: "getConfig", + }); + const resp: GetConfigResp = JSON.parse(rawText); + return resp; +} + +/** Send a typing indicator to a user. */ +export async function sendTyping( + params: WeixinApiOptions & { body: SendTypingReq }, +): Promise<void> { + await apiFetch({ + baseUrl: params.baseUrl, + endpoint: "ilink/bot/sendtyping", + body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }), + token: params.token, + timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS, + label: "sendTyping", + }); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/config-cache.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/config-cache.ts" new file mode 100644 index 00000000..cad9d0f9 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/config-cache.ts" @@ -0,0 +1,79 @@ +import { getConfig } from "./api.js"; + +/** Subset of getConfig fields that we actually need; add new fields here as needed. */ +export interface CachedConfig { + typingTicket: string; +} + +const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const CONFIG_CACHE_INITIAL_RETRY_MS = 2_000; +const CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000; + +interface ConfigCacheEntry { + config: CachedConfig; + everSucceeded: boolean; + nextFetchAt: number; + retryDelayMs: number; +} + +/** + * Per-user getConfig cache with periodic random refresh (within 24h) and + * exponential-backoff retry (up to 1h) on failure. + */ +export class WeixinConfigManager { + private cache = new Map<string, ConfigCacheEntry>(); + + constructor( + private apiOpts: { baseUrl: string; token?: string }, + private log: (msg: string) => void, + ) {} + + async getForUser(userId: string, contextToken?: string): Promise<CachedConfig> { + const now = Date.now(); + const entry = this.cache.get(userId); + const shouldFetch = !entry || now >= entry.nextFetchAt; + + if (shouldFetch) { + let fetchOk = false; + try { + const resp = await getConfig({ + baseUrl: this.apiOpts.baseUrl, + token: this.apiOpts.token, + ilinkUserId: userId, + contextToken, + }); + if (resp.ret === 0) { + this.cache.set(userId, { + config: { typingTicket: resp.typing_ticket ?? "" }, + everSucceeded: true, + nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS, + retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS, + }); + this.log( + `[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`, + ); + fetchOk = true; + } + } catch (err) { + this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`); + } + if (!fetchOk) { + const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS; + const nextDelay = Math.min(prevDelay * 2, CONFIG_CACHE_MAX_RETRY_MS); + if (entry) { + entry.nextFetchAt = now + nextDelay; + entry.retryDelayMs = nextDelay; + } else { + this.cache.set(userId, { + config: { typingTicket: "" }, + everSucceeded: false, + nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS, + retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS, + }); + } + } + } + + return this.cache.get(userId)?.config ?? { typingTicket: "" }; + } +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/session-guard.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/session-guard.ts" new file mode 100644 index 00000000..e31094cb --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/session-guard.ts" @@ -0,0 +1,58 @@ +import { logger } from "../util/logger.js"; + +const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000; + +/** Error code returned by the server when the bot session has expired. */ +export const SESSION_EXPIRED_ERRCODE = -14; + +const pauseUntilMap = new Map<string, number>(); + +/** Pause all inbound/outbound API calls for `accountId` for one hour. */ +export function pauseSession(accountId: string): void { + const until = Date.now() + SESSION_PAUSE_DURATION_MS; + pauseUntilMap.set(accountId, until); + logger.info( + `session-guard: paused accountId=${accountId} until=${new Date(until).toISOString()} (${SESSION_PAUSE_DURATION_MS / 1000}s)`, + ); +} + +/** Returns `true` when the bot is still within its one-hour cooldown window. */ +export function isSessionPaused(accountId: string): boolean { + const until = pauseUntilMap.get(accountId); + if (until === undefined) return false; + if (Date.now() >= until) { + pauseUntilMap.delete(accountId); + return false; + } + return true; +} + +/** Milliseconds remaining until the pause expires (0 when not paused). */ +export function getRemainingPauseMs(accountId: string): number { + const until = pauseUntilMap.get(accountId); + if (until === undefined) return 0; + const remaining = until - Date.now(); + if (remaining <= 0) { + pauseUntilMap.delete(accountId); + return 0; + } + return remaining; +} + +/** Throw if the session is currently paused. Call before any API request. */ +export function assertSessionActive(accountId: string): void { + if (isSessionPaused(accountId)) { + const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000); + throw new Error( + `session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${SESSION_EXPIRED_ERRCODE})`, + ); + } +} + +/** + * Reset internal state — only for tests. + * @internal + */ +export function _resetForTest(): void { + pauseUntilMap.clear(); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/types.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/types.ts" new file mode 100644 index 00000000..52cb694f --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/types.ts" @@ -0,0 +1,222 @@ +/** + * Weixin protocol types (mirrors proto: GetUpdatesReq/Resp, WeixinMessage, SendMessageReq). + * API uses JSON over HTTP; bytes fields are base64 strings in JSON. + */ + +/** Common request metadata attached to every CGI request. */ +export interface BaseInfo { + channel_version?: string; +} + +/** proto: UploadMediaType */ +export const UploadMediaType = { + IMAGE: 1, + VIDEO: 2, + FILE: 3, + VOICE: 4, +} as const; + +export interface GetUploadUrlReq { + filekey?: string; + /** proto field 2: media_type, see UploadMediaType */ + media_type?: number; + to_user_id?: string; + /** 原文件明文大小 */ + rawsize?: number; + /** 原文件明文 MD5 */ + rawfilemd5?: string; + /** 原文件密文大小(AES-128-ECB 加密后) */ + filesize?: number; + /** 缩略图明文大小(IMAGE/VIDEO 时必填) */ + thumb_rawsize?: number; + /** 缩略图明文 MD5(IMAGE/VIDEO 时必填) */ + thumb_rawfilemd5?: string; + /** 缩略图密文大小(IMAGE/VIDEO 时必填) */ + thumb_filesize?: number; + /** 不需要缩略图上传 URL,默认 false */ + no_need_thumb?: boolean; + /** 加密 key */ + aeskey?: string; +} + +export interface GetUploadUrlResp { + /** 原图上传加密参数 */ + upload_param?: string; + /** 缩略图上传加密参数,无缩略图时为空 */ + thumb_upload_param?: string; +} + +export const MessageType = { + NONE: 0, + USER: 1, + BOT: 2, +} as const; + +export const MessageItemType = { + NONE: 0, + TEXT: 1, + IMAGE: 2, + VOICE: 3, + FILE: 4, + VIDEO: 5, +} as const; + +export const MessageState = { + NEW: 0, + GENERATING: 1, + FINISH: 2, +} as const; + +export interface TextItem { + text?: string; +} + +/** CDN media reference; aes_key is base64-encoded bytes in JSON. */ +export interface CDNMedia { + encrypt_query_param?: string; + aes_key?: string; + /** 加密类型: 0=只加密fileid, 1=打包缩略图/中图等信息 */ + encrypt_type?: number; +} + +export interface ImageItem { + /** 原图 CDN 引用 */ + media?: CDNMedia; + /** 缩略图 CDN 引用 */ + thumb_media?: CDNMedia; + /** Raw AES-128 key as hex string (16 bytes); preferred over media.aes_key for inbound decryption. */ + aeskey?: string; + url?: string; + mid_size?: number; + thumb_size?: number; + thumb_height?: number; + thumb_width?: number; + hd_size?: number; +} + +export interface VoiceItem { + media?: CDNMedia; + /** 语音编码类型:1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3 8=ogg-speex */ + encode_type?: number; + bits_per_sample?: number; + /** 采样率 (Hz) */ + sample_rate?: number; + /** 语音长度 (毫秒) */ + playtime?: number; + /** 语音转文字内容 */ + text?: string; +} + +export interface FileItem { + media?: CDNMedia; + file_name?: string; + md5?: string; + len?: string; +} + +export interface VideoItem { + media?: CDNMedia; + video_size?: number; + play_length?: number; + video_md5?: string; + thumb_media?: CDNMedia; + thumb_size?: number; + thumb_height?: number; + thumb_width?: number; +} + +export interface RefMessage { + message_item?: MessageItem; + title?: string; // 摘要 +} + +export interface MessageItem { + type?: number; + create_time_ms?: number; + update_time_ms?: number; + is_completed?: boolean; + msg_id?: string; + ref_msg?: RefMessage; + text_item?: TextItem; + image_item?: ImageItem; + voice_item?: VoiceItem; + file_item?: FileItem; + video_item?: VideoItem; +} + +/** Unified message (proto: WeixinMessage). Replaces the old split Message + MessageContent + FullMessage. */ +export interface WeixinMessage { + seq?: number; + message_id?: number; + from_user_id?: string; + to_user_id?: string; + client_id?: string; + create_time_ms?: number; + update_time_ms?: number; + delete_time_ms?: number; + session_id?: string; + group_id?: string; + message_type?: number; + message_state?: number; + item_list?: MessageItem[]; + context_token?: string; +} + +/** GetUpdates request: bytes fields are base64 strings in JSON. */ +export interface GetUpdatesReq { + /** @deprecated compat only, will be removed */ + sync_buf?: string; + /** Full context buf cached locally; send "" when none (first request or after reset). */ + get_updates_buf?: string; +} + +/** GetUpdates response: bytes fields are base64 strings in JSON. */ +export interface GetUpdatesResp { + ret?: number; + /** Error code returned by the server (e.g. -14 = session timeout). Present when request fails. */ + errcode?: number; + errmsg?: string; + msgs?: WeixinMessage[]; + /** @deprecated compat only */ + sync_buf?: string; + /** Full context buf to cache locally and send on next request. */ + get_updates_buf?: string; + /** Server-suggested timeout (ms) for the next getUpdates long-poll. */ + longpolling_timeout_ms?: number; +} + +/** SendMessage request: wraps a single WeixinMessage. */ +export interface SendMessageReq { + msg?: WeixinMessage; +} + +export interface SendMessageResp { + // empty +} + +/** Typing status: 1 = typing (default), 2 = cancel typing. */ +export const TypingStatus = { + TYPING: 1, + CANCEL: 2, +} as const; + +/** SendTyping request: send a typing indicator to a user. */ +export interface SendTypingReq { + ilink_user_id?: string; + typing_ticket?: string; + /** 1=typing (default), 2=cancel typing */ + status?: number; +} + +export interface SendTypingResp { + ret?: number; + errmsg?: string; +} + +/** GetConfig response: bot config including typing_ticket. */ +export interface GetConfigResp { + ret?: number; + errmsg?: string; + /** Base64-encoded typing ticket for sendTyping. */ + typing_ticket?: string; +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/accounts.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/accounts.ts" new file mode 100644 index 00000000..e36b4549 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/accounts.ts" @@ -0,0 +1,289 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { normalizeAccountId } from "openclaw/plugin-sdk"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; + +import { getWeixinRuntime } from "../runtime.js"; +import { resolveStateDir } from "../storage/state-dir.js"; +import { logger } from "../util/logger.js"; + +export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"; +export const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"; + + +// --------------------------------------------------------------------------- +// Account ID compatibility (legacy raw ID → normalized ID) +// --------------------------------------------------------------------------- + +/** + * Pattern-based reverse of normalizeWeixinAccountId for known weixin ID suffixes. + * Used only as a compatibility fallback when loading accounts / sync bufs stored + * under the old raw ID. + * e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot" + */ +export function deriveRawAccountId(normalizedId: string): string | undefined { + if (normalizedId.endsWith("-im-bot")) { + return `${normalizedId.slice(0, -7)}@im.bot`; + } + if (normalizedId.endsWith("-im-wechat")) { + return `${normalizedId.slice(0, -10)}@im.wechat`; + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Account index (persistent list of registered account IDs) +// --------------------------------------------------------------------------- + +function resolveWeixinStateDir(): string { + return path.join(resolveStateDir(), "openclaw-weixin"); +} + +function resolveAccountIndexPath(): string { + return path.join(resolveWeixinStateDir(), "accounts.json"); +} + +/** Returns all accountIds registered via QR login. */ +export function listIndexedWeixinAccountIds(): string[] { + const filePath = resolveAccountIndexPath(); + try { + if (!fs.existsSync(filePath)) return []; + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((id): id is string => typeof id === "string" && id.trim() !== ""); + } catch { + return []; + } +} + +/** Add accountId to the persistent index (no-op if already present). */ +export function registerWeixinAccountId(accountId: string): void { + const dir = resolveWeixinStateDir(); + fs.mkdirSync(dir, { recursive: true }); + + const existing = listIndexedWeixinAccountIds(); + if (existing.includes(accountId)) return; + + const updated = [...existing, accountId]; + fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify(updated, null, 2), "utf-8"); +} + +// --------------------------------------------------------------------------- +// Account store (per-account credential files) +// --------------------------------------------------------------------------- + +/** Unified per-account data: token + baseUrl in one file. */ +export type WeixinAccountData = { + token?: string; + savedAt?: string; + baseUrl?: string; + /** Last linked Weixin user id from QR login (optional). */ + userId?: string; +}; + +function resolveAccountsDir(): string { + return path.join(resolveWeixinStateDir(), "accounts"); +} + +function resolveAccountPath(accountId: string): string { + return path.join(resolveAccountsDir(), `${accountId}.json`); +} + +/** + * Legacy single-file token: `credentials/openclaw-weixin/credentials.json` (pre per-account files). + */ +function loadLegacyToken(): string | undefined { + const legacyPath = path.join(resolveStateDir(), "credentials", "openclaw-weixin", "credentials.json"); + try { + if (!fs.existsSync(legacyPath)) return undefined; + const raw = fs.readFileSync(legacyPath, "utf-8"); + const parsed = JSON.parse(raw) as { token?: string }; + return typeof parsed.token === "string" ? parsed.token : undefined; + } catch { + return undefined; + } +} + +function readAccountFile(filePath: string): WeixinAccountData | null { + try { + if (fs.existsSync(filePath)) { + return JSON.parse(fs.readFileSync(filePath, "utf-8")) as WeixinAccountData; + } + } catch { + // ignore + } + return null; +} + +/** Load account data by ID, with compatibility fallbacks. */ +export function loadWeixinAccount(accountId: string): WeixinAccountData | null { + // Primary: try given accountId (normalized IDs written after this change). + const primary = readAccountFile(resolveAccountPath(accountId)); + if (primary) return primary; + + // Compatibility: if the given ID is normalized, derive the old raw filename + // (e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot") for existing installs. + const rawId = deriveRawAccountId(accountId); + if (rawId) { + const compat = readAccountFile(resolveAccountPath(rawId)); + if (compat) return compat; + } + + // Legacy fallback: read token from old single-account credentials file. + const token = loadLegacyToken(); + if (token) return { token }; + + return null; +} + +/** + * Persist account data after QR login (merges into existing file). + * - token: overwritten when provided. + * - baseUrl: stored when non-empty; resolveWeixinAccount falls back to DEFAULT_BASE_URL. + * - userId: set when `update.userId` is provided; omitted from file when cleared to empty. + */ +export function saveWeixinAccount( + accountId: string, + update: { token?: string; baseUrl?: string; userId?: string }, +): void { + const dir = resolveAccountsDir(); + fs.mkdirSync(dir, { recursive: true }); + + const existing = loadWeixinAccount(accountId) ?? {}; + + const token = update.token?.trim() || existing.token; + const baseUrl = update.baseUrl?.trim() || existing.baseUrl; + const userId = + update.userId !== undefined + ? update.userId.trim() || undefined + : existing.userId?.trim() || undefined; + + const data: WeixinAccountData = { + ...(token ? { token, savedAt: new Date().toISOString() } : {}), + ...(baseUrl ? { baseUrl } : {}), + ...(userId ? { userId } : {}), + }; + + const filePath = resolveAccountPath(accountId); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // best-effort + } +} + +/** Remove account data file. */ +export function clearWeixinAccount(accountId: string): void { + try { + fs.unlinkSync(resolveAccountPath(accountId)); + } catch { + // ignore if not found + } +} + +/** + * Resolve the openclaw.json config file path. + * Checks OPENCLAW_CONFIG env var, then state dir. + */ +function resolveConfigPath(): string { + const envPath = process.env.OPENCLAW_CONFIG?.trim(); + if (envPath) return envPath; + return path.join(resolveStateDir(), "openclaw.json"); +} + +/** + * Read `routeTag` from openclaw.json (for callers without an `OpenClawConfig` object). + * Checks per-account `channels.<id>.accounts[accountId].routeTag` first, then section-level + * `channels.<id>.routeTag`. Matches `feat_weixin_extension` behavior; channel key is `"openclaw-weixin"`. + */ +export function loadConfigRouteTag(accountId?: string): string | undefined { + try { + const configPath = resolveConfigPath(); + if (!fs.existsSync(configPath)) return undefined; + const raw = fs.readFileSync(configPath, "utf-8"); + const cfg = JSON.parse(raw) as Record<string, unknown>; + const channels = cfg.channels as Record<string, unknown> | undefined; + const section = channels?.["openclaw-weixin"] as Record<string, unknown> | undefined; + if (!section) return undefined; + if (accountId) { + const accounts = section.accounts as Record<string, Record<string, unknown>> | undefined; + const tag = accounts?.[accountId]?.routeTag; + if (typeof tag === "number") return String(tag); + if (typeof tag === "string" && tag.trim()) return tag.trim(); + } + if (typeof section.routeTag === "number") return String(section.routeTag); + return typeof section.routeTag === "string" && section.routeTag.trim() + ? section.routeTag.trim() + : undefined; + } catch { + return undefined; + } +} + +/** + * No-op stub — config reload is now handled externally via `openclaw gateway restart`. + */ +export async function triggerWeixinChannelReload(): Promise<void> {} + +// --------------------------------------------------------------------------- +// Account resolution (merge config + stored credentials) +// --------------------------------------------------------------------------- + +export type ResolvedWeixinAccount = { + accountId: string; + baseUrl: string; + cdnBaseUrl: string; + token?: string; + enabled: boolean; + /** true when a token has been obtained via QR login. */ + configured: boolean; + name?: string; +}; + +type WeixinAccountConfig = { + name?: string; + enabled?: boolean; + cdnBaseUrl?: string; + /** Optional SKRouteTag source; read from openclaw.json when `accountId` is passed to `loadConfigRouteTag`. */ + routeTag?: number | string; +}; + +type WeixinSectionConfig = WeixinAccountConfig & { + accounts?: Record<string, WeixinAccountConfig>; +}; + +/** List accountIds from the index file (written at QR login). */ +export function listWeixinAccountIds(_cfg: OpenClawConfig): string[] { + return listIndexedWeixinAccountIds(); +} + +/** Resolve a weixin account by ID, merging config and stored credentials. */ +export function resolveWeixinAccount( + cfg: OpenClawConfig, + accountId?: string | null, +): ResolvedWeixinAccount { + const raw = accountId?.trim(); + if (!raw) { + throw new Error("weixin: accountId is required (no default account)"); + } + const id = normalizeAccountId(raw); + const section = cfg.channels?.["openclaw-weixin"] as WeixinSectionConfig | undefined; + const accountCfg: WeixinAccountConfig = section?.accounts?.[id] ?? section ?? {}; + + const accountData = loadWeixinAccount(id); + const token = accountData?.token?.trim() || undefined; + const stateBaseUrl = accountData?.baseUrl?.trim() || ""; + + return { + accountId: id, + baseUrl: stateBaseUrl || DEFAULT_BASE_URL, + cdnBaseUrl: accountCfg.cdnBaseUrl?.trim() || CDN_BASE_URL, + token, + enabled: accountCfg.enabled !== false, + configured: Boolean(token), + name: accountCfg.name?.trim() || undefined, + }; +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/login-qr.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/login-qr.ts" new file mode 100644 index 00000000..1ca8d5ba --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/login-qr.ts" @@ -0,0 +1,333 @@ +import { randomUUID } from "node:crypto"; + +import { loadConfigRouteTag } from "./accounts.js"; +import { logger } from "../util/logger.js"; +import { redactToken } from "../util/redact.js"; + +type ActiveLogin = { + sessionKey: string; + id: string; + qrcode: string; + qrcodeUrl: string; + startedAt: number; + botToken?: string; + status?: "wait" | "scaned" | "confirmed" | "expired"; + error?: string; +}; + +const ACTIVE_LOGIN_TTL_MS = 5 * 60_000; +/** Client-side timeout for the long-poll get_qrcode_status request. */ +const QR_LONG_POLL_TIMEOUT_MS = 35_000; + +/** Default `bot_type` for ilink get_bot_qrcode / get_qrcode_status (this channel build). */ +export const DEFAULT_ILINK_BOT_TYPE = "3"; + +const activeLogins = new Map<string, ActiveLogin>(); + +interface QRCodeResponse { + qrcode: string; + qrcode_img_content: string; +} + +interface StatusResponse { + status: "wait" | "scaned" | "confirmed" | "expired"; + bot_token?: string; + ilink_bot_id?: string; + baseurl?: string; + /** The user ID of the person who scanned the QR code. */ + ilink_user_id?: string; +} + +function isLoginFresh(login: ActiveLogin): boolean { + return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS; +} + +/** Remove all expired entries from the activeLogins map to prevent memory leaks. */ +function purgeExpiredLogins(): void { + for (const [id, login] of activeLogins) { + if (!isLoginFresh(login)) { + activeLogins.delete(id); + } + } +} + +async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeResponse> { + const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`; + const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base); + logger.info(`Fetching QR code from: ${url.toString()}`); + + const headers: Record<string, string> = {}; + const routeTag = loadConfigRouteTag(); + if (routeTag) { + headers.SKRouteTag = routeTag; + } + + const response = await fetch(url.toString(), { headers }); + if (!response.ok) { + const body = await response.text().catch(() => "(unreadable)"); + logger.error(`QR code fetch failed: ${response.status} ${response.statusText} body=${body}`); + throw new Error(`Failed to fetch QR code: ${response.status} ${response.statusText}`); + } + return await response.json(); +} + +async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise<StatusResponse> { + const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`; + const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base); + logger.debug(`Long-poll QR status from: ${url.toString()}`); + + const headers: Record<string, string> = { + "iLink-App-ClientVersion": "1", + }; + const routeTag = loadConfigRouteTag(); + if (routeTag) { + headers.SKRouteTag = routeTag; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS); + try { + const response = await fetch(url.toString(), { headers, signal: controller.signal }); + clearTimeout(timer); + logger.debug(`pollQRStatus: HTTP ${response.status}, reading body...`); + const rawText = await response.text(); + logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`); + if (!response.ok) { + logger.error(`QR status poll failed: ${response.status} ${response.statusText} body=${rawText}`); + throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText}`); + } + return JSON.parse(rawText) as StatusResponse; + } catch (err) { + clearTimeout(timer); + if (err instanceof Error && err.name === "AbortError") { + logger.debug(`pollQRStatus: client-side timeout after ${QR_LONG_POLL_TIMEOUT_MS}ms, returning wait`); + return { status: "wait" }; + } + throw err; + } +} + +export type WeixinQrStartResult = { + qrcodeUrl?: string; + message: string; + sessionKey: string; +}; + +export type WeixinQrWaitResult = { + connected: boolean; + botToken?: string; + accountId?: string; + baseUrl?: string; + /** The user ID of the person who scanned the QR code; add to allowFrom. */ + userId?: string; + message: string; +}; + +export async function startWeixinLoginWithQr(opts: { + verbose?: boolean; + timeoutMs?: number; + force?: boolean; + accountId?: string; + apiBaseUrl: string; + botType?: string; +}): Promise<WeixinQrStartResult> { + const sessionKey = opts.accountId || randomUUID(); + + purgeExpiredLogins(); + + const existing = activeLogins.get(sessionKey); + if (!opts.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) { + return { + qrcodeUrl: existing.qrcodeUrl, + message: "二维码已就绪,请使用微信扫描。", + sessionKey, + }; + } + + try { + const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE; + logger.info(`Starting Weixin login with bot_type=${botType}`); + + if (!opts.apiBaseUrl) { + return { + message: + "No baseUrl configured. Add channels.openclaw-weixin.baseUrl to your config before logging in.", + sessionKey, + }; + } + + const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType); + logger.info( + `QR code received, qrcode=${redactToken(qrResponse.qrcode)} imgContentLen=${qrResponse.qrcode_img_content?.length ?? 0}`, + ); + logger.info(`二维码链接: ${qrResponse.qrcode_img_content}`); + + const login: ActiveLogin = { + sessionKey, + id: randomUUID(), + qrcode: qrResponse.qrcode, + qrcodeUrl: qrResponse.qrcode_img_content, + startedAt: Date.now(), + }; + + activeLogins.set(sessionKey, login); + + return { + qrcodeUrl: qrResponse.qrcode_img_content, + message: "使用微信扫描以下二维码,以完成连接。", + sessionKey, + }; + } catch (err) { + logger.error(`Failed to start Weixin login: ${String(err)}`); + return { + message: `Failed to start login: ${String(err)}`, + sessionKey, + }; + } +} + +const MAX_QR_REFRESH_COUNT = 3; + +export async function waitForWeixinLogin(opts: { + timeoutMs?: number; + verbose?: boolean; + sessionKey: string; + apiBaseUrl: string; + botType?: string; +}): Promise<WeixinQrWaitResult> { + let activeLogin = activeLogins.get(opts.sessionKey); + + if (!activeLogin) { + logger.warn(`waitForWeixinLogin: no active login sessionKey=${opts.sessionKey}`); + return { + connected: false, + message: "当前没有进行中的登录,请先发起登录。", + }; + } + + if (!isLoginFresh(activeLogin)) { + logger.warn(`waitForWeixinLogin: login QR expired sessionKey=${opts.sessionKey}`); + activeLogins.delete(opts.sessionKey); + return { + connected: false, + message: "二维码已过期,请重新生成。", + }; + } + + const timeoutMs = Math.max(opts.timeoutMs ?? 480_000, 1000); + const deadline = Date.now() + timeoutMs; + let scannedPrinted = false; + let qrRefreshCount = 1; + + logger.info("Starting to poll QR code status..."); + + while (Date.now() < deadline) { + try { + const statusResponse = await pollQRStatus(opts.apiBaseUrl, activeLogin.qrcode); + logger.debug(`pollQRStatus: status=${statusResponse.status} hasBotToken=${Boolean(statusResponse.bot_token)} hasBotId=${Boolean(statusResponse.ilink_bot_id)}`); + activeLogin.status = statusResponse.status; + + switch (statusResponse.status) { + case "wait": + if (opts.verbose) { + process.stdout.write("."); + } + break; + case "scaned": + if (!scannedPrinted) { + process.stdout.write("\n👀 已扫码,在微信继续操作...\n"); + scannedPrinted = true; + } + break; + case "expired": { + qrRefreshCount++; + if (qrRefreshCount > MAX_QR_REFRESH_COUNT) { + logger.warn( + `waitForWeixinLogin: QR expired ${MAX_QR_REFRESH_COUNT} times, giving up sessionKey=${opts.sessionKey}`, + ); + activeLogins.delete(opts.sessionKey); + return { + connected: false, + message: "登录超时:二维码多次过期,请重新开始登录流程。", + }; + } + + process.stdout.write(`\n⏳ 二维码已过期,正在刷新...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`); + logger.info( + `waitForWeixinLogin: QR expired, refreshing (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})`, + ); + + try { + const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE; + const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType); + activeLogin.qrcode = qrResponse.qrcode; + activeLogin.qrcodeUrl = qrResponse.qrcode_img_content; + activeLogin.startedAt = Date.now(); + scannedPrinted = false; + logger.info(`waitForWeixinLogin: new QR code obtained qrcode=${redactToken(qrResponse.qrcode)}`); + process.stdout.write(`🔄 新二维码已生成,请重新扫描\n\n`); + try { + const qrterm = await import("qrcode-terminal"); + qrterm.default.generate(qrResponse.qrcode_img_content, { small: true }); + } catch { + process.stdout.write(`QR Code URL: ${qrResponse.qrcode_img_content}\n`); + } + } catch (refreshErr) { + logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`); + activeLogins.delete(opts.sessionKey); + return { + connected: false, + message: `刷新二维码失败: ${String(refreshErr)}`, + }; + } + break; + } + case "confirmed": { + if (!statusResponse.ilink_bot_id) { + activeLogins.delete(opts.sessionKey); + logger.error("Login confirmed but ilink_bot_id missing from response"); + return { + connected: false, + message: "登录失败:服务器未返回 ilink_bot_id。", + }; + } + + activeLogin.botToken = statusResponse.bot_token; + activeLogins.delete(opts.sessionKey); + + logger.info( + `✅ Login confirmed! ilink_bot_id=${statusResponse.ilink_bot_id} ilink_user_id=${redactToken(statusResponse.ilink_user_id)}`, + ); + + return { + connected: true, + botToken: statusResponse.bot_token, + accountId: statusResponse.ilink_bot_id, + baseUrl: statusResponse.baseurl, + userId: statusResponse.ilink_user_id, + message: "✅ 与微信连接成功!", + }; + } + } + + } catch (err) { + logger.error(`Error polling QR status: ${String(err)}`); + activeLogins.delete(opts.sessionKey); + return { + connected: false, + message: `Login failed: ${String(err)}`, + }; + } + + await new Promise((r) => setTimeout(r, 1000)); + } + + logger.warn( + `waitForWeixinLogin: timed out waiting for QR scan sessionKey=${opts.sessionKey} timeoutMs=${timeoutMs}`, + ); + activeLogins.delete(opts.sessionKey); + return { + connected: false, + message: "登录超时,请重试。", + }; +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/pairing.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/pairing.ts" new file mode 100644 index 00000000..eb4a9dca --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/pairing.ts" @@ -0,0 +1,120 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { withFileLock } from "openclaw/plugin-sdk"; + +import { resolveStateDir } from "../storage/state-dir.js"; +import { logger } from "../util/logger.js"; + +/** + * Resolve the framework credentials directory (mirrors core resolveOAuthDir). + * Path: $OPENCLAW_OAUTH_DIR || $OPENCLAW_STATE_DIR/credentials || ~/.openclaw/credentials + */ +function resolveCredentialsDir(): string { + const override = process.env.OPENCLAW_OAUTH_DIR?.trim(); + if (override) return override; + return path.join(resolveStateDir(), "credentials"); +} + +/** + * Sanitize a channel/account key for safe use in filenames (mirrors core safeChannelKey). + */ +function safeKey(raw: string): string { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) throw new Error("invalid key for allowFrom path"); + const safe = trimmed.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_"); + if (!safe || safe === "_") throw new Error("invalid key for allowFrom path"); + return safe; +} + +/** + * Resolve the framework allowFrom file path for a given account. + * Mirrors: `resolveAllowFromPath(channel, env, accountId)` from core. + * Path: `<credDir>/openclaw-weixin-<accountId>-allowFrom.json` + */ +export function resolveFrameworkAllowFromPath(accountId: string): string { + const base = safeKey("openclaw-weixin"); + const safeAccount = safeKey(accountId); + return path.join(resolveCredentialsDir(), `${base}-${safeAccount}-allowFrom.json`); +} + +type AllowFromFileContent = { + version: number; + allowFrom: string[]; +}; + +/** + * Read the framework allowFrom list for an account (user IDs authorized via pairing). + * Returns an empty array when the file is missing or unreadable. + */ +export function readFrameworkAllowFromList(accountId: string): string[] { + const filePath = resolveFrameworkAllowFromPath(accountId); + try { + if (!fs.existsSync(filePath)) return []; + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as AllowFromFileContent; + if (Array.isArray(parsed.allowFrom)) { + return parsed.allowFrom.filter((id): id is string => typeof id === "string" && id.trim() !== ""); + } + } catch { + // best-effort + } + return []; +} + +/** File lock options matching the framework's pairing store lock settings. */ +const LOCK_OPTIONS = { + retries: { retries: 3, factor: 2, minTimeout: 100, maxTimeout: 2000 }, + stale: 10_000, +}; + +/** + * Register a user ID in the framework's channel allowFrom store. + * This writes directly to the same JSON file that `readChannelAllowFromStore` reads, + * making the user visible to the framework authorization pipeline. + * + * Uses file locking to avoid races with concurrent readers/writers. + */ +export async function registerUserInFrameworkStore(params: { + accountId: string; + userId: string; +}): Promise<{ changed: boolean }> { + const { accountId, userId } = params; + const trimmedUserId = userId.trim(); + if (!trimmedUserId) return { changed: false }; + + const filePath = resolveFrameworkAllowFromPath(accountId); + + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + + // Ensure the file exists before locking + if (!fs.existsSync(filePath)) { + const initial: AllowFromFileContent = { version: 1, allowFrom: [] }; + fs.writeFileSync(filePath, JSON.stringify(initial, null, 2), "utf-8"); + } + + return await withFileLock(filePath, LOCK_OPTIONS, async () => { + let content: AllowFromFileContent = { version: 1, allowFrom: [] }; + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as AllowFromFileContent; + if (Array.isArray(parsed.allowFrom)) { + content = parsed; + } + } catch { + // If read/parse fails, start fresh + } + + if (content.allowFrom.includes(trimmedUserId)) { + return { changed: false }; + } + + content.allowFrom.push(trimmedUserId); + fs.writeFileSync(filePath, JSON.stringify(content, null, 2), "utf-8"); + logger.info( + `registerUserInFrameworkStore: added userId=${trimmedUserId} accountId=${accountId} path=${filePath}`, + ); + return { changed: true }; + }); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/aes-ecb.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/aes-ecb.ts" new file mode 100644 index 00000000..1a977439 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/aes-ecb.ts" @@ -0,0 +1,21 @@ +/** + * Shared AES-128-ECB crypto utilities for CDN upload and download. + */ +import { createCipheriv, createDecipheriv } from "node:crypto"; + +/** Encrypt buffer with AES-128-ECB (PKCS7 padding is default). */ +export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer { + const cipher = createCipheriv("aes-128-ecb", key, null); + return Buffer.concat([cipher.update(plaintext), cipher.final()]); +} + +/** Decrypt buffer with AES-128-ECB (PKCS7 padding). */ +export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer { + const decipher = createDecipheriv("aes-128-ecb", key, null); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +/** Compute AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */ +export function aesEcbPaddedSize(plaintextSize: number): number { + return Math.ceil((plaintextSize + 1) / 16) * 16; +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/cdn-upload.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/cdn-upload.ts" new file mode 100644 index 00000000..407fad95 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/cdn-upload.ts" @@ -0,0 +1,77 @@ +import { encryptAesEcb } from "./aes-ecb.js"; +import { buildCdnUploadUrl } from "./cdn-url.js"; +import { logger } from "../util/logger.js"; +import { redactUrl } from "../util/redact.js"; + +/** Maximum retry attempts for CDN upload. */ +const UPLOAD_MAX_RETRIES = 3; + +/** + * Upload one buffer to the Weixin CDN with AES-128-ECB encryption. + * Returns the download encrypted_query_param from the CDN response. + * Retries up to UPLOAD_MAX_RETRIES times on server errors; client errors (4xx) abort immediately. + */ +export async function uploadBufferToCdn(params: { + buf: Buffer; + uploadParam: string; + filekey: string; + cdnBaseUrl: string; + label: string; + aeskey: Buffer; +}): Promise<{ downloadParam: string }> { + const { buf, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params; + const ciphertext = encryptAesEcb(buf, aeskey); + const cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey }); + logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`); + + let downloadParam: string | undefined; + let lastError: unknown; + + for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) { + try { + const res = await fetch(cdnUrl, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: new Uint8Array(ciphertext), + }); + if (res.status >= 400 && res.status < 500) { + const errMsg = res.headers.get("x-error-message") ?? (await res.text()); + logger.error( + `${label}: CDN client error attempt=${attempt} status=${res.status} errMsg=${errMsg}`, + ); + throw new Error(`CDN upload client error ${res.status}: ${errMsg}`); + } + if (res.status !== 200) { + const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`; + logger.error( + `${label}: CDN server error attempt=${attempt} status=${res.status} errMsg=${errMsg}`, + ); + throw new Error(`CDN upload server error: ${errMsg}`); + } + downloadParam = res.headers.get("x-encrypted-param") ?? undefined; + if (!downloadParam) { + logger.error( + `${label}: CDN response missing x-encrypted-param header attempt=${attempt}`, + ); + throw new Error("CDN upload response missing x-encrypted-param header"); + } + logger.debug(`${label}: CDN upload success attempt=${attempt}`); + break; + } catch (err) { + lastError = err; + if (err instanceof Error && err.message.includes("client error")) throw err; + if (attempt < UPLOAD_MAX_RETRIES) { + logger.error(`${label}: attempt ${attempt} failed, retrying... err=${String(err)}`); + } else { + logger.error(`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed err=${String(err)}`); + } + } + } + + if (!downloadParam) { + throw lastError instanceof Error + ? lastError + : new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`); + } + return { downloadParam }; +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/cdn-url.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/cdn-url.ts" new file mode 100644 index 00000000..b03d5b0b --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/cdn-url.ts" @@ -0,0 +1,17 @@ +/** + * Unified CDN URL construction for Weixin CDN upload/download. + */ + +/** Build a CDN download URL from encrypt_query_param. */ +export function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string { + return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`; +} + +/** Build a CDN upload URL from upload_param and filekey. */ +export function buildCdnUploadUrl(params: { + cdnBaseUrl: string; + uploadParam: string; + filekey: string; +}): string { + return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`; +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/pic-decrypt.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/pic-decrypt.ts" new file mode 100644 index 00000000..1fe995cb --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/pic-decrypt.ts" @@ -0,0 +1,85 @@ +import { decryptAesEcb } from "./aes-ecb.js"; +import { buildCdnDownloadUrl } from "./cdn-url.js"; +import { logger } from "../util/logger.js"; + +/** + * Download raw bytes from the CDN (no decryption). + */ +async function fetchCdnBytes(url: string, label: string): Promise<Buffer> { + let res: Response; + try { + res = await fetch(url); + } catch (err) { + const cause = + (err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "(no cause)"; + logger.error( + `${label}: fetch network error url=${url} err=${String(err)} cause=${String(cause)}`, + ); + throw err; + } + logger.debug(`${label}: response status=${res.status} ok=${res.ok}`); + if (!res.ok) { + const body = await res.text().catch(() => "(unreadable)"); + const msg = `${label}: CDN download ${res.status} ${res.statusText} body=${body}`; + logger.error(msg); + throw new Error(msg); + } + return Buffer.from(await res.arrayBuffer()); +} + +/** + * Parse CDNMedia.aes_key into a raw 16-byte AES key. + * + * Two encodings are seen in the wild: + * - base64(raw 16 bytes) → images (aes_key from media field) + * - base64(hex string of 16 bytes) → file / voice / video + * + * In the second case, base64-decoding yields 32 ASCII hex chars which must + * then be parsed as hex to recover the actual 16-byte key. + */ +function parseAesKey(aesKeyBase64: string, label: string): Buffer { + const decoded = Buffer.from(aesKeyBase64, "base64"); + if (decoded.length === 16) { + return decoded; + } + if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) { + // hex-encoded key: base64 → hex string → raw bytes + return Buffer.from(decoded.toString("ascii"), "hex"); + } + const msg = `${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes (base64="${aesKeyBase64}")`; + logger.error(msg); + throw new Error(msg); +} + +/** + * Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer. + * aesKeyBase64: CDNMedia.aes_key JSON field (see parseAesKey for supported formats). + */ +export async function downloadAndDecryptBuffer( + encryptedQueryParam: string, + aesKeyBase64: string, + cdnBaseUrl: string, + label: string, +): Promise<Buffer> { + const key = parseAesKey(aesKeyBase64, label); + const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl); + logger.debug(`${label}: fetching url=${url}`); + const encrypted = await fetchCdnBytes(url, label); + logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`); + const decrypted = decryptAesEcb(encrypted, key); + logger.debug(`${label}: decrypted ${decrypted.length} bytes`); + return decrypted; +} + +/** + * Download plain (unencrypted) bytes from the CDN. Returns the raw Buffer. + */ +export async function downloadPlainCdnBuffer( + encryptedQueryParam: string, + cdnBaseUrl: string, + label: string, +): Promise<Buffer> { + const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl); + logger.debug(`${label}: fetching url=${url}`); + return fetchCdnBytes(url, label); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/upload.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/upload.ts" new file mode 100644 index 00000000..9e3177a7 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/upload.ts" @@ -0,0 +1,155 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { getUploadUrl } from "../api/api.js"; +import type { WeixinApiOptions } from "../api/api.js"; +import { aesEcbPaddedSize } from "./aes-ecb.js"; +import { uploadBufferToCdn } from "./cdn-upload.js"; +import { logger } from "../util/logger.js"; +import { getExtensionFromContentTypeOrUrl } from "../media/mime.js"; +import { tempFileName } from "../util/random.js"; +import { UploadMediaType } from "../api/types.js"; + +export type UploadedFileInfo = { + filekey: string; + /** 由 upload_param 上传后 CDN 返回的下载加密参数; fill into ImageItem.media.encrypt_query_param */ + downloadEncryptedQueryParam: string; + /** AES-128-ECB key, hex-encoded; convert to base64 for CDNMedia.aes_key */ + aeskey: string; + /** Plaintext file size in bytes */ + fileSize: number; + /** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding); use for ImageItem.hd_size / mid_size */ + fileSizeCiphertext: number; +}; + +/** + * Download a remote media URL (image, video, file) to a local temp file in destDir. + * Returns the local file path; extension is inferred from Content-Type / URL. + */ +export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> { + logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`); + const res = await fetch(url); + if (!res.ok) { + const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`; + logger.error(`downloadRemoteImageToTemp: ${msg}`); + throw new Error(msg); + } + const buf = Buffer.from(await res.arrayBuffer()); + logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`); + await fs.mkdir(destDir, { recursive: true }); + const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url); + const name = tempFileName("weixin-remote", ext); + const filePath = path.join(destDir, name); + await fs.writeFile(filePath, buf); + logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`); + return filePath; +} + +/** + * Common upload pipeline: read file → hash → gen aeskey → getUploadUrl → uploadBufferToCdn → return info. + */ +async function uploadMediaToCdn(params: { + filePath: string; + toUserId: string; + opts: WeixinApiOptions; + cdnBaseUrl: string; + mediaType: (typeof UploadMediaType)[keyof typeof UploadMediaType]; + label: string; +}): Promise<UploadedFileInfo> { + const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params; + + const plaintext = await fs.readFile(filePath); + const rawsize = plaintext.length; + const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex"); + const filesize = aesEcbPaddedSize(rawsize); + const filekey = crypto.randomBytes(16).toString("hex"); + const aeskey = crypto.randomBytes(16); + + logger.debug( + `${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`, + ); + + const uploadUrlResp = await getUploadUrl({ + ...opts, + filekey, + media_type: mediaType, + to_user_id: toUserId, + rawsize, + rawfilemd5, + filesize, + no_need_thumb: true, + aeskey: aeskey.toString("hex"), + }); + + const uploadParam = uploadUrlResp.upload_param; + if (!uploadParam) { + logger.error( + `${label}: getUploadUrl returned no upload_param, resp=${JSON.stringify(uploadUrlResp)}`, + ); + throw new Error(`${label}: getUploadUrl returned no upload_param`); + } + + const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({ + buf: plaintext, + uploadParam, + filekey, + cdnBaseUrl, + aeskey, + label: `${label}[orig filekey=${filekey}]`, + }); + + return { + filekey, + downloadEncryptedQueryParam, + aeskey: aeskey.toString("hex"), + fileSize: rawsize, + fileSizeCiphertext: filesize, + }; +} + +/** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */ +export async function uploadFileToWeixin(params: { + filePath: string; + toUserId: string; + opts: WeixinApiOptions; + cdnBaseUrl: string; +}): Promise<UploadedFileInfo> { + return uploadMediaToCdn({ + ...params, + mediaType: UploadMediaType.IMAGE, + label: "uploadFileToWeixin", + }); +} + +/** Upload a local video file to the Weixin CDN. */ +export async function uploadVideoToWeixin(params: { + filePath: string; + toUserId: string; + opts: WeixinApiOptions; + cdnBaseUrl: string; +}): Promise<UploadedFileInfo> { + return uploadMediaToCdn({ + ...params, + mediaType: UploadMediaType.VIDEO, + label: "uploadVideoToWeixin", + }); +} + +/** + * Upload a local file attachment (non-image, non-video) to the Weixin CDN. + * Uses media_type=FILE; no thumbnail required. + */ +export async function uploadFileAttachmentToWeixin(params: { + filePath: string; + fileName: string; + toUserId: string; + opts: WeixinApiOptions; + cdnBaseUrl: string; +}): Promise<UploadedFileInfo> { + return uploadMediaToCdn({ + ...params, + mediaType: UploadMediaType.FILE, + label: "uploadFileAttachmentToWeixin", + }); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/channel.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/channel.ts" new file mode 100644 index 00000000..dae8a7cc --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/channel.ts" @@ -0,0 +1,380 @@ +import path from "node:path"; + +import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk"; +import { normalizeAccountId } from "openclaw/plugin-sdk"; + +import { + registerWeixinAccountId, + loadWeixinAccount, + saveWeixinAccount, + listWeixinAccountIds, + resolveWeixinAccount, + triggerWeixinChannelReload, + DEFAULT_BASE_URL, +} from "./auth/accounts.js"; +import type { ResolvedWeixinAccount } from "./auth/accounts.js"; +import { assertSessionActive } from "./api/session-guard.js"; +import { getContextToken } from "./messaging/inbound.js"; +import { logger } from "./util/logger.js"; +import { + DEFAULT_ILINK_BOT_TYPE, + startWeixinLoginWithQr, + waitForWeixinLogin, +} from "./auth/login-qr.js"; +import type { WeixinQrStartResult, WeixinQrWaitResult } from "./auth/login-qr.js"; +import { monitorWeixinProvider } from "./monitor/monitor.js"; +import { sendWeixinMediaFile } from "./messaging/send-media.js"; +import { sendMessageWeixin } from "./messaging/send.js"; +import { downloadRemoteImageToTemp } from "./cdn/upload.js"; + +/** Returns true when mediaUrl refers to a local filesystem path (absolute or relative). */ +function isLocalFilePath(mediaUrl: string): boolean { + // Treat anything without a URL scheme (no "://") as a local path. + return !mediaUrl.includes("://"); +} + +function isRemoteUrl(mediaUrl: string): boolean { + return mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://"); +} + +const MEDIA_OUTBOUND_TEMP_DIR = "/tmp/openclaw/weixin/media/outbound-temp"; + +/** Resolve any local path scheme to an absolute filesystem path. */ +function resolveLocalPath(mediaUrl: string): string { + if (mediaUrl.startsWith("file://")) return new URL(mediaUrl).pathname; + // Resolve any relative path (./foo, ../foo, .openclaw/foo, foo/bar) against cwd + if (!path.isAbsolute(mediaUrl)) return path.resolve(mediaUrl); + return mediaUrl; +} + +async function sendWeixinOutbound(params: { + cfg: OpenClawConfig; + to: string; + text: string; + accountId?: string | null; + contextToken?: string; + mediaUrl?: string; +}): Promise<{ channel: string; messageId: string }> { + const account = resolveWeixinAccount(params.cfg, params.accountId); + const aLog = logger.withAccount(account.accountId); + assertSessionActive(account.accountId); + if (!account.configured) { + aLog.error(`sendWeixinOutbound: account not configured`); + throw new Error("weixin not configured: please run `openclaw channels login --channel openclaw-weixin`"); + } + if (!params.contextToken) { + aLog.error(`sendWeixinOutbound: contextToken missing, refusing to send to=${params.to}`); + throw new Error("sendWeixinOutbound: contextToken is required"); + } + const result = await sendMessageWeixin({ to: params.to, text: params.text, opts: { + baseUrl: account.baseUrl, + token: account.token, + contextToken: params.contextToken, + }}); + return { channel: "openclaw-weixin", messageId: result.messageId }; +} + +export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = { + id: "openclaw-weixin", + meta: { + id: "openclaw-weixin", + label: "openclaw-weixin", + selectionLabel: "openclaw-weixin (long-poll)", + docsPath: "/channels/openclaw-weixin", + docsLabel: "openclaw-weixin", + blurb: "getUpdates long-poll upstream, sendMessage downstream; token auth.", + order: 75, + }, + configSchema: { + schema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, + capabilities: { + chatTypes: ["direct"], + media: true, + }, + messaging: { + targetResolver: { + // Weixin user IDs always end with @im.wechat; treat as direct IDs, skip directory lookup. + looksLikeId: (raw) => raw.endsWith("@im.wechat"), + }, + }, + agentPrompt: { + messageToolHints: () => [ + "To send an image or file to the current user, use the message tool with action='send' and set 'media' to a local file path or a remote URL. You do not need to specify 'to' — the current conversation recipient is used automatically.", + "When the user asks you to find an image from the web, use a web search or browser tool to find a suitable image URL, then send it using the message tool with 'media' set to that HTTPS image URL — do NOT download the image first.", + "IMPORTANT: When generating or saving a file to send, always use an absolute path (e.g. /tmp/photo.png), never a relative path like ./photo.png. Relative paths cannot be resolved and the file will not be delivered.", + "IMPORTANT: When creating a cron job (scheduled task) for the current Weixin user, you MUST set delivery.to to the user's Weixin ID (the xxx@im.wechat address from the current conversation). Without an explicit 'to', the cron delivery will fail with 'requires target'. Example: delivery: { mode: 'announce', channel: 'openclaw-weixin', to: '<current_user_id@im.wechat>' }.", + ], + }, + reload: { configPrefixes: ["channels.openclaw-weixin"] }, + config: { + listAccountIds: (cfg) => listWeixinAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveWeixinAccount(cfg, accountId), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + }), + }, + outbound: { + deliveryMode: "direct", + textChunkLimit: 4000, + sendText: async (ctx) => { + const result = await sendWeixinOutbound({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text, + accountId: ctx.accountId, + contextToken: getContextToken(ctx.accountId!, ctx.to), + }); + return result; + }, + sendMedia: async (ctx) => { + const account = resolveWeixinAccount(ctx.cfg, ctx.accountId); + const aLog = logger.withAccount(account.accountId); + assertSessionActive(account.accountId); + if (!account.configured) { + aLog.error(`sendMedia: account not configured`); + throw new Error( + "weixin not configured: please run `openclaw channels login --channel openclaw-weixin`", + ); + } + + const mediaUrl = ctx.mediaUrl; + + if (mediaUrl && (isLocalFilePath(mediaUrl) || isRemoteUrl(mediaUrl))) { + let filePath: string; + if (isLocalFilePath(mediaUrl)) { + filePath = resolveLocalPath(mediaUrl); + aLog.debug(`sendMedia: uploading local file ${filePath}`); + } else { + aLog.debug(`sendMedia: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`); + filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR); + aLog.debug(`sendMedia: remote image downloaded to ${filePath}`); + } + const contextToken = getContextToken(account.accountId, ctx.to); + const result = await sendWeixinMediaFile({ + filePath, + to: ctx.to, + text: ctx.text ?? "", + opts: { baseUrl: account.baseUrl, token: account.token, contextToken }, + cdnBaseUrl: account.cdnBaseUrl, + }); + return { channel: "openclaw-weixin", messageId: result.messageId }; + } + + const result = await sendWeixinOutbound({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text ?? "", + accountId: ctx.accountId, + contextToken: getContextToken(ctx.accountId!, ctx.to), + }); + return result; + }, + }, + status: { + defaultRuntime: { + accountId: "", + lastError: null, + lastInboundAt: null, + lastOutboundAt: null, + }, + collectStatusIssues: () => [], + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + lastError: snapshot.lastError ?? null, + lastInboundAt: snapshot.lastInboundAt ?? null, + lastOutboundAt: snapshot.lastOutboundAt ?? null, + }), + buildAccountSnapshot: ({ account, runtime }) => ({ + ...runtime, + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + }), + }, + auth: { + login: async ({ cfg, accountId, verbose, runtime }) => { + const account = resolveWeixinAccount(cfg, accountId); + + const log = (msg: string) => { + runtime?.log?.(msg); + }; + + log(`正在启动微信扫码登录...`); + const startResult: WeixinQrStartResult = await startWeixinLoginWithQr({ + accountId: account.accountId, + apiBaseUrl: account.baseUrl, + botType: DEFAULT_ILINK_BOT_TYPE, + verbose: Boolean(verbose), + }); + + if (!startResult.qrcodeUrl) { + logger.warn( + `auth.login: failed to get QR code accountId=${account.accountId} message=${startResult.message}`, + ); + log(startResult.message); + throw new Error(startResult.message); + } + + log(`\n使用微信扫描以下二维码,以完成连接:\n`); + try { + const qrcodeterminal = await import("qrcode-terminal"); + await new Promise<void>((resolve) => { + qrcodeterminal.default.generate(startResult.qrcodeUrl!, { small: true }, (qr: string) => { + console.log(qr); + resolve(); + }); + }); + } catch (err) { + logger.warn( + `auth.login: qrcode-terminal unavailable, falling back to URL err=${String(err)}`, + ); + log(`二维码链接: ${startResult.qrcodeUrl}`); + } + + const loginTimeoutMs = 480_000; + log(`\n等待连接结果...\n`); + + const waitResult: WeixinQrWaitResult = await waitForWeixinLogin({ + sessionKey: startResult.sessionKey, + apiBaseUrl: account.baseUrl, + timeoutMs: loginTimeoutMs, + verbose: Boolean(verbose), + botType: DEFAULT_ILINK_BOT_TYPE, + }); + + if (waitResult.connected && waitResult.botToken && waitResult.accountId) { + try { + // Normalize the raw ilink_bot_id (e.g. "hex@im.bot") to a filesystem-safe + // key (e.g. "hex-im-bot") so account files have no special chars. + const normalizedId = normalizeAccountId(waitResult.accountId); + saveWeixinAccount(normalizedId, { + token: waitResult.botToken, + baseUrl: waitResult.baseUrl, + userId: waitResult.userId, + }); + registerWeixinAccountId(normalizedId); + void triggerWeixinChannelReload(); + log(`\n✅ 与微信连接成功!`); + } catch (err) { + logger.error( + `auth.login: failed to save account data accountId=${waitResult.accountId} err=${String(err)}`, + ); + log(`⚠️ 保存账号数据失败: ${String(err)}`); + } + } else { + logger.warn( + `auth.login: login did not complete accountId=${account.accountId} message=${waitResult.message}`, + ); + // log(waitResult.message); + throw new Error(waitResult.message); + } + }, + }, + gateway: { + startAccount: async (ctx) => { + logger.debug(`startAccount entry`); + if (!ctx) { + logger.warn(`gateway.startAccount: called with undefined ctx, skipping`); + return; + } + const account = ctx.account; + const aLog = logger.withAccount(account.accountId); + aLog.debug(`about to call monitorWeixinProvider`); + aLog.info(`starting weixin webhook`); + + ctx.setStatus?.({ + accountId: account.accountId, + running: true, + lastStartAt: Date.now(), + lastEventAt: Date.now(), + }); + + if (!account.configured) { + aLog.error(`account not configured`); + ctx.log?.error?.( + `[${account.accountId}] weixin not logged in — run: openclaw channels login --channel openclaw-weixin`, + ); + ctx.setStatus?.({ accountId: account.accountId, running: false }); + throw new Error("weixin not configured: missing token"); + } + + ctx.log?.info?.(`[${account.accountId}] starting weixin provider (${DEFAULT_BASE_URL})`); + + const logPath = aLog.getLogFilePath(); + ctx.log?.info?.(`[${account.accountId}] weixin logs: ${logPath}`); + + return monitorWeixinProvider({ + baseUrl: account.baseUrl, + cdnBaseUrl: account.cdnBaseUrl, + token: account.token, + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + setStatus: ctx.setStatus, + }); + }, + loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => { + // For re-login: use saved baseUrl from account data; fall back to default for new accounts. + const savedBaseUrl = accountId ? loadWeixinAccount(accountId)?.baseUrl?.trim() : ""; + const result: WeixinQrStartResult = await startWeixinLoginWithQr({ + accountId: accountId ?? undefined, + apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL, + botType: DEFAULT_ILINK_BOT_TYPE, + force, + timeoutMs, + verbose, + }); + // Return sessionKey so the client can pass it back in loginWithQrWait. + return { + qrDataUrl: result.qrcodeUrl, + message: result.message, + sessionKey: result.sessionKey, + } as { qrDataUrl?: string; message: string }; + }, + loginWithQrWait: async (params) => { + // sessionKey is forwarded by the client after loginWithQrStart (runtime param extension). + const sessionKey = (params as { sessionKey?: string }).sessionKey || params.accountId || ""; + const savedBaseUrl = params.accountId + ? loadWeixinAccount(params.accountId)?.baseUrl?.trim() + : ""; + const result: WeixinQrWaitResult = await waitForWeixinLogin({ + sessionKey, + apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL, + timeoutMs: params.timeoutMs, + }); + + if (result.connected && result.botToken && result.accountId) { + try { + const normalizedId = normalizeAccountId(result.accountId); + saveWeixinAccount(normalizedId, { + token: result.botToken, + baseUrl: result.baseUrl, + userId: result.userId, + }); + registerWeixinAccountId(normalizedId); + triggerWeixinChannelReload(); + logger.info(`loginWithQrWait: saved account data for accountId=${normalizedId}`); + } catch (err) { + logger.error(`loginWithQrWait: failed to save account data err=${String(err)}`); + } + } + + return { + connected: result.connected, + message: result.message, + accountId: result.accountId, + } as { connected: boolean; message: string }; + }, + }, +}; diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/config/config-schema.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/config/config-schema.ts" new file mode 100644 index 00000000..e23c67c9 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/config/config-schema.ts" @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { CDN_BASE_URL, DEFAULT_BASE_URL } from "../auth/accounts.js"; + +// --------------------------------------------------------------------------- +// Zod config schema +// --------------------------------------------------------------------------- + +const weixinAccountSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + baseUrl: z.string().default(DEFAULT_BASE_URL), + cdnBaseUrl: z.string().default(CDN_BASE_URL), + routeTag: z.number().optional(), +}); + +/** Top-level weixin config schema (token is stored in credentials file, not config). */ +export const WeixinConfigSchema = weixinAccountSchema.extend({ + accounts: z.record(z.string(), weixinAccountSchema).optional(), + /** Default URL for `openclaw openclaw-weixin logs-upload`. Set via `openclaw config set channels.openclaw-weixin.logUploadUrl <url>`. */ + logUploadUrl: z.string().optional(), +}); diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/log-upload.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/log-upload.ts" new file mode 100644 index 00000000..c7adaaaf --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/log-upload.ts" @@ -0,0 +1,126 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { OpenClawConfig } from "openclaw/plugin-sdk"; + + +/** Minimal subset of commander's Command used by registerWeixinCli. */ +type CliCommand = { + command(name: string): CliCommand; + description(str: string): CliCommand; + option(flags: string, description: string): CliCommand; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + action(fn: (...args: any[]) => void | Promise<void>): CliCommand; +}; + +function currentDayLogFileName(): string { + const now = new Date(); + const offsetMs = -now.getTimezoneOffset() * 60_000; + const dateKey = new Date(now.getTime() + offsetMs).toISOString().slice(0, 10); + return `openclaw-${dateKey}.log`; +} + +/** + * Parse --file argument: accepts a short 8-digit date (YYYYMMDD) + * like "20260316", a full filename like "openclaw-2026-03-16.log", + * or a legacy 10-digit hour timestamp "2026031614". + */ +function resolveLogFileName(file: string): string { + if (/^\d{8}$/.test(file)) { + const yyyy = file.slice(0, 4); + const mm = file.slice(4, 6); + const dd = file.slice(6, 8); + return `openclaw-${yyyy}-${mm}-${dd}.log`; + } + if (/^\d{10}$/.test(file)) { + const yyyy = file.slice(0, 4); + const mm = file.slice(4, 6); + const dd = file.slice(6, 8); + return `openclaw-${yyyy}-${mm}-${dd}.log`; + } + return file; +} + +function mainLogDir(): string { + return path.join("/tmp", "openclaw"); +} + +function getConfiguredUploadUrl(config: OpenClawConfig): string | undefined { + const section = config.channels?.["openclaw-weixin"] as { logUploadUrl?: string } | undefined; + return section?.logUploadUrl; +} + +/** Register the `openclaw openclaw-weixin logs-upload` CLI subcommand. */ +export function registerWeixinCli(params: { program: CliCommand; config: OpenClawConfig }): void { + const { program, config } = params; + + const root = program.command("openclaw-weixin").description("Weixin channel utilities"); + + root + .command("logs-upload") + .description("Upload a Weixin log file to a remote URL via HTTP POST") + .option("--url <url>", "Remote URL to POST the log file to (overrides config)") + .option( + "--file <file>", + "Log file to upload: full filename or 8-digit date YYYYMMDD (default: today)", + ) + .action(async (options: { url?: string; file?: string }) => { + const uploadUrl = options.url ?? getConfiguredUploadUrl(config); + if (!uploadUrl) { + console.error( + `[weixin] No upload URL specified. Pass --url or set it with:\n openclaw config set channels.openclaw-weixin.logUploadUrl <url>`, + ); + process.exit(1); + } + + const logDir = mainLogDir(); + const rawFile = options.file ?? currentDayLogFileName(); + const fileName = resolveLogFileName(rawFile); + const filePath = path.isAbsolute(fileName) ? fileName : path.join(logDir, fileName); + + let content: Buffer; + try { + content = await fs.readFile(filePath); + } catch (err) { + console.error(`[weixin] Failed to read log file: ${filePath}\n ${String(err)}`); + process.exit(1); + } + + console.log(`[weixin] Uploading ${filePath} (${content.length} bytes) to ${uploadUrl} ...`); + + const formData = new FormData(); + formData.append("file", new Blob([new Uint8Array(content)], { type: "text/plain" }), fileName); + + let res: Response; + try { + res = await fetch(uploadUrl, { method: "POST", body: formData }); + } catch (err) { + console.error(`[weixin] Upload request failed: ${String(err)}`); + process.exit(1); + } + + const responseBody = await res.text().catch(() => ""); + if (!res.ok) { + console.error( + `[weixin] Upload failed: HTTP ${res.status} ${res.statusText}\n ${responseBody}`, + ); + process.exit(1); + } + + console.log(`[weixin] Upload succeeded (HTTP ${res.status})`); + const fileid = res.headers.get("fileid"); + if (fileid) { + console.log(`fileid: ${fileid}`); + } else { + // fileid not found; dump all headers for diagnosis + const headers: Record<string, string> = {}; + res.headers.forEach((value, key) => { + headers[key] = value; + }); + console.log("headers:", JSON.stringify(headers, null, 2)); + } + if (responseBody) { + console.log("body:", responseBody); + } + }); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/media-download.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/media-download.ts" new file mode 100644 index 00000000..38eeb2fc --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/media-download.ts" @@ -0,0 +1,141 @@ +import type { WeixinInboundMediaOpts } from "../messaging/inbound.js"; +import { logger } from "../util/logger.js"; +import { getMimeFromFilename } from "./mime.js"; +import { + downloadAndDecryptBuffer, + downloadPlainCdnBuffer, +} from "../cdn/pic-decrypt.js"; +import { silkToWav } from "./silk-transcode.js"; +import type { WeixinMessage } from "../api/types.js"; +import { MessageItemType } from "../api/types.js"; + +const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024; + +/** Persist a buffer via the framework's unified media store. */ +type SaveMediaFn = ( + buffer: Buffer, + contentType?: string, + subdir?: string, + maxBytes?: number, + originalFilename?: string, +) => Promise<{ path: string }>; + +/** + * Download and decrypt media from a single MessageItem. + * Returns the populated WeixinInboundMediaOpts fields; empty object on unsupported type or failure. + */ +export async function downloadMediaFromItem( + item: WeixinMessage["item_list"] extends (infer T)[] | undefined ? T : never, + deps: { + cdnBaseUrl: string; + saveMedia: SaveMediaFn; + log: (msg: string) => void; + errLog: (msg: string) => void; + label: string; + }, +): Promise<WeixinInboundMediaOpts> { + const { cdnBaseUrl, saveMedia, log, errLog, label } = deps; + const result: WeixinInboundMediaOpts = {}; + + if (item.type === MessageItemType.IMAGE) { + const img = item.image_item; + if (!img?.media?.encrypt_query_param) return result; + const aesKeyBase64 = img.aeskey + ? Buffer.from(img.aeskey, "hex").toString("base64") + : img.media.aes_key; + logger.debug( + `${label} image: encrypt_query_param=${img.media.encrypt_query_param.slice(0, 40)}... hasAesKey=${Boolean(aesKeyBase64)} aeskeySource=${img.aeskey ? "image_item.aeskey" : "media.aes_key"}`, + ); + try { + const buf = aesKeyBase64 + ? await downloadAndDecryptBuffer( + img.media.encrypt_query_param, + aesKeyBase64, + cdnBaseUrl, + `${label} image`, + ) + : await downloadPlainCdnBuffer( + img.media.encrypt_query_param, + cdnBaseUrl, + `${label} image-plain`, + ); + const saved = await saveMedia(buf, undefined, "inbound", WEIXIN_MEDIA_MAX_BYTES); + result.decryptedPicPath = saved.path; + logger.debug(`${label} image saved: ${saved.path}`); + } catch (err) { + logger.error(`${label} image download/decrypt failed: ${String(err)}`); + errLog(`weixin ${label} image download/decrypt failed: ${String(err)}`); + } + } else if (item.type === MessageItemType.VOICE) { + const voice = item.voice_item; + if (!voice?.media?.encrypt_query_param || !voice.media.aes_key) return result; + try { + const silkBuf = await downloadAndDecryptBuffer( + voice.media.encrypt_query_param, + voice.media.aes_key, + cdnBaseUrl, + `${label} voice`, + ); + logger.debug(`${label} voice: decrypted ${silkBuf.length} bytes, attempting silk transcode`); + const wavBuf = await silkToWav(silkBuf); + if (wavBuf) { + const saved = await saveMedia(wavBuf, "audio/wav", "inbound", WEIXIN_MEDIA_MAX_BYTES); + result.decryptedVoicePath = saved.path; + result.voiceMediaType = "audio/wav"; + logger.debug(`${label} voice: saved WAV to ${saved.path}`); + } else { + const saved = await saveMedia(silkBuf, "audio/silk", "inbound", WEIXIN_MEDIA_MAX_BYTES); + result.decryptedVoicePath = saved.path; + result.voiceMediaType = "audio/silk"; + logger.debug(`${label} voice: silk transcode unavailable, saved raw SILK to ${saved.path}`); + } + } catch (err) { + logger.error(`${label} voice download/transcode failed: ${String(err)}`); + errLog(`weixin ${label} voice download/transcode failed: ${String(err)}`); + } + } else if (item.type === MessageItemType.FILE) { + const fileItem = item.file_item; + if (!fileItem?.media?.encrypt_query_param || !fileItem.media.aes_key) return result; + try { + const buf = await downloadAndDecryptBuffer( + fileItem.media.encrypt_query_param, + fileItem.media.aes_key, + cdnBaseUrl, + `${label} file`, + ); + const mime = getMimeFromFilename(fileItem.file_name ?? "file.bin"); + const saved = await saveMedia( + buf, + mime, + "inbound", + WEIXIN_MEDIA_MAX_BYTES, + fileItem.file_name ?? undefined, + ); + result.decryptedFilePath = saved.path; + result.fileMediaType = mime; + logger.debug(`${label} file: saved to ${saved.path} mime=${mime}`); + } catch (err) { + logger.error(`${label} file download failed: ${String(err)}`); + errLog(`weixin ${label} file download failed: ${String(err)}`); + } + } else if (item.type === MessageItemType.VIDEO) { + const videoItem = item.video_item; + if (!videoItem?.media?.encrypt_query_param || !videoItem.media.aes_key) return result; + try { + const buf = await downloadAndDecryptBuffer( + videoItem.media.encrypt_query_param, + videoItem.media.aes_key, + cdnBaseUrl, + `${label} video`, + ); + const saved = await saveMedia(buf, "video/mp4", "inbound", WEIXIN_MEDIA_MAX_BYTES); + result.decryptedVideoPath = saved.path; + logger.debug(`${label} video: saved to ${saved.path}`); + } catch (err) { + logger.error(`${label} video download failed: ${String(err)}`); + errLog(`weixin ${label} video download failed: ${String(err)}`); + } + } + + return result; +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/mime.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/mime.ts" new file mode 100644 index 00000000..08f39e26 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/mime.ts" @@ -0,0 +1,76 @@ +import path from "node:path"; + +const EXTENSION_TO_MIME: Record<string, string> = { + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".txt": "text/plain", + ".csv": "text/csv", + ".zip": "application/zip", + ".tar": "application/x-tar", + ".gz": "application/gzip", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".wav": "audio/wav", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", +}; + +const MIME_TO_EXTENSION: Record<string, string> = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", + "video/mp4": ".mp4", + "video/quicktime": ".mov", + "video/webm": ".webm", + "video/x-matroska": ".mkv", + "video/x-msvideo": ".avi", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "application/pdf": ".pdf", + "application/zip": ".zip", + "application/x-tar": ".tar", + "application/gzip": ".gz", + "text/plain": ".txt", + "text/csv": ".csv", +}; + +/** Get MIME type from filename extension. Returns "application/octet-stream" for unknown extensions. */ +export function getMimeFromFilename(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + return EXTENSION_TO_MIME[ext] ?? "application/octet-stream"; +} + +/** Get file extension from MIME type. Returns ".bin" for unknown types. */ +export function getExtensionFromMime(mimeType: string): string { + const ct = mimeType.split(";")[0].trim().toLowerCase(); + return MIME_TO_EXTENSION[ct] ?? ".bin"; +} + +/** Get file extension from Content-Type header or URL path. Returns ".bin" for unknown. */ +export function getExtensionFromContentTypeOrUrl(contentType: string | null, url: string): string { + if (contentType) { + const ext = getExtensionFromMime(contentType); + if (ext !== ".bin") return ext; + } + const ext = path.extname(new URL(url).pathname).toLowerCase(); + const knownExts = new Set(Object.keys(EXTENSION_TO_MIME)); + return knownExts.has(ext) ? ext : ".bin"; +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/silk-transcode.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/silk-transcode.ts" new file mode 100644 index 00000000..473458aa --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/silk-transcode.ts" @@ -0,0 +1,74 @@ +import { logger } from "../util/logger.js"; + +/** Default sample rate for Weixin voice messages. */ +const SILK_SAMPLE_RATE = 24_000; + +/** + * Wrap raw pcm_s16le bytes in a WAV container. + * Mono channel, 16-bit signed little-endian. + */ +function pcmBytesToWav(pcm: Uint8Array, sampleRate: number): Buffer { + const pcmBytes = pcm.byteLength; + const totalSize = 44 + pcmBytes; + const buf = Buffer.allocUnsafe(totalSize); + let offset = 0; + + buf.write("RIFF", offset); + offset += 4; + buf.writeUInt32LE(totalSize - 8, offset); + offset += 4; + buf.write("WAVE", offset); + offset += 4; + + buf.write("fmt ", offset); + offset += 4; + buf.writeUInt32LE(16, offset); + offset += 4; // fmt chunk size + buf.writeUInt16LE(1, offset); + offset += 2; // PCM format + buf.writeUInt16LE(1, offset); + offset += 2; // mono + buf.writeUInt32LE(sampleRate, offset); + offset += 4; + buf.writeUInt32LE(sampleRate * 2, offset); + offset += 4; // byte rate (mono 16-bit) + buf.writeUInt16LE(2, offset); + offset += 2; // block align + buf.writeUInt16LE(16, offset); + offset += 2; // bits per sample + + buf.write("data", offset); + offset += 4; + buf.writeUInt32LE(pcmBytes, offset); + offset += 4; + + Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset); + + return buf; +} + +/** + * Try to transcode a SILK audio buffer to WAV using silk-wasm. + * silk-wasm's decode() returns { data: Uint8Array (pcm_s16le), duration: number }. + * + * Returns a WAV Buffer on success, or null if silk-wasm is unavailable or decoding fails. + * Callers should fall back to passing the raw SILK file when null is returned. + */ +export async function silkToWav(silkBuf: Buffer): Promise<Buffer | null> { + try { + const { decode } = await import("silk-wasm"); + + logger.debug(`silkToWav: decoding ${silkBuf.length} bytes of SILK`); + const result = await decode(silkBuf, SILK_SAMPLE_RATE); + logger.debug( + `silkToWav: decoded duration=${result.duration}ms pcmBytes=${result.data.byteLength}`, + ); + + const wav = pcmBytesToWav(result.data, SILK_SAMPLE_RATE); + logger.debug(`silkToWav: WAV size=${wav.length}`); + return wav; + } catch (err) { + logger.warn(`silkToWav: transcode failed, will use raw silk err=${String(err)}`); + return null; + } +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/debug-mode.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/debug-mode.ts" new file mode 100644 index 00000000..7d4ce097 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/debug-mode.ts" @@ -0,0 +1,69 @@ +/** + * Per-bot debug mode toggle, persisted to disk so it survives gateway restarts. + * + * State file: `<stateDir>/openclaw-weixin/debug-mode.json` + * Format: `{ "accounts": { "<accountId>": true, ... } }` + * + * When enabled, processOneMessage appends a timing summary after each + * AI reply is delivered to the user. + */ +import fs from "node:fs"; +import path from "node:path"; + +import { resolveStateDir } from "../storage/state-dir.js"; +import { logger } from "../util/logger.js"; + +interface DebugModeState { + accounts: Record<string, boolean>; +} + +function resolveDebugModePath(): string { + return path.join(resolveStateDir(), "openclaw-weixin", "debug-mode.json"); +} + +function loadState(): DebugModeState { + try { + const raw = fs.readFileSync(resolveDebugModePath(), "utf-8"); + const parsed = JSON.parse(raw) as DebugModeState; + if (parsed && typeof parsed.accounts === "object") return parsed; + } catch { + // missing or corrupt — start fresh + } + return { accounts: {} }; +} + +function saveState(state: DebugModeState): void { + const filePath = resolveDebugModePath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8"); +} + +/** Toggle debug mode for a bot account. Returns the new state. */ +export function toggleDebugMode(accountId: string): boolean { + const state = loadState(); + const next = !state.accounts[accountId]; + state.accounts[accountId] = next; + try { + saveState(state); + } catch (err) { + logger.error(`debug-mode: failed to persist state: ${String(err)}`); + } + return next; +} + +/** Check whether debug mode is active for a bot account. */ +export function isDebugMode(accountId: string): boolean { + return loadState().accounts[accountId] === true; +} + +/** + * Reset internal state — only for tests. + * @internal + */ +export function _resetForTest(): void { + try { + fs.unlinkSync(resolveDebugModePath()); + } catch { + // ignore if not present + } +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/error-notice.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/error-notice.ts" new file mode 100644 index 00000000..00e0fa02 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/error-notice.ts" @@ -0,0 +1,31 @@ +import { logger } from "../util/logger.js"; +import { sendMessageWeixin } from "./send.js"; + +/** + * Send a plain-text error notice back to the user. + * Fire-and-forget: errors are logged but never thrown, so callers stay unaffected. + * No-op when contextToken is absent (we have no conversation reference to reply into). + */ +export async function sendWeixinErrorNotice(params: { + to: string; + contextToken: string | undefined; + message: string; + baseUrl: string; + token?: string; + errLog: (m: string) => void; +}): Promise<void> { + if (!params.contextToken) { + logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, cannot notify user`); + return; + } + try { + await sendMessageWeixin({ to: params.to, text: params.message, opts: { + baseUrl: params.baseUrl, + token: params.token, + contextToken: params.contextToken, + }}); + logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`); + } catch (err) { + params.errLog(`[weixin] sendWeixinErrorNotice failed to=${params.to}: ${String(err)}`); + } +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/inbound.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/inbound.ts" new file mode 100644 index 00000000..86ee190f --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/inbound.ts" @@ -0,0 +1,171 @@ +import { logger } from "../util/logger.js"; +import { generateId } from "../util/random.js"; +import type { WeixinMessage, MessageItem } from "../api/types.js"; +import { MessageItemType } from "../api/types.js"; + +// --------------------------------------------------------------------------- +// Context token store (in-process cache: accountId+userId → contextToken) +// --------------------------------------------------------------------------- + +/** + * contextToken is issued per-message by the Weixin getupdates API and must + * be echoed verbatim in every outbound send. It is not persisted: the monitor + * loop populates this map on each inbound message, and the outbound adapter + * reads it back when the agent sends a reply. + */ +const contextTokenStore = new Map<string, string>(); + +function contextTokenKey(accountId: string, userId: string): string { + return `${accountId}:${userId}`; +} + +/** Store a context token for a given account+user pair. */ +export function setContextToken(accountId: string, userId: string, token: string): void { + const k = contextTokenKey(accountId, userId); + logger.debug(`setContextToken: key=${k}`); + contextTokenStore.set(k, token); +} + +/** Retrieve the cached context token for a given account+user pair. */ +export function getContextToken(accountId: string, userId: string): string | undefined { + const k = contextTokenKey(accountId, userId); + const val = contextTokenStore.get(k); + logger.debug( + `getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`, + ); + return val; +} + +// --------------------------------------------------------------------------- +// Message ID generation +// --------------------------------------------------------------------------- + +function generateMessageSid(): string { + return generateId("openclaw-weixin"); +} + +/** Inbound context passed to the OpenClaw core pipeline (matches MsgContext shape). */ +export type WeixinMsgContext = { + Body: string; + From: string; + To: string; + AccountId: string; + OriginatingChannel: "openclaw-weixin"; + OriginatingTo: string; + MessageSid: string; + Timestamp?: number; + Provider: "openclaw-weixin"; + ChatType: "direct"; + /** Set by monitor after resolveAgentRoute so dispatchReplyFromConfig uses the correct session. */ + SessionKey?: string; + context_token?: string; + MediaUrl?: string; + MediaPath?: string; + MediaType?: string; + /** Raw message body for framework command authorization. */ + CommandBody?: string; + /** Whether the sender is authorized to execute slash commands. */ + CommandAuthorized?: boolean; +}; + +/** Returns true if the message item is a media type (image, video, file, or voice). */ +export function isMediaItem(item: MessageItem): boolean { + return ( + item.type === MessageItemType.IMAGE || + item.type === MessageItemType.VIDEO || + item.type === MessageItemType.FILE || + item.type === MessageItemType.VOICE + ); +} + +function bodyFromItemList(itemList?: MessageItem[]): string { + if (!itemList?.length) return ""; + for (const item of itemList) { + if (item.type === MessageItemType.TEXT && item.text_item?.text != null) { + const text = String(item.text_item.text); + const ref = item.ref_msg; + if (!ref) return text; + // Quoted media is passed as MediaPath; only include the current text as body. + if (ref.message_item && isMediaItem(ref.message_item)) return text; + // Build quoted context from both title and message_item content. + const parts: string[] = []; + if (ref.title) parts.push(ref.title); + if (ref.message_item) { + const refBody = bodyFromItemList([ref.message_item]); + if (refBody) parts.push(refBody); + } + if (!parts.length) return text; + return `[引用: ${parts.join(" | ")}]\n${text}`; + } + // 语音转文字:如果语音消息有 text 字段,直接使用文字内容 + if (item.type === MessageItemType.VOICE && item.voice_item?.text) { + return item.voice_item.text; + } + } + return ""; +} + +export type WeixinInboundMediaOpts = { + /** Local path to decrypted image file. */ + decryptedPicPath?: string; + /** Local path to transcoded/raw voice file (.wav or .silk). */ + decryptedVoicePath?: string; + /** MIME type for the voice file (e.g. "audio/wav" or "audio/silk"). */ + voiceMediaType?: string; + /** Local path to decrypted file attachment. */ + decryptedFilePath?: string; + /** MIME type for the file attachment (guessed from file_name). */ + fileMediaType?: string; + /** Local path to decrypted video file. */ + decryptedVideoPath?: string; +}; + +/** + * Convert a WeixinMessage from getUpdates to the inbound MsgContext for the core pipeline. + * Media: only pass MediaPath (local file, after CDN download + decrypt). + * We never pass MediaUrl — the upstream CDN URL is encrypted/auth-only. + * Priority when multiple media types present: image > video > file > voice. + */ +export function weixinMessageToMsgContext( + msg: WeixinMessage, + accountId: string, + opts?: WeixinInboundMediaOpts, +): WeixinMsgContext { + const from_user_id = msg.from_user_id ?? ""; + const ctx: WeixinMsgContext = { + Body: bodyFromItemList(msg.item_list), + From: from_user_id, + To: from_user_id, + AccountId: accountId, + OriginatingChannel: "openclaw-weixin", + OriginatingTo: from_user_id, + MessageSid: generateMessageSid(), + Timestamp: msg.create_time_ms, + Provider: "openclaw-weixin", + ChatType: "direct", + }; + if (msg.context_token) { + ctx.context_token = msg.context_token; + } + + if (opts?.decryptedPicPath) { + ctx.MediaPath = opts.decryptedPicPath; + ctx.MediaType = "image/*"; + } else if (opts?.decryptedVideoPath) { + ctx.MediaPath = opts.decryptedVideoPath; + ctx.MediaType = "video/mp4"; + } else if (opts?.decryptedFilePath) { + ctx.MediaPath = opts.decryptedFilePath; + ctx.MediaType = opts.fileMediaType ?? "application/octet-stream"; + } else if (opts?.decryptedVoicePath) { + ctx.MediaPath = opts.decryptedVoicePath; + ctx.MediaType = opts.voiceMediaType ?? "audio/wav"; + } + + return ctx; +} + +/** Extract the context_token from an inbound WeixinMsgContext. */ +export function getContextTokenFromMsgContext(ctx: WeixinMsgContext): string | undefined { + return ctx.context_token; +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/process-message.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/process-message.ts" new file mode 100644 index 00000000..d9d9508f --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/process-message.ts" @@ -0,0 +1,481 @@ +import path from "node:path"; + +import { + createTypingCallbacks, + resolveSenderCommandAuthorizationWithRuntime, + resolveDirectDmAuthorizationOutcome, + resolvePreferredOpenClawTmpDir, +} from "openclaw/plugin-sdk"; +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +import { sendTyping } from "../api/api.js"; +import type { WeixinMessage } from "../api/types.js"; +import { MessageItemType, TypingStatus } from "../api/types.js"; +import { loadWeixinAccount } from "../auth/accounts.js"; +import { readFrameworkAllowFromList } from "../auth/pairing.js"; +import { downloadRemoteImageToTemp } from "../cdn/upload.js"; +import { downloadMediaFromItem } from "../media/media-download.js"; +import { logger } from "../util/logger.js"; +import { redactBody, redactToken } from "../util/redact.js"; + +import { isDebugMode } from "./debug-mode.js"; +import { sendWeixinErrorNotice } from "./error-notice.js"; +import { + setContextToken, + weixinMessageToMsgContext, + getContextTokenFromMsgContext, + isMediaItem, +} from "./inbound.js"; +import type { WeixinInboundMediaOpts } from "./inbound.js"; +import { sendWeixinMediaFile } from "./send-media.js"; +import { markdownToPlainText, sendMessageWeixin } from "./send.js"; +import { handleSlashCommand } from "./slash-commands.js"; + +const MEDIA_OUTBOUND_TEMP_DIR = path.join(resolvePreferredOpenClawTmpDir(), "weixin/media/outbound-temp"); + +/** Dependencies for processOneMessage, injected by the monitor loop. */ +export type ProcessMessageDeps = { + accountId: string; + config: import("openclaw/plugin-sdk/core").OpenClawConfig; + channelRuntime: PluginRuntime["channel"]; + baseUrl: string; + cdnBaseUrl: string; + token?: string; + typingTicket?: string; + log: (msg: string) => void; + errLog: (m: string) => void; +}; + +/** Extract text body from item_list (for slash command detection). */ +function extractTextBody(itemList?: import("../api/types.js").MessageItem[]): string { + if (!itemList?.length) return ""; + for (const item of itemList) { + if (item.type === MessageItemType.TEXT && item.text_item?.text != null) { + return String(item.text_item.text); + } + } + return ""; +} + +/** + * Process a single inbound message: route → download media → dispatch reply. + * Extracted from the monitor loop to keep monitoring and message handling separate. + */ +export async function processOneMessage( + full: WeixinMessage, + deps: ProcessMessageDeps, +): Promise<void> { + if (!deps?.channelRuntime) { + logger.error( + `processOneMessage: channelRuntime is undefined, skipping message from=${full.from_user_id}`, + ); + deps.errLog("processOneMessage: channelRuntime is undefined, skip"); + return; + } + + const receivedAt = Date.now(); + const debug = isDebugMode(deps.accountId); + const debugTrace: string[] = []; + const debugTs: Record<string, number> = { received: receivedAt }; + + const textBody = extractTextBody(full.item_list); + if (textBody.startsWith("/")) { + const slashResult = await handleSlashCommand(textBody, { + to: full.from_user_id ?? "", + contextToken: full.context_token, + baseUrl: deps.baseUrl, + token: deps.token, + accountId: deps.accountId, + log: deps.log, + errLog: deps.errLog, + }, receivedAt, full.create_time_ms); + if (slashResult.handled) { + logger.info(`[weixin] Slash command handled, skipping AI pipeline`); + return; + } + } + + if (debug) { + const itemTypes = full.item_list?.map((i) => i.type).join(",") ?? "none"; + debugTrace.push( + "── 收消息 ──", + `│ seq=${full.seq ?? "?"} msgId=${full.message_id ?? "?"} from=${full.from_user_id ?? "?"}`, + `│ body="${textBody.slice(0, 40)}${textBody.length > 40 ? "…" : ""}" (len=${textBody.length}) itemTypes=[${itemTypes}]`, + `│ sessionId=${full.session_id ?? "?"} contextToken=${full.context_token ? "present" : "none"}`, + ); + } + + const mediaOpts: WeixinInboundMediaOpts = {}; + + // Find the first downloadable media item (priority: IMAGE > VIDEO > FILE > VOICE). + // When none found in the main item_list, fall back to media referenced via a quoted message. + const mainMediaItem = + full.item_list?.find( + (i) => i.type === MessageItemType.IMAGE && i.image_item?.media?.encrypt_query_param, + ) ?? + full.item_list?.find( + (i) => i.type === MessageItemType.VIDEO && i.video_item?.media?.encrypt_query_param, + ) ?? + full.item_list?.find( + (i) => i.type === MessageItemType.FILE && i.file_item?.media?.encrypt_query_param, + ) ?? + full.item_list?.find( + (i) => + i.type === MessageItemType.VOICE && + i.voice_item?.media?.encrypt_query_param && + !i.voice_item.text, + ); + const refMediaItem = !mainMediaItem + ? full.item_list?.find( + (i) => + i.type === MessageItemType.TEXT && + i.ref_msg?.message_item && + isMediaItem(i.ref_msg.message_item!), + )?.ref_msg?.message_item + : undefined; + + const mediaDownloadStart = Date.now(); + const mediaItem = mainMediaItem ?? refMediaItem; + if (mediaItem) { + const label = refMediaItem ? "ref" : "inbound"; + const downloaded = await downloadMediaFromItem(mediaItem, { + cdnBaseUrl: deps.cdnBaseUrl, + saveMedia: deps.channelRuntime.media.saveMediaBuffer, + log: deps.log, + errLog: deps.errLog, + label, + }); + Object.assign(mediaOpts, downloaded); + } + const mediaDownloadMs = Date.now() - mediaDownloadStart; + + if (debug) { + debugTrace.push(mediaItem + ? `│ mediaDownload: type=${mediaItem.type} cost=${mediaDownloadMs}ms` + : "│ mediaDownload: none", + ); + } + + const ctx = weixinMessageToMsgContext(full, deps.accountId, mediaOpts); + + // --- Framework command authorization --- + const rawBody = ctx.Body?.trim() ?? ""; + ctx.CommandBody = rawBody; + + const senderId = full.from_user_id ?? ""; + + const { senderAllowedForCommands, commandAuthorized } = + await resolveSenderCommandAuthorizationWithRuntime({ + cfg: deps.config, + rawBody, + isGroup: false, + dmPolicy: "pairing", + configuredAllowFrom: [], + configuredGroupAllowFrom: [], + senderId, + isSenderAllowed: (id: string, list: string[]) => list.length === 0 || list.includes(id), + /** Pairing: framework credentials `*-allowFrom.json`, with account `userId` fallback for legacy installs. */ + readAllowFromStore: async () => { + const fromStore = readFrameworkAllowFromList(deps.accountId); + if (fromStore.length > 0) return fromStore; + const uid = loadWeixinAccount(deps.accountId)?.userId?.trim(); + return uid ? [uid] : []; + }, + runtime: deps.channelRuntime.commands, + }); + + const directDmOutcome = resolveDirectDmAuthorizationOutcome({ + isGroup: false, + dmPolicy: "pairing", + senderAllowedForCommands, + }); + + if (directDmOutcome === "disabled" || directDmOutcome === "unauthorized") { + logger.info( + `authorization: dropping message from=${senderId} outcome=${directDmOutcome}`, + ); + return; + } + + ctx.CommandAuthorized = commandAuthorized; + logger.debug( + `authorization: senderId=${senderId} commandAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`, + ); + + if (debug) { + debugTrace.push( + "── 鉴权 & 路由 ──", + `│ auth: cmdAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`, + ); + } + + const route = deps.channelRuntime.routing.resolveAgentRoute({ + cfg: deps.config, + channel: "openclaw-weixin", + accountId: deps.accountId, + peer: { kind: "direct", id: ctx.To }, + }); + logger.debug( + `resolveAgentRoute: agentId=${route.agentId ?? "(none)"} sessionKey=${route.sessionKey ?? "(none)"} mainSessionKey=${route.mainSessionKey ?? "(none)"}`, + ); + if (!route.agentId) { + logger.error( + `resolveAgentRoute: no agentId resolved for peer=${ctx.To} accountId=${deps.accountId} — message will not be dispatched`, + ); + } + + if (debug) { + debugTrace.push( + `│ route: agent=${route.agentId ?? "none"} session=${route.sessionKey ?? "none"}`, + ); + debugTs.preDispatch = Date.now(); + } + // Propagate the resolved session key into ctx so dispatchReplyFromConfig uses + // the correct session (matching the dmScope from config) instead of falling back + // to agent:main:main. + ctx.SessionKey = route.sessionKey; + const storePath = deps.channelRuntime.session.resolveStorePath(deps.config.session?.store, { + agentId: route.agentId, + }); + const finalized = deps.channelRuntime.reply.finalizeInboundContext( + ctx as Parameters<typeof deps.channelRuntime.reply.finalizeInboundContext>[0], + ); + + logger.info( + `inbound: from=${finalized.From} to=${finalized.To} bodyLen=${(finalized.Body ?? "").length} hasMedia=${Boolean(finalized.MediaPath ?? finalized.MediaUrl)}`, + ); + logger.debug(`inbound context: ${redactBody(JSON.stringify(finalized))}`); + + await deps.channelRuntime.session.recordInboundSession({ + storePath, + sessionKey: route.sessionKey, + ctx: finalized as Parameters<typeof deps.channelRuntime.session.recordInboundSession>[0]["ctx"], + updateLastRoute: { + sessionKey: route.mainSessionKey, + channel: "openclaw-weixin", + to: ctx.To, + accountId: deps.accountId, + }, + onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`), + }); + logger.debug( + `recordInboundSession: done storePath=${storePath} sessionKey=${route.sessionKey ?? "(none)"}`, + ); + + const contextToken = getContextTokenFromMsgContext(ctx); + if (contextToken) { + setContextToken(deps.accountId, full.from_user_id ?? "", contextToken); + } + const humanDelay = deps.channelRuntime.reply.resolveHumanDelayConfig(deps.config, route.agentId); + + const hasTypingTicket = Boolean(deps.typingTicket); + const typingCallbacks = createTypingCallbacks({ + start: hasTypingTicket + ? () => + sendTyping({ + baseUrl: deps.baseUrl, + token: deps.token, + body: { + ilink_user_id: ctx.To, + typing_ticket: deps.typingTicket!, + status: TypingStatus.TYPING, + }, + }) + : async () => {}, + stop: hasTypingTicket + ? () => + sendTyping({ + baseUrl: deps.baseUrl, + token: deps.token, + body: { + ilink_user_id: ctx.To, + typing_ticket: deps.typingTicket!, + status: TypingStatus.CANCEL, + }, + }) + : async () => {}, + onStartError: (err) => deps.log(`[weixin] typing send error: ${String(err)}`), + onStopError: (err) => deps.log(`[weixin] typing cancel error: ${String(err)}`), + keepaliveIntervalMs: 5000, + }); + + /** Delivery records populated synchronously at deliver() entry, safe to read in finally. */ + const debugDeliveries: Array<{ textLen: number; media: string; preview: string; ts: number }> = []; + + const { dispatcher, replyOptions, markDispatchIdle } = + deps.channelRuntime.reply.createReplyDispatcherWithTyping({ + humanDelay, + typingCallbacks, + deliver: async (payload) => { + const text = markdownToPlainText(payload.text ?? ""); + const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0]; + logger.debug(`outbound payload: ${redactBody(JSON.stringify(payload))}`); + logger.info( + `outbound: to=${ctx.To} contextToken=${redactToken(contextToken)} textLen=${text.length} mediaUrl=${mediaUrl ? "present" : "none"}`, + ); + + if (debug) { + debugDeliveries.push({ + textLen: text.length, + media: mediaUrl ? "present" : "none", + preview: `${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`, + ts: Date.now(), + }); + } + + try { + if (mediaUrl) { + let filePath: string; + if (!mediaUrl.includes("://") || mediaUrl.startsWith("file://")) { + // Local path: absolute, relative, or file:// URL + if (mediaUrl.startsWith("file://")) { + filePath = new URL(mediaUrl).pathname; + } else if (!path.isAbsolute(mediaUrl)) { + filePath = path.resolve(mediaUrl); + logger.debug(`outbound: resolved relative path ${mediaUrl} -> ${filePath}`); + } else { + filePath = mediaUrl; + } + logger.debug(`outbound: local file path resolved filePath=${filePath}`); + } else if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) { + logger.debug(`outbound: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`); + filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR); + logger.debug(`outbound: remote image downloaded to filePath=${filePath}`); + } else { + logger.warn( + `outbound: unrecognized mediaUrl scheme, sending text only mediaUrl=${mediaUrl.slice(0, 80)}`, + ); + await sendMessageWeixin({ to: ctx.To, text, opts: { + baseUrl: deps.baseUrl, + token: deps.token, + contextToken, + }}); + logger.info(`outbound: text sent to=${ctx.To}`); + return; + } + await sendWeixinMediaFile({ + filePath, + to: ctx.To, + text, + opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken }, + cdnBaseUrl: deps.cdnBaseUrl, + }); + logger.info(`outbound: media sent OK to=${ctx.To}`); + } else { + logger.debug(`outbound: sending text message to=${ctx.To}`); + await sendMessageWeixin({ to: ctx.To, text, opts: { + baseUrl: deps.baseUrl, + token: deps.token, + contextToken, + }}); + logger.info(`outbound: text sent OK to=${ctx.To}`); + } + } catch (err) { + logger.error( + `outbound: FAILED to=${ctx.To} mediaUrl=${mediaUrl ?? "none"} err=${String(err)} stack=${(err as Error).stack ?? ""}`, + ); + throw err; + } + }, + onError: (err, info) => { + deps.errLog(`weixin reply ${info.kind}: ${String(err)}`); + const errMsg = err instanceof Error ? err.message : String(err); + let notice: string; + if (errMsg.includes("contextToken is required")) { + // No contextToken means we cannot send a notice either; just log. + logger.warn(`onError: contextToken missing, cannot send error notice to=${ctx.To}`); + return; + } else if (errMsg.includes("remote media download failed") || errMsg.includes("fetch")) { + notice = `⚠️ 媒体文件下载失败,请检查链接是否可访问。`; + } else if ( + errMsg.includes("getUploadUrl") || + errMsg.includes("CDN upload") || + errMsg.includes("upload_param") + ) { + notice = `⚠️ 媒体文件上传失败,请稍后重试。`; + } else { + notice = `⚠️ 消息发送失败:${errMsg}`; + } + void sendWeixinErrorNotice({ + to: ctx.To, + contextToken, + message: notice, + baseUrl: deps.baseUrl, + token: deps.token, + errLog: deps.errLog, + }); + }, + }); + + logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`); + try { + await deps.channelRuntime.reply.withReplyDispatcher({ + dispatcher, + run: () => + deps.channelRuntime.reply.dispatchReplyFromConfig({ + ctx: finalized, + cfg: deps.config, + dispatcher, + replyOptions, + }), + }); + logger.debug(`dispatchReplyFromConfig: done agentId=${route.agentId ?? "(none)"}`); + } catch (err) { + logger.error( + `dispatchReplyFromConfig: error agentId=${route.agentId ?? "(none)"} err=${String(err)}`, + ); + throw err; + } finally { + markDispatchIdle(); + + logger.info( + `debug-check: accountId=${deps.accountId} debug=${String(debug)} hasContextToken=${Boolean(contextToken)} stateDir=${process.env.OPENCLAW_STATE_DIR ?? "(unset)"}`, + ); + + if (debug && contextToken) { + const dispatchDoneAt = Date.now(); + const eventTs = full.create_time_ms ?? 0; + const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A"; + const inboundProcessMs = (debugTs.preDispatch ?? receivedAt) - receivedAt; + const aiMs = dispatchDoneAt - (debugTs.preDispatch ?? receivedAt); + const totalTime = eventTs > 0 ? `${dispatchDoneAt - eventTs}ms` : `${dispatchDoneAt - receivedAt}ms`; + + if (debugDeliveries.length > 0) { + debugTrace.push("── 回复 ──"); + for (const d of debugDeliveries) { + debugTrace.push( + `│ textLen=${d.textLen} media=${d.media}`, + `│ text="${d.preview}"`, + ); + } + const firstTs = debugDeliveries[0].ts; + debugTrace.push(`│ deliver耗时: ${dispatchDoneAt - firstTs}ms`); + } else { + debugTrace.push("── 回复 ──", "│ (deliver未捕获)"); + } + + debugTrace.push( + "── 耗时 ──", + `├ 平台→插件: ${platformDelay}`, + `├ 入站处理(auth+route+media): ${inboundProcessMs}ms (mediaDownload: ${mediaDownloadMs}ms)`, + `├ AI生成+回复: ${aiMs}ms`, + `├ 总耗时: ${totalTime}`, + `└ eventTime: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`, + ); + + const timingText = `⏱ Debug 全链路\n${debugTrace.join("\n")}`; + + logger.info(`debug-timing: sending to=${ctx.To}`); + try { + await sendMessageWeixin({ + to: ctx.To, + text: timingText, + opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken }, + }); + logger.info(`debug-timing: sent OK`); + } catch (debugErr) { + logger.error(`debug-timing: send FAILED err=${String(debugErr)}`); + } + } + } +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/send-media.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/send-media.ts" new file mode 100644 index 00000000..df56a270 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/send-media.ts" @@ -0,0 +1,72 @@ +import path from "node:path"; +import type { WeixinApiOptions } from "../api/api.js"; +import { logger } from "../util/logger.js"; +import { getMimeFromFilename } from "../media/mime.js"; +import { sendFileMessageWeixin, sendImageMessageWeixin, sendVideoMessageWeixin } from "./send.js"; +import { uploadFileAttachmentToWeixin, uploadFileToWeixin, uploadVideoToWeixin } from "../cdn/upload.js"; + +/** + * Upload a local file and send it as a weixin message, routing by MIME type: + * video/* → uploadVideoToWeixin + sendVideoMessageWeixin + * image/* → uploadFileToWeixin + sendImageMessageWeixin + * else → uploadFileAttachmentToWeixin + sendFileMessageWeixin + * + * Used by both the auto-reply deliver path (monitor.ts) and the outbound + * sendMedia path (channel.ts) so they stay in sync. + */ +export async function sendWeixinMediaFile(params: { + filePath: string; + to: string; + text: string; + opts: WeixinApiOptions & { contextToken?: string }; + cdnBaseUrl: string; +}): Promise<{ messageId: string }> { + const { filePath, to, text, opts, cdnBaseUrl } = params; + const mime = getMimeFromFilename(filePath); + const uploadOpts: WeixinApiOptions = { baseUrl: opts.baseUrl, token: opts.token }; + + if (mime.startsWith("video/")) { + logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`); + const uploaded = await uploadVideoToWeixin({ + filePath, + toUserId: to, + opts: uploadOpts, + cdnBaseUrl, + }); + logger.info( + `[weixin] sendWeixinMediaFile: video upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`, + ); + return sendVideoMessageWeixin({ to, text, uploaded, opts }); + } + + if (mime.startsWith("image/")) { + logger.info(`[weixin] sendWeixinMediaFile: uploading image filePath=${filePath} to=${to}`); + const uploaded = await uploadFileToWeixin({ + filePath, + toUserId: to, + opts: uploadOpts, + cdnBaseUrl, + }); + logger.info( + `[weixin] sendWeixinMediaFile: image upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`, + ); + return sendImageMessageWeixin({ to, text, uploaded, opts }); + } + + // File attachment: pdf, doc, zip, etc. + const fileName = path.basename(filePath); + logger.info( + `[weixin] sendWeixinMediaFile: uploading file attachment filePath=${filePath} name=${fileName} to=${to}`, + ); + const uploaded = await uploadFileAttachmentToWeixin({ + filePath, + fileName, + toUserId: to, + opts: uploadOpts, + cdnBaseUrl, + }); + logger.info( + `[weixin] sendWeixinMediaFile: file upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`, + ); + return sendFileMessageWeixin({ to, text, fileName, uploaded, opts }); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/send.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/send.ts" new file mode 100644 index 00000000..5c2a2afd --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/send.ts" @@ -0,0 +1,267 @@ +import type { ReplyPayload } from "openclaw/plugin-sdk"; +import { stripMarkdown } from "openclaw/plugin-sdk"; + +import { sendMessage as sendMessageApi } from "../api/api.js"; +import type { WeixinApiOptions } from "../api/api.js"; +import { logger } from "../util/logger.js"; +import { generateId } from "../util/random.js"; +import type { MessageItem, SendMessageReq } from "../api/types.js"; +import { MessageItemType, MessageState, MessageType } from "../api/types.js"; +import type { UploadedFileInfo } from "../cdn/upload.js"; + +function generateClientId(): string { + return generateId("openclaw-weixin"); +} + +/** + * Convert markdown-formatted model reply to plain text for Weixin delivery. + * Preserves newlines; strips markdown syntax. + */ +export function markdownToPlainText(text: string): string { + let result = text; + // Code blocks: strip fences, keep code content + result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim()); + // Images: remove entirely + result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, ""); + // Links: keep display text only + result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); + // Tables: remove separator rows, then strip leading/trailing pipes and convert inner pipes to spaces + result = result.replace(/^\|[\s:|-]+\|$/gm, ""); + result = result.replace(/^\|(.+)\|$/gm, (_, inner: string) => + inner.split("|").map((cell) => cell.trim()).join(" "), + ); + result = stripMarkdown(result); + return result; +} + + +/** Build a SendMessageReq containing a single text message. */ +function buildTextMessageReq(params: { + to: string; + text: string; + contextToken?: string; + clientId: string; +}): SendMessageReq { + const { to, text, contextToken, clientId } = params; + const item_list: MessageItem[] = text + ? [{ type: MessageItemType.TEXT, text_item: { text } }] + : []; + return { + msg: { + from_user_id: "", + to_user_id: to, + client_id: clientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + item_list: item_list.length ? item_list : undefined, + context_token: contextToken ?? undefined, + }, + }; +} + +/** Build a SendMessageReq from a reply payload (text only; image send uses sendImageMessageWeixin). */ +function buildSendMessageReq(params: { + to: string; + contextToken?: string; + payload: ReplyPayload; + clientId: string; +}): SendMessageReq { + const { to, contextToken, payload, clientId } = params; + return buildTextMessageReq({ + to, + text: payload.text ?? "", + contextToken, + clientId, + }); +} + +/** + * Send a plain text message downstream. + * contextToken is required for all reply sends; missing it breaks conversation association. + */ +export async function sendMessageWeixin(params: { + to: string; + text: string; + opts: WeixinApiOptions & { contextToken?: string }; +}): Promise<{ messageId: string }> { + const { to, text, opts } = params; + if (!opts.contextToken) { + logger.error(`sendMessageWeixin: contextToken missing, refusing to send to=${to}`); + throw new Error("sendMessageWeixin: contextToken is required"); + } + const clientId = generateClientId(); + const req = buildSendMessageReq({ + to, + contextToken: opts.contextToken, + payload: { text }, + clientId, + }); + try { + await sendMessageApi({ + baseUrl: opts.baseUrl, + token: opts.token, + timeoutMs: opts.timeoutMs, + body: req, + }); + } catch (err) { + logger.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`); + throw err; + } + return { messageId: clientId }; +} + +/** + * Send one or more MessageItems (optionally preceded by a text caption) downstream. + * Each item is sent as its own request so that item_list always has exactly one entry. + */ +async function sendMediaItems(params: { + to: string; + text: string; + mediaItem: MessageItem; + opts: WeixinApiOptions & { contextToken?: string }; + label: string; +}): Promise<{ messageId: string }> { + const { to, text, mediaItem, opts, label } = params; + + const items: MessageItem[] = []; + if (text) { + items.push({ type: MessageItemType.TEXT, text_item: { text } }); + } + items.push(mediaItem); + + let lastClientId = ""; + for (const item of items) { + lastClientId = generateClientId(); + const req: SendMessageReq = { + msg: { + from_user_id: "", + to_user_id: to, + client_id: lastClientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + item_list: [item], + context_token: opts.contextToken ?? undefined, + }, + }; + try { + await sendMessageApi({ + baseUrl: opts.baseUrl, + token: opts.token, + timeoutMs: opts.timeoutMs, + body: req, + }); + } catch (err) { + logger.error( + `${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`, + ); + throw err; + } + } + + logger.debug(`${label}: success to=${to} clientId=${lastClientId}`); + return { messageId: lastClientId }; +} + +/** + * Send an image message downstream using a previously uploaded file. + * Optionally include a text caption as a separate TEXT item before the image. + * + * ImageItem fields: + * - media.encrypt_query_param: CDN download param + * - media.aes_key: AES key, base64-encoded + * - mid_size: original ciphertext file size + */ +export async function sendImageMessageWeixin(params: { + to: string; + text: string; + uploaded: UploadedFileInfo; + opts: WeixinApiOptions & { contextToken?: string }; +}): Promise<{ messageId: string }> { + const { to, text, uploaded, opts } = params; + if (!opts.contextToken) { + logger.error(`sendImageMessageWeixin: contextToken missing, refusing to send to=${to}`); + throw new Error("sendImageMessageWeixin: contextToken is required"); + } + logger.debug( + `sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`, + ); + + const imageItem: MessageItem = { + type: MessageItemType.IMAGE, + image_item: { + media: { + encrypt_query_param: uploaded.downloadEncryptedQueryParam, + aes_key: Buffer.from(uploaded.aeskey).toString("base64"), + encrypt_type: 1, + }, + mid_size: uploaded.fileSizeCiphertext, + }, + }; + + return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" }); +} + +/** + * Send a video message downstream using a previously uploaded file. + * VideoItem: media (CDN ref), video_size (ciphertext bytes). + * Includes an optional text caption sent as a separate TEXT item first. + */ +export async function sendVideoMessageWeixin(params: { + to: string; + text: string; + uploaded: UploadedFileInfo; + opts: WeixinApiOptions & { contextToken?: string }; +}): Promise<{ messageId: string }> { + const { to, text, uploaded, opts } = params; + if (!opts.contextToken) { + logger.error(`sendVideoMessageWeixin: contextToken missing, refusing to send to=${to}`); + throw new Error("sendVideoMessageWeixin: contextToken is required"); + } + + const videoItem: MessageItem = { + type: MessageItemType.VIDEO, + video_item: { + media: { + encrypt_query_param: uploaded.downloadEncryptedQueryParam, + aes_key: Buffer.from(uploaded.aeskey).toString("base64"), + encrypt_type: 1, + }, + video_size: uploaded.fileSizeCiphertext, + }, + }; + + return sendMediaItems({ to, text, mediaItem: videoItem, opts, label: "sendVideoMessageWeixin" }); +} + +/** + * Send a file attachment downstream using a previously uploaded file. + * FileItem: media (CDN ref), file_name, len (plaintext bytes as string). + * Includes an optional text caption sent as a separate TEXT item first. + */ +export async function sendFileMessageWeixin(params: { + to: string; + text: string; + fileName: string; + uploaded: UploadedFileInfo; + opts: WeixinApiOptions & { contextToken?: string }; +}): Promise<{ messageId: string }> { + const { to, text, fileName, uploaded, opts } = params; + if (!opts.contextToken) { + logger.error(`sendFileMessageWeixin: contextToken missing, refusing to send to=${to}`); + throw new Error("sendFileMessageWeixin: contextToken is required"); + } + const fileItem: MessageItem = { + type: MessageItemType.FILE, + file_item: { + media: { + encrypt_query_param: uploaded.downloadEncryptedQueryParam, + aes_key: Buffer.from(uploaded.aeskey).toString("base64"), + encrypt_type: 1, + }, + file_name: fileName, + len: String(uploaded.fileSize), + }, + }; + + return sendMediaItems({ to, text, mediaItem: fileItem, opts, label: "sendFileMessageWeixin" }); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/slash-commands.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/slash-commands.ts" new file mode 100644 index 00000000..9d2160e5 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/slash-commands.ts" @@ -0,0 +1,110 @@ +/** + * Weixin 斜杠指令处理模块 + * + * 支持的指令: + * - /echo <message> 直接回复消息(不经过 AI),并附带通道耗时统计 + * - /toggle-debug 开关 debug 模式,启用后每条 AI 回复追加全链路耗时 + */ +import type { WeixinApiOptions } from "../api/api.js"; +import { logger } from "../util/logger.js"; + +import { toggleDebugMode, isDebugMode } from "./debug-mode.js"; +import { sendMessageWeixin } from "./send.js"; + +export interface SlashCommandResult { + /** 是否是斜杠指令(true 表示已处理,不需要继续走 AI) */ + handled: boolean; +} + +export interface SlashCommandContext { + to: string; + contextToken?: string; + baseUrl: string; + token?: string; + accountId: string; + log: (msg: string) => void; + errLog: (msg: string) => void; +} + +/** 发送回复消息 */ +async function sendReply(ctx: SlashCommandContext, text: string): Promise<void> { + const opts: WeixinApiOptions & { contextToken?: string } = { + baseUrl: ctx.baseUrl, + token: ctx.token, + contextToken: ctx.contextToken, + }; + await sendMessageWeixin({ to: ctx.to, text, opts }); +} + +/** 处理 /echo 指令 */ +async function handleEcho( + ctx: SlashCommandContext, + args: string, + receivedAt: number, + eventTimestamp?: number, +): Promise<void> { + const message = args.trim(); + if (message) { + await sendReply(ctx, message); + } + const eventTs = eventTimestamp ?? 0; + const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A"; + const timing = [ + "⏱ 通道耗时", + `├ 事件时间: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`, + `├ 平台→插件: ${platformDelay}`, + `└ 插件处理: ${Date.now() - receivedAt}ms`, + ].join("\n"); + await sendReply(ctx, timing); +} + +/** + * 尝试处理斜杠指令 + * + * @returns handled=true 表示该消息已作为指令处理,不需要继续走 AI 管道 + */ +export async function handleSlashCommand( + content: string, + ctx: SlashCommandContext, + receivedAt: number, + eventTimestamp?: number, +): Promise<SlashCommandResult> { + const trimmed = content.trim(); + if (!trimmed.startsWith("/")) { + return { handled: false }; + } + + const spaceIdx = trimmed.indexOf(" "); + const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase(); + const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1); + + logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`); + + try { + switch (command) { + case "/echo": + await handleEcho(ctx, args, receivedAt, eventTimestamp); + return { handled: true }; + case "/toggle-debug": { + const enabled = toggleDebugMode(ctx.accountId); + await sendReply( + ctx, + enabled + ? "Debug 模式已开启" + : "Debug 模式已关闭", + ); + return { handled: true }; + } + default: + return { handled: false }; + } + } catch (err) { + logger.error(`[weixin] Slash command error: ${String(err)}`); + try { + await sendReply(ctx, `❌ 指令执行失败: ${String(err).slice(0, 200)}`); + } catch { + // 发送错误消息也失败了,只能记日志 + } + return { handled: true }; + } +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/monitor/monitor.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/monitor/monitor.ts" new file mode 100644 index 00000000..385cef34 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/monitor/monitor.ts" @@ -0,0 +1,221 @@ +import type { ChannelAccountSnapshot, PluginRuntime } from "openclaw/plugin-sdk"; + +import { getUpdates } from "../api/api.js"; +import { WeixinConfigManager } from "../api/config-cache.js"; +import { SESSION_EXPIRED_ERRCODE, pauseSession, getRemainingPauseMs } from "../api/session-guard.js"; +import { processOneMessage } from "../messaging/process-message.js"; +import { getWeixinRuntime, waitForWeixinRuntime } from "../runtime.js"; +import { getSyncBufFilePath, loadGetUpdatesBuf, saveGetUpdatesBuf } from "../storage/sync-buf.js"; +import { logger } from "../util/logger.js"; +import type { Logger } from "../util/logger.js"; +import { redactBody } from "../util/redact.js"; + +const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000; +const MAX_CONSECUTIVE_FAILURES = 3; +const BACKOFF_DELAY_MS = 30_000; +const RETRY_DELAY_MS = 2_000; + +export type MonitorWeixinOpts = { + baseUrl: string; + cdnBaseUrl: string; + token?: string; + accountId: string; + /** When non-empty, only messages whose from_user_id is in this list are processed. */ + allowFrom?: string[]; + config: import("openclaw/plugin-sdk/core").OpenClawConfig; + runtime?: { log?: (msg: string) => void; error?: (msg: string) => void }; + abortSignal?: AbortSignal; + longPollTimeoutMs?: number; + /** Gateway status callback — called on each successful poll and inbound message. */ + setStatus?: (next: ChannelAccountSnapshot) => void; +}; + +/** + * Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig. + * Runs until abort. + */ +export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<void> { + const { + baseUrl, + cdnBaseUrl, + token, + accountId, + config, + abortSignal, + longPollTimeoutMs, + setStatus, + } = opts; + const log = opts.runtime?.log ?? (() => {}); + const errLog = opts.runtime?.error ?? ((m: string) => log(m)); + const aLog: Logger = logger.withAccount(accountId); + + aLog.info(`waiting for Weixin runtime...`); + let channelRuntime: PluginRuntime["channel"]; + try { + const pluginRuntime = await waitForWeixinRuntime(); + channelRuntime = pluginRuntime.channel; + aLog.info(`Weixin runtime acquired, channelRuntime type: ${typeof channelRuntime}`); + } catch (err) { + aLog.error(`waitForWeixinRuntime() failed: ${String(err)}`); + throw err; + } + + log(`weixin monitor started (${baseUrl}, account=${accountId})`); + aLog.info( + `Monitor started: baseUrl=${baseUrl} timeoutMs=${longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS}`, + ); + + const syncFilePath = getSyncBufFilePath(accountId); + aLog.debug(`syncFilePath: ${syncFilePath}`); + + const previousGetUpdatesBuf = loadGetUpdatesBuf(syncFilePath); + let getUpdatesBuf = previousGetUpdatesBuf ?? ""; + + if (previousGetUpdatesBuf) { + log(`[weixin] resuming from previous sync buf (${getUpdatesBuf.length} bytes)`); + aLog.debug(`Using previous get_updates_buf (${getUpdatesBuf.length} bytes)`); + } else { + log(`[weixin] no previous sync buf, starting fresh`); + aLog.info(`No previous get_updates_buf found, starting fresh`); + } + + const configManager = new WeixinConfigManager({ baseUrl, token }, log); + + let nextTimeoutMs = longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS; + let consecutiveFailures = 0; + + while (!abortSignal?.aborted) { + try { + aLog.debug( + `getUpdates: get_updates_buf=${getUpdatesBuf.substring(0, 50)}..., timeoutMs=${nextTimeoutMs}`, + ); + const resp = await getUpdates({ + baseUrl, + token, + get_updates_buf: getUpdatesBuf, + timeoutMs: nextTimeoutMs, + }); + aLog.debug( + `getUpdates response: ret=${resp.ret}, msgs=${resp.msgs?.length ?? 0}, get_updates_buf_length=${resp.get_updates_buf?.length ?? 0}`, + ); + + if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) { + nextTimeoutMs = resp.longpolling_timeout_ms; + aLog.debug(`Updated next poll timeout: ${nextTimeoutMs}ms`); + } + const isApiError = + (resp.ret !== undefined && resp.ret !== 0) || + (resp.errcode !== undefined && resp.errcode !== 0); + if (isApiError) { + const isSessionExpired = + resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE; + + if (isSessionExpired) { + pauseSession(accountId); + const pauseMs = getRemainingPauseMs(accountId); + errLog( + `weixin getUpdates: session expired (errcode ${SESSION_EXPIRED_ERRCODE}), pausing bot for ${Math.ceil(pauseMs / 60_000)} min`, + ); + aLog.error( + `getUpdates: session expired (errcode=${resp.errcode} ret=${resp.ret}), pausing all requests for ${Math.ceil(pauseMs / 60_000)} min`, + ); + consecutiveFailures = 0; + await sleep(pauseMs, abortSignal); + continue; + } + + consecutiveFailures += 1; + errLog( + `weixin getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ""} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`, + ); + aLog.error( + `getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg} response=${redactBody(JSON.stringify(resp))}`, + ); + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + errLog( + `weixin getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`, + ); + aLog.error( + `getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`, + ); + consecutiveFailures = 0; + await sleep(BACKOFF_DELAY_MS, abortSignal); + } else { + await sleep(RETRY_DELAY_MS, abortSignal); + } + continue; + } + consecutiveFailures = 0; + setStatus?.({ accountId, lastEventAt: Date.now() }); + if (resp.get_updates_buf != null && resp.get_updates_buf !== "") { + saveGetUpdatesBuf(syncFilePath, resp.get_updates_buf); + getUpdatesBuf = resp.get_updates_buf; + aLog.debug(`Saved new get_updates_buf (${getUpdatesBuf.length} bytes)`); + } + const list = resp.msgs ?? []; + for (const full of list) { + aLog.info( + `inbound message: from=${full.from_user_id} types=${full.item_list?.map((i) => i.type).join(",") ?? "none"}`, + ); + + const now = Date.now(); + setStatus?.({ accountId, lastEventAt: now, lastInboundAt: now }); + + // allowFrom filtering is delegated to processOneMessage via the framework + // authorization pipeline (resolveSenderCommandAuthorizationWithRuntime). + + const fromUserId = full.from_user_id ?? ""; + const cachedConfig = await configManager.getForUser(fromUserId, full.context_token); + + await processOneMessage(full, { + accountId, + config, + channelRuntime, + baseUrl, + cdnBaseUrl, + token, + typingTicket: cachedConfig.typingTicket, + log: opts.runtime?.log ?? (() => {}), + errLog, + }); + } + } catch (err) { + if (abortSignal?.aborted) { + aLog.info(`Monitor stopped (aborted)`); + return; + } + consecutiveFailures += 1; + errLog( + `weixin getUpdates error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${String(err)}`, + ); + aLog.error(`getUpdates error: ${String(err)}, stack=${(err as Error).stack}`); + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + errLog( + `weixin getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`, + ); + aLog.error( + `getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`, + ); + consecutiveFailures = 0; + await sleep(30_000, abortSignal); + } else { + await sleep(2000, abortSignal); + } + } + } + aLog.info(`Monitor ended`); +} + +function sleep(ms: number, signal?: AbortSignal): Promise<void> { + return new Promise((resolve, reject) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(t); + reject(new Error("aborted")); + }, + { once: true }, + ); + }); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/runtime.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/runtime.ts" new file mode 100644 index 00000000..3f806a79 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/runtime.ts" @@ -0,0 +1,70 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +import { logger } from "./util/logger.js"; + +let pluginRuntime: PluginRuntime | null = null; + +export type PluginChannelRuntime = PluginRuntime["channel"]; + +/** + * Sets the global Weixin runtime (called from plugin register). + */ +export function setWeixinRuntime(next: PluginRuntime): void { + pluginRuntime = next; + logger.info(`[runtime] setWeixinRuntime called, runtime set successfully`); +} + +/** + * Gets the global Weixin runtime (throws if not initialized). + */ +export function getWeixinRuntime(): PluginRuntime { + if (!pluginRuntime) { + throw new Error("Weixin runtime not initialized"); + } + return pluginRuntime; +} + +const WAIT_INTERVAL_MS = 100; +const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * Waits for the Weixin runtime to be initialized (async polling). + */ +export async function waitForWeixinRuntime( + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise<PluginRuntime> { + const start = Date.now(); + while (!pluginRuntime) { + if (Date.now() - start > timeoutMs) { + throw new Error("Weixin runtime initialization timeout"); + } + await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS)); + } + return pluginRuntime; +} + +/** + * Resolves `PluginRuntime["channel"]` for the long-poll monitor. + * + * Prefer the gateway-injected `channelRuntime` on `ChannelGatewayContext` when present (avoids + * races with the module-global from `register()`). Fall back to the global set by `setWeixinRuntime()`, + * then to a short wait for legacy hosts. + */ +export async function resolveWeixinChannelRuntime(params: { + channelRuntime?: PluginChannelRuntime; + waitTimeoutMs?: number; +}): Promise<PluginChannelRuntime> { + if (params.channelRuntime) { + logger.debug("[runtime] channelRuntime from gateway context"); + return params.channelRuntime; + } + if (pluginRuntime) { + logger.debug("[runtime] channelRuntime from register() global"); + return pluginRuntime.channel; + } + logger.warn( + "[runtime] no channelRuntime on ctx and no global runtime yet; waiting for register()", + ); + const pr = await waitForWeixinRuntime(params.waitTimeoutMs ?? DEFAULT_TIMEOUT_MS); + return pr.channel; +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/storage/state-dir.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/storage/state-dir.ts" new file mode 100644 index 00000000..f5662a7c --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/storage/state-dir.ts" @@ -0,0 +1,11 @@ +import os from "node:os"; +import path from "node:path"; + +/** Resolve the OpenClaw state directory (mirrors core logic in src/infra). */ +export function resolveStateDir(): string { + return ( + process.env.OPENCLAW_STATE_DIR?.trim() || + process.env.CLAWDBOT_STATE_DIR?.trim() || + path.join(os.homedir(), ".openclaw") + ); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/storage/sync-buf.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/storage/sync-buf.ts" new file mode 100644 index 00000000..ba4f199b --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/storage/sync-buf.ts" @@ -0,0 +1,81 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { deriveRawAccountId } from "../auth/accounts.js"; + +import { resolveStateDir } from "./state-dir.js"; + +function resolveAccountsDir(): string { + return path.join(resolveStateDir(), "openclaw-weixin", "accounts"); +} + +/** + * Path to the persistent get_updates_buf file for an account. + * Stored alongside account data: ~/.openclaw/openclaw-weixin/accounts/{accountId}.sync.json + */ +export function getSyncBufFilePath(accountId: string): string { + return path.join(resolveAccountsDir(), `${accountId}.sync.json`); +} + +/** Legacy single-account syncbuf (pre multi-account): `.openclaw-weixin-sync/default.json`. */ +function getLegacySyncBufDefaultJsonPath(): string { + return path.join( + resolveStateDir(), + "agents", + "default", + "sessions", + ".openclaw-weixin-sync", + "default.json", + ); +} + +export type SyncBufData = { + get_updates_buf: string; +}; + +function readSyncBufFile(filePath: string): string | undefined { + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const data = JSON.parse(raw) as { get_updates_buf?: string }; + if (typeof data.get_updates_buf === "string") { + return data.get_updates_buf; + } + } catch { + // file not found or invalid + } + return undefined; +} + +/** + * Load persisted get_updates_buf. + * Falls back in order: + * 1. Primary path (normalized accountId, new installs) + * 2. Compat path (raw accountId derived from pattern, old installs) + * 3. Legacy single-account path (very old installs without multi-account support) + */ +export function loadGetUpdatesBuf(filePath: string): string | undefined { + const value = readSyncBufFile(filePath); + if (value !== undefined) return value; + + // Compat: if given path uses a normalized accountId (e.g. "b0f5860fdecb-im-bot.sync.json"), + // also try the old raw-ID filename (e.g. "b0f5860fdecb@im.bot.sync.json"). + const accountId = path.basename(filePath, ".sync.json"); + const rawId = deriveRawAccountId(accountId); + if (rawId) { + const compatPath = path.join(resolveAccountsDir(), `${rawId}.sync.json`); + const compatValue = readSyncBufFile(compatPath); + if (compatValue !== undefined) return compatValue; + } + + // Legacy fallback: old single-account installs stored syncbuf without accountId. + return readSyncBufFile(getLegacySyncBufDefaultJsonPath()); +} + +/** + * Persist get_updates_buf. Creates parent dir if needed. + */ +export function saveGetUpdatesBuf(filePath: string, getUpdatesBuf: string): void { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ get_updates_buf: getUpdatesBuf }, null, 0), "utf-8"); +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/logger.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/logger.ts" new file mode 100644 index 00000000..cbb1ccc1 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/logger.ts" @@ -0,0 +1,143 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Plugin logger — writes JSON lines to the main openclaw log file: + * /tmp/openclaw/openclaw-YYYY-MM-DD.log + * Same file and format used by all other channels. + */ + +const MAIN_LOG_DIR = path.join("/tmp", "openclaw"); +const SUBSYSTEM = "gateway/channels/openclaw-weixin"; +const RUNTIME = "node"; +const RUNTIME_VERSION = process.versions.node; +const HOSTNAME = os.hostname() || "unknown"; +const PARENT_NAMES = ["openclaw"]; + +/** tslog-compatible level IDs (higher = more severe). */ +const LEVEL_IDS: Record<string, number> = { + TRACE: 1, + DEBUG: 2, + INFO: 3, + WARN: 4, + ERROR: 5, + FATAL: 6, +}; + +const DEFAULT_LOG_LEVEL = "INFO"; + +function resolveMinLevel(): number { + const env = process.env.OPENCLAW_LOG_LEVEL?.toUpperCase(); + if (env && env in LEVEL_IDS) return LEVEL_IDS[env]; + return LEVEL_IDS[DEFAULT_LOG_LEVEL]; +} + +let minLevelId = resolveMinLevel(); + +/** Dynamically change the minimum log level at runtime. */ +export function setLogLevel(level: string): void { + const upper = level.toUpperCase(); + if (!(upper in LEVEL_IDS)) { + throw new Error(`Invalid log level: ${level}. Valid levels: ${Object.keys(LEVEL_IDS).join(", ")}`); + } + minLevelId = LEVEL_IDS[upper]; +} + +/** Shift a Date into local time so toISOString() renders local clock digits. */ +function toLocalISO(now: Date): string { + const offsetMs = -now.getTimezoneOffset() * 60_000; + const sign = offsetMs >= 0 ? "+" : "-"; + const abs = Math.abs(now.getTimezoneOffset()); + const offStr = `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`; + return new Date(now.getTime() + offsetMs).toISOString().replace("Z", offStr); +} + +function localDateKey(now: Date): string { + return toLocalISO(now).slice(0, 10); +} + +function resolveMainLogPath(): string { + const dateKey = localDateKey(new Date()); + return path.join(MAIN_LOG_DIR, `openclaw-${dateKey}.log`); +} + +let logDirEnsured = false; + +export type Logger = { + info(message: string): void; + debug(message: string): void; + warn(message: string): void; + error(message: string): void; + /** Returns a child logger whose messages are prefixed with `[accountId]`. */ + withAccount(accountId: string): Logger; + /** Returns the current main log file path. */ + getLogFilePath(): string; + close(): void; +}; + +function buildLoggerName(accountId?: string): string { + return accountId ? `${SUBSYSTEM}/${accountId}` : SUBSYSTEM; +} + +function writeLog(level: string, message: string, accountId?: string): void { + const levelId = LEVEL_IDS[level] ?? LEVEL_IDS.INFO; + if (levelId < minLevelId) return; + + const now = new Date(); + const loggerName = buildLoggerName(accountId); + const prefixedMessage = accountId ? `[${accountId}] ${message}` : message; + const entry = JSON.stringify({ + "0": loggerName, + "1": prefixedMessage, + _meta: { + runtime: RUNTIME, + runtimeVersion: RUNTIME_VERSION, + hostname: HOSTNAME, + name: loggerName, + parentNames: PARENT_NAMES, + date: now.toISOString(), + logLevelId: LEVEL_IDS[level] ?? LEVEL_IDS.INFO, + logLevelName: level, + }, + time: toLocalISO(now), + }); + try { + if (!logDirEnsured) { + fs.mkdirSync(MAIN_LOG_DIR, { recursive: true }); + logDirEnsured = true; + } + fs.appendFileSync(resolveMainLogPath(), `${entry}\n`, "utf-8"); + } catch { + // Best-effort; never block on logging failures. + } +} + +/** Creates a logger instance, optionally bound to a specific account. */ +function createLogger(accountId?: string): Logger { + return { + info(message: string): void { + writeLog("INFO", message, accountId); + }, + debug(message: string): void { + writeLog("DEBUG", message, accountId); + }, + warn(message: string): void { + writeLog("WARN", message, accountId); + }, + error(message: string): void { + writeLog("ERROR", message, accountId); + }, + withAccount(id: string): Logger { + return createLogger(id); + }, + getLogFilePath(): string { + return resolveMainLogPath(); + }, + close(): void { + // No-op: appendFileSync has no persistent handle to close. + }, + }; +} + +export const logger: Logger = createLogger(); diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/random.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/random.ts" new file mode 100644 index 00000000..194124da --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/random.ts" @@ -0,0 +1,17 @@ +import crypto from "node:crypto"; + +/** + * Generate a prefixed unique ID using timestamp + crypto random bytes. + * Format: `{prefix}:{timestamp}-{8-char hex}` + */ +export function generateId(prefix: string): string { + return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`; +} + +/** + * Generate a temporary file name with random suffix. + * Format: `{prefix}-{timestamp}-{8-char hex}{ext}` + */ +export function tempFileName(prefix: string, ext: string): string { + return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`; +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/redact.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/redact.ts" new file mode 100644 index 00000000..2b0491d9 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/redact.ts" @@ -0,0 +1,46 @@ +const DEFAULT_BODY_MAX_LEN = 200; +const DEFAULT_TOKEN_PREFIX_LEN = 6; + +/** + * Truncate a string, appending a length indicator when trimmed. + * Returns `""` for empty/undefined input. + */ +export function truncate(s: string | undefined, max: number): string { + if (!s) return ""; + if (s.length <= max) return s; + return `${s.slice(0, max)}…(len=${s.length})`; +} + +/** + * Redact a token/secret: show only the first few chars + total length. + * Returns `"(none)"` when absent. + */ +export function redactToken(token: string | undefined, prefixLen = DEFAULT_TOKEN_PREFIX_LEN): string { + if (!token) return "(none)"; + if (token.length <= prefixLen) return `****(len=${token.length})`; + return `${token.slice(0, prefixLen)}…(len=${token.length})`; +} + +/** + * Truncate a JSON body string to `maxLen` chars for safe logging. + * Appends original length so the reader knows how much was dropped. + */ +export function redactBody(body: string | undefined, maxLen = DEFAULT_BODY_MAX_LEN): string { + if (!body) return "(empty)"; + if (body.length <= maxLen) return body; + return `${body.slice(0, maxLen)}…(truncated, totalLen=${body.length})`; +} + +/** + * Strip query string (which often contains signatures/tokens) from a URL, + * keeping only origin + pathname. + */ +export function redactUrl(rawUrl: string): string { + try { + const u = new URL(rawUrl); + const base = `${u.origin}${u.pathname}`; + return u.search ? `${base}?<redacted>` : base; + } catch { + return truncate(rawUrl, 80); + } +} diff --git "a/\350\265\204\346\226\231/weixin-openclaw-package/package/src/vendor.d.ts" "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/vendor.d.ts" new file mode 100644 index 00000000..996acea2 --- /dev/null +++ "b/\350\265\204\346\226\231/weixin-openclaw-package/package/src/vendor.d.ts" @@ -0,0 +1,25 @@ +declare module "qrcode-terminal" { + const qrcodeTerminal: { + generate( + text: string, + options?: { small?: boolean }, + callback?: (qr: string) => void, + ): void; + }; + export default qrcodeTerminal; +} + +declare module "fluent-ffmpeg" { + interface FfmpegCommand { + setFfmpegPath(path: string): FfmpegCommand; + seekInput(time: number): FfmpegCommand; + frames(n: number): FfmpegCommand; + outputOptions(opts: string[]): FfmpegCommand; + output(path: string): FfmpegCommand; + on(event: "end", cb: () => void): FfmpegCommand; + on(event: "error", cb: (err: Error) => void): FfmpegCommand; + run(): void; + } + function ffmpeg(input: string): FfmpegCommand; + export default ffmpeg; +}