From bd8256b70041b14e0680e9483f47f0b98f19172b Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Mon, 16 Mar 2026 21:50:58 +0800 Subject: [PATCH 01/32] feat: register WebMCP tools for AI agent testing (dev only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 9 structured tools via navigator.modelContext (Chrome 146+ WebMCP) so AI agents can interact with CodePilot through call_website_tool instead of screenshot → a11y parse → click loops (~90% token reduction). Tools: codepilot_navigate, codepilot_send_message, codepilot_get_chat_status, codepilot_list_providers, codepilot_switch_provider, codepilot_run_doctor, codepilot_doctor_repair, codepilot_export_logs, codepilot_test_provider, codepilot_get_settings. Only loaded in development mode (NODE_ENV check in layout.tsx). Requires Chrome 146+ with chrome://flags/#web-mcp enabled. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + public/webmcp-tools.js | 312 +++++++++++++++++++++++++++++++++++++++++ src/app/layout.tsx | 4 + 3 files changed, 317 insertions(+) create mode 100644 public/webmcp-tools.js diff --git a/CLAUDE.md b/CLAUDE.md index 003aacd3..6d701eb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ CodePilot — Claude Code 的桌面 GUI 客户端,基于 Electron + Next.js。 - 验证流程:`npm run dev` 启动应用 → 用 CDP 打开 `http://localhost:3000` 对应页面 → 截图确认渲染正确 → 检查 console 无报错 - 涉及交互的改动(按钮、表单、导航)需通过 CDP 模拟点击/输入并截图验证 - 修改响应式布局时,用 CDP 的 device emulation 分别验证桌面和移动端视口 +- **WebMCP(Chrome 146+):** dev 模式下 `public/webmcp-tools.js` 会通过 `navigator.modelContext` 注册 9 个结构化 tools(导航、发消息、切 provider、诊断等),支持 chrome-devtools-mcp 的 `call_website_tool` / `list_website_tools` 调用,比截图循环节省 ~90% token。需要 Chrome 开启 `chrome://flags/#web-mcp`。优先用 WebMCP tools,不支持时 fallback 到传统 CDP 截图流程。 **新增功能前必须详尽调研:** - 新增功能前必须充分调研相关技术方案、API 兼容性、社区最佳实践 diff --git a/public/webmcp-tools.js b/public/webmcp-tools.js new file mode 100644 index 00000000..2c274b68 --- /dev/null +++ b/public/webmcp-tools.js @@ -0,0 +1,312 @@ +/** + * WebMCP tools for CodePilot — dev/test only. + * + * Registers structured tools via navigator.modelContext so AI agents + * (via chrome-devtools-mcp + WebMCP) can interact with CodePilot + * without screenshot → a11y tree → click loops (~90% token reduction). + * + * Only loaded in development mode (see layout.tsx). + * Requires Chrome 146+ with WebMCP flag enabled. + */ +(function () { + if (!('modelContext' in navigator)) return; + + const mc = navigator.modelContext; + + // ── Navigation ────────────────────────────────────────────── + + mc.registerTool({ + name: 'codepilot_navigate', + description: 'Navigate to a CodePilot page. Returns the page title after navigation.', + inputSchema: { + type: 'object', + properties: { + page: { + type: 'string', + enum: ['chat', 'settings', 'settings-providers', 'settings-cli', 'settings-assistant', 'skills', 'mcp', 'cli-tools', 'gallery', 'bridge'], + description: 'Page to navigate to', + }, + }, + required: ['page'], + }, + handler: async ({ page }) => { + const routes = { + chat: '/chat', + settings: '/settings', + 'settings-providers': '/settings#providers', + 'settings-cli': '/settings#claude-cli', + 'settings-assistant': '/settings#assistant', + skills: '/skills', + mcp: '/mcp', + 'cli-tools': '/cli-tools', + gallery: '/gallery', + bridge: '/bridge', + }; + const url = routes[page] || '/chat'; + window.location.href = url; + await new Promise((r) => setTimeout(r, 1000)); + return { success: true, title: document.title, url: window.location.href }; + }, + }); + + // ── Chat ──────────────────────────────────────────────────── + + mc.registerTool({ + name: 'codepilot_send_message', + description: 'Send a chat message in the current session. Returns the AI response text.', + inputSchema: { + type: 'object', + properties: { + message: { type: 'string', description: 'Message to send' }, + waitSeconds: { type: 'number', description: 'Max seconds to wait for response (default 30)' }, + }, + required: ['message'], + }, + handler: async ({ message, waitSeconds = 30 }) => { + const textarea = document.querySelector('textarea[placeholder*="Message"]'); + if (!textarea) return { error: 'Chat textarea not found. Navigate to a chat page first.' }; + + // Set value and trigger React onChange + const nativeSet = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; + nativeSet.call(textarea, message); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + + await new Promise((r) => setTimeout(r, 100)); + + // Click submit + const submitBtn = document.querySelector('button[type="submit"], button:has(svg)'); + const buttons = Array.from(document.querySelectorAll('button')); + const send = buttons.find((b) => b.textContent.includes('Submit') || b.getAttribute('aria-label')?.includes('Send')); + (send || submitBtn)?.click(); + + // Wait for response + const start = Date.now(); + let lastContent = ''; + while (Date.now() - start < waitSeconds * 1000) { + await new Promise((r) => setTimeout(r, 1000)); + const msgs = document.querySelectorAll('[data-role="assistant"], .prose'); + if (msgs.length > 0) { + const latest = msgs[msgs.length - 1].textContent || ''; + if (latest === lastContent && latest.length > 0) { + // Content stopped changing — response complete + return { response: latest.slice(0, 2000), length: latest.length }; + } + lastContent = latest; + } + } + return { response: lastContent.slice(0, 2000) || '(no response within timeout)', length: lastContent.length }; + }, + }); + + mc.registerTool({ + name: 'codepilot_get_chat_status', + description: 'Get the current chat page status: selected model, provider, streaming state, message count.', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const modelBtn = document.querySelector('button[class*="model"], button:has(span)'); + const buttons = Array.from(document.querySelectorAll('button')); + const modelButton = buttons.find((b) => { + const text = b.textContent || ''; + return text.match(/Sonnet|Opus|Haiku|K2|GLM|Qwen|MiniMax|claude/i); + }); + const msgs = document.querySelectorAll('[data-role], .prose'); + const textarea = document.querySelector('textarea'); + const isStreaming = !!document.querySelector('.animate-spin, [data-streaming="true"]'); + + return { + currentModel: modelButton?.textContent?.trim() || 'unknown', + messageCount: msgs.length, + isStreaming, + hasInput: !!textarea, + pageUrl: window.location.pathname, + }; + }, + }); + + // ── Provider Management ──────────────────────────────────── + + mc.registerTool({ + name: 'codepilot_list_providers', + description: 'List all configured providers with their status. Calls the API directly.', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const res = await fetch('/api/providers'); + if (!res.ok) return { error: `API error: ${res.status}` }; + const data = await res.json(); + return { + providers: (data.providers || []).map((p) => ({ + id: p.id, + name: p.name, + type: p.provider_type, + hasKey: !!p.api_key, + baseUrl: p.base_url, + })), + envDetected: data.env_detected || {}, + }; + }, + }); + + mc.registerTool({ + name: 'codepilot_switch_provider', + description: 'Switch the active provider and model for new conversations.', + inputSchema: { + type: 'object', + properties: { + providerId: { type: 'string', description: 'Provider ID to switch to' }, + model: { type: 'string', description: 'Model name (e.g. sonnet, opus)' }, + }, + required: ['providerId'], + }, + handler: async ({ providerId, model }) => { + // Set default provider + const res = await fetch('/api/providers/set-default', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider_id: providerId }), + }); + if (!res.ok) return { error: `Failed to set default: ${res.status}` }; + + localStorage.setItem('codepilot:last-provider-id', providerId); + if (model) localStorage.setItem('codepilot:last-model', model); + + window.dispatchEvent(new Event('provider-changed')); + return { success: true, providerId, model: model || 'unchanged' }; + }, + }); + + // ── Doctor ───────────────────────────────────────────────── + + mc.registerTool({ + name: 'codepilot_run_doctor', + description: 'Run Provider Doctor diagnostics. Returns structured probe results.', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const res = await fetch('/api/doctor'); + if (!res.ok) return { error: `Doctor API error: ${res.status}` }; + return await res.json(); + }, + }); + + mc.registerTool({ + name: 'codepilot_doctor_repair', + description: 'Execute a Doctor repair action.', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['set-default-provider', 'apply-provider-to-session', 'clear-stale-resume', 'switch-auth-style', 'reimport-env-config'], + }, + params: { type: 'object', description: 'Action-specific parameters' }, + }, + required: ['action'], + }, + handler: async ({ action, params }) => { + const res = await fetch('/api/doctor/repair', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, params }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { error: err.error || `Repair failed: ${res.status}` }; + } + return await res.json(); + }, + }); + + mc.registerTool({ + name: 'codepilot_export_logs', + description: 'Export sanitized diagnostic logs for troubleshooting.', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const res = await fetch('/api/doctor/export'); + if (!res.ok) return { error: `Export failed: ${res.status}` }; + return await res.json(); + }, + }); + + // ── Provider API Test ────────────────────────────────────── + + mc.registerTool({ + name: 'codepilot_test_provider', + description: 'Send a test message through a specific provider and return the response. Creates a temporary session.', + inputSchema: { + type: 'object', + properties: { + providerId: { type: 'string', description: 'Provider ID to test' }, + model: { type: 'string', description: 'Model to use (default: sonnet)' }, + message: { type: 'string', description: 'Test message (default: Say just "OK [model name]")' }, + }, + required: ['providerId'], + }, + handler: async ({ providerId, model = 'sonnet', message }) => { + const testMsg = message || 'Say just "OK [model name]" and nothing else.'; + + // Create session + const sessRes = await fetch('/api/chat/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'WebMCP test', + mode: 'code', + working_directory: '/tmp', + model, + provider_id: providerId, + }), + }); + if (!sessRes.ok) return { error: 'Session creation failed' }; + const { session } = await sessRes.json(); + + // Send message + const chatRes = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: session.id, content: testMsg, model, provider_id: providerId }), + }); + if (!chatRes.ok) return { error: `Chat API: ${chatRes.status}` }; + + // Read SSE + const reader = chatRes.body.getReader(); + const decoder = new TextDecoder(); + let text = '', errorMsg = ''; + const t0 = Date.now(); + + while (Date.now() - t0 < 25000) { + const { done, value } = await reader.read(); + if (done) break; + for (const line of decoder.decode(value, { stream: true }).split('\n')) { + if (!line.startsWith('data: ')) continue; + try { + const ev = JSON.parse(line.slice(6)); + if (ev.type === 'text') text += ev.data; + if (ev.type === 'error') errorMsg = ev.data?.slice(0, 300) || 'error'; + } catch {} + } + } + reader.cancel(); + + return errorMsg + ? { status: 'error', error: errorMsg, ttft_ms: Date.now() - t0 } + : { status: 'ok', response: text.trim().slice(0, 200), ttft_ms: Date.now() - t0 }; + }, + }); + + // ── Settings ─────────────────────────────────────────────── + + mc.registerTool({ + name: 'codepilot_get_settings', + description: 'Get current CodePilot settings (Claude status, provider config, workspace state).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const [status, setup, workspace] = await Promise.all([ + fetch('/api/claude-status').then((r) => r.json()).catch(() => null), + fetch('/api/setup').then((r) => r.json()).catch(() => null), + fetch('/api/settings/workspace').then((r) => r.json()).catch(() => null), + ]); + return { claudeStatus: status, setup, workspace: workspace?.state || null }; + }, + }); + + console.log('[WebMCP] CodePilot tools registered (%d tools)', 9); +})(); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b9f30609..b2ebe237 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -43,6 +43,10 @@ export default function RootLayout({ + {/* WebMCP tools: register structured tools for AI agent testing (dev only) */} + {process.env.NODE_ENV === 'development' && ( + + )} From 04a1e4904e5e4c181f1c8237b845cd3ce62c2195 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Mon, 16 Mar 2026 22:11:14 +0800 Subject: [PATCH 02/32] Revert "feat: register WebMCP tools for AI agent testing (dev only)" This reverts commit bd8256b70041b14e0680e9483f47f0b98f19172b. --- CLAUDE.md | 1 - public/webmcp-tools.js | 312 ----------------------------------------- src/app/layout.tsx | 4 - 3 files changed, 317 deletions(-) delete mode 100644 public/webmcp-tools.js diff --git a/CLAUDE.md b/CLAUDE.md index 6d701eb3..003aacd3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,6 @@ CodePilot — Claude Code 的桌面 GUI 客户端,基于 Electron + Next.js。 - 验证流程:`npm run dev` 启动应用 → 用 CDP 打开 `http://localhost:3000` 对应页面 → 截图确认渲染正确 → 检查 console 无报错 - 涉及交互的改动(按钮、表单、导航)需通过 CDP 模拟点击/输入并截图验证 - 修改响应式布局时,用 CDP 的 device emulation 分别验证桌面和移动端视口 -- **WebMCP(Chrome 146+):** dev 模式下 `public/webmcp-tools.js` 会通过 `navigator.modelContext` 注册 9 个结构化 tools(导航、发消息、切 provider、诊断等),支持 chrome-devtools-mcp 的 `call_website_tool` / `list_website_tools` 调用,比截图循环节省 ~90% token。需要 Chrome 开启 `chrome://flags/#web-mcp`。优先用 WebMCP tools,不支持时 fallback 到传统 CDP 截图流程。 **新增功能前必须详尽调研:** - 新增功能前必须充分调研相关技术方案、API 兼容性、社区最佳实践 diff --git a/public/webmcp-tools.js b/public/webmcp-tools.js deleted file mode 100644 index 2c274b68..00000000 --- a/public/webmcp-tools.js +++ /dev/null @@ -1,312 +0,0 @@ -/** - * WebMCP tools for CodePilot — dev/test only. - * - * Registers structured tools via navigator.modelContext so AI agents - * (via chrome-devtools-mcp + WebMCP) can interact with CodePilot - * without screenshot → a11y tree → click loops (~90% token reduction). - * - * Only loaded in development mode (see layout.tsx). - * Requires Chrome 146+ with WebMCP flag enabled. - */ -(function () { - if (!('modelContext' in navigator)) return; - - const mc = navigator.modelContext; - - // ── Navigation ────────────────────────────────────────────── - - mc.registerTool({ - name: 'codepilot_navigate', - description: 'Navigate to a CodePilot page. Returns the page title after navigation.', - inputSchema: { - type: 'object', - properties: { - page: { - type: 'string', - enum: ['chat', 'settings', 'settings-providers', 'settings-cli', 'settings-assistant', 'skills', 'mcp', 'cli-tools', 'gallery', 'bridge'], - description: 'Page to navigate to', - }, - }, - required: ['page'], - }, - handler: async ({ page }) => { - const routes = { - chat: '/chat', - settings: '/settings', - 'settings-providers': '/settings#providers', - 'settings-cli': '/settings#claude-cli', - 'settings-assistant': '/settings#assistant', - skills: '/skills', - mcp: '/mcp', - 'cli-tools': '/cli-tools', - gallery: '/gallery', - bridge: '/bridge', - }; - const url = routes[page] || '/chat'; - window.location.href = url; - await new Promise((r) => setTimeout(r, 1000)); - return { success: true, title: document.title, url: window.location.href }; - }, - }); - - // ── Chat ──────────────────────────────────────────────────── - - mc.registerTool({ - name: 'codepilot_send_message', - description: 'Send a chat message in the current session. Returns the AI response text.', - inputSchema: { - type: 'object', - properties: { - message: { type: 'string', description: 'Message to send' }, - waitSeconds: { type: 'number', description: 'Max seconds to wait for response (default 30)' }, - }, - required: ['message'], - }, - handler: async ({ message, waitSeconds = 30 }) => { - const textarea = document.querySelector('textarea[placeholder*="Message"]'); - if (!textarea) return { error: 'Chat textarea not found. Navigate to a chat page first.' }; - - // Set value and trigger React onChange - const nativeSet = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; - nativeSet.call(textarea, message); - textarea.dispatchEvent(new Event('input', { bubbles: true })); - - await new Promise((r) => setTimeout(r, 100)); - - // Click submit - const submitBtn = document.querySelector('button[type="submit"], button:has(svg)'); - const buttons = Array.from(document.querySelectorAll('button')); - const send = buttons.find((b) => b.textContent.includes('Submit') || b.getAttribute('aria-label')?.includes('Send')); - (send || submitBtn)?.click(); - - // Wait for response - const start = Date.now(); - let lastContent = ''; - while (Date.now() - start < waitSeconds * 1000) { - await new Promise((r) => setTimeout(r, 1000)); - const msgs = document.querySelectorAll('[data-role="assistant"], .prose'); - if (msgs.length > 0) { - const latest = msgs[msgs.length - 1].textContent || ''; - if (latest === lastContent && latest.length > 0) { - // Content stopped changing — response complete - return { response: latest.slice(0, 2000), length: latest.length }; - } - lastContent = latest; - } - } - return { response: lastContent.slice(0, 2000) || '(no response within timeout)', length: lastContent.length }; - }, - }); - - mc.registerTool({ - name: 'codepilot_get_chat_status', - description: 'Get the current chat page status: selected model, provider, streaming state, message count.', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const modelBtn = document.querySelector('button[class*="model"], button:has(span)'); - const buttons = Array.from(document.querySelectorAll('button')); - const modelButton = buttons.find((b) => { - const text = b.textContent || ''; - return text.match(/Sonnet|Opus|Haiku|K2|GLM|Qwen|MiniMax|claude/i); - }); - const msgs = document.querySelectorAll('[data-role], .prose'); - const textarea = document.querySelector('textarea'); - const isStreaming = !!document.querySelector('.animate-spin, [data-streaming="true"]'); - - return { - currentModel: modelButton?.textContent?.trim() || 'unknown', - messageCount: msgs.length, - isStreaming, - hasInput: !!textarea, - pageUrl: window.location.pathname, - }; - }, - }); - - // ── Provider Management ──────────────────────────────────── - - mc.registerTool({ - name: 'codepilot_list_providers', - description: 'List all configured providers with their status. Calls the API directly.', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const res = await fetch('/api/providers'); - if (!res.ok) return { error: `API error: ${res.status}` }; - const data = await res.json(); - return { - providers: (data.providers || []).map((p) => ({ - id: p.id, - name: p.name, - type: p.provider_type, - hasKey: !!p.api_key, - baseUrl: p.base_url, - })), - envDetected: data.env_detected || {}, - }; - }, - }); - - mc.registerTool({ - name: 'codepilot_switch_provider', - description: 'Switch the active provider and model for new conversations.', - inputSchema: { - type: 'object', - properties: { - providerId: { type: 'string', description: 'Provider ID to switch to' }, - model: { type: 'string', description: 'Model name (e.g. sonnet, opus)' }, - }, - required: ['providerId'], - }, - handler: async ({ providerId, model }) => { - // Set default provider - const res = await fetch('/api/providers/set-default', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider_id: providerId }), - }); - if (!res.ok) return { error: `Failed to set default: ${res.status}` }; - - localStorage.setItem('codepilot:last-provider-id', providerId); - if (model) localStorage.setItem('codepilot:last-model', model); - - window.dispatchEvent(new Event('provider-changed')); - return { success: true, providerId, model: model || 'unchanged' }; - }, - }); - - // ── Doctor ───────────────────────────────────────────────── - - mc.registerTool({ - name: 'codepilot_run_doctor', - description: 'Run Provider Doctor diagnostics. Returns structured probe results.', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const res = await fetch('/api/doctor'); - if (!res.ok) return { error: `Doctor API error: ${res.status}` }; - return await res.json(); - }, - }); - - mc.registerTool({ - name: 'codepilot_doctor_repair', - description: 'Execute a Doctor repair action.', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['set-default-provider', 'apply-provider-to-session', 'clear-stale-resume', 'switch-auth-style', 'reimport-env-config'], - }, - params: { type: 'object', description: 'Action-specific parameters' }, - }, - required: ['action'], - }, - handler: async ({ action, params }) => { - const res = await fetch('/api/doctor/repair', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action, params }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - return { error: err.error || `Repair failed: ${res.status}` }; - } - return await res.json(); - }, - }); - - mc.registerTool({ - name: 'codepilot_export_logs', - description: 'Export sanitized diagnostic logs for troubleshooting.', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const res = await fetch('/api/doctor/export'); - if (!res.ok) return { error: `Export failed: ${res.status}` }; - return await res.json(); - }, - }); - - // ── Provider API Test ────────────────────────────────────── - - mc.registerTool({ - name: 'codepilot_test_provider', - description: 'Send a test message through a specific provider and return the response. Creates a temporary session.', - inputSchema: { - type: 'object', - properties: { - providerId: { type: 'string', description: 'Provider ID to test' }, - model: { type: 'string', description: 'Model to use (default: sonnet)' }, - message: { type: 'string', description: 'Test message (default: Say just "OK [model name]")' }, - }, - required: ['providerId'], - }, - handler: async ({ providerId, model = 'sonnet', message }) => { - const testMsg = message || 'Say just "OK [model name]" and nothing else.'; - - // Create session - const sessRes = await fetch('/api/chat/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: 'WebMCP test', - mode: 'code', - working_directory: '/tmp', - model, - provider_id: providerId, - }), - }); - if (!sessRes.ok) return { error: 'Session creation failed' }; - const { session } = await sessRes.json(); - - // Send message - const chatRes = await fetch('/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: session.id, content: testMsg, model, provider_id: providerId }), - }); - if (!chatRes.ok) return { error: `Chat API: ${chatRes.status}` }; - - // Read SSE - const reader = chatRes.body.getReader(); - const decoder = new TextDecoder(); - let text = '', errorMsg = ''; - const t0 = Date.now(); - - while (Date.now() - t0 < 25000) { - const { done, value } = await reader.read(); - if (done) break; - for (const line of decoder.decode(value, { stream: true }).split('\n')) { - if (!line.startsWith('data: ')) continue; - try { - const ev = JSON.parse(line.slice(6)); - if (ev.type === 'text') text += ev.data; - if (ev.type === 'error') errorMsg = ev.data?.slice(0, 300) || 'error'; - } catch {} - } - } - reader.cancel(); - - return errorMsg - ? { status: 'error', error: errorMsg, ttft_ms: Date.now() - t0 } - : { status: 'ok', response: text.trim().slice(0, 200), ttft_ms: Date.now() - t0 }; - }, - }); - - // ── Settings ─────────────────────────────────────────────── - - mc.registerTool({ - name: 'codepilot_get_settings', - description: 'Get current CodePilot settings (Claude status, provider config, workspace state).', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - const [status, setup, workspace] = await Promise.all([ - fetch('/api/claude-status').then((r) => r.json()).catch(() => null), - fetch('/api/setup').then((r) => r.json()).catch(() => null), - fetch('/api/settings/workspace').then((r) => r.json()).catch(() => null), - ]); - return { claudeStatus: status, setup, workspace: workspace?.state || null }; - }, - }); - - console.log('[WebMCP] CodePilot tools registered (%d tools)', 9); -})(); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b2ebe237..b9f30609 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -43,10 +43,6 @@ export default function RootLayout({ - {/* WebMCP tools: register structured tools for AI agent testing (dev only) */} - {process.env.NODE_ENV === 'development' && ( - - )} From 8d44b3448c6fa2e8809f72330f1afa063f96fc93 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Mon, 16 Mar 2026 22:50:38 +0800 Subject: [PATCH 03/32] feat: Doctor live probe + model validation for third-party providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the gap where Doctor reports OK but chat fails with exit code 1. Live probe (new runLiveProbe): - Actually spawns a minimal Claude Code process with "Say OK" prompt - 15s timeout, permissionMode: default (no tool use) - Captures stderr (last 500 chars) for diagnostics - Classifies errors via error-classifier on failure - Exports lastLiveProbeError in /api/doctor/export for log sharing - Runs in parallel with other probes, doesn't block Doctor Model validation (runProviderProbe enhancements): - provider.no-explicit-model: warns when third-party Anthropic provider relies on default sonnet/opus/haiku names that the endpoint may not support — addresses #302 - provider.sdk-proxy-only: info finding for Kimi/GLM/MiniMax/Volcengine etc. noting that thinking/context1m/code mode may not be fully supported — addresses #301 - provider.resolve-ok: severity upgraded to warn when model is undefined for non-env, non-official-Anthropic providers Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/doctor/export/route.ts | 14 +- src/lib/provider-doctor.ts | 277 ++++++++++++++++++++++++++++- 2 files changed, 287 insertions(+), 4 deletions(-) diff --git a/src/app/api/doctor/export/route.ts b/src/app/api/doctor/export/route.ts index a9e41bff..509b7704 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 { runDiagnosis, getLastLiveProbeError } from '@/lib/provider-doctor'; import { getRecentLogs } from '@/lib/runtime-log'; import { resolveProvider } from '@/lib/provider-resolver'; @@ -109,6 +109,9 @@ export async function GET() { // 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 +128,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/lib/provider-doctor.ts b/src/lib/provider-doctor.ts index b1619553..0c49b5ab 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); + 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); + 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,221 @@ 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; +} + +/** + * 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[] = [ @@ -712,6 +982,7 @@ export async function runDiagnosis(): Promise { runProviderProbe(), runFeaturesProbe(), runNetworkProbe(), + runLiveProbe(), ]); let overallSeverity: Severity = 'ok'; From 1c6dab3d1e620edd7b1e6cb6a782b41cbbf255ea Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Mon, 16 Mar 2026 23:07:51 +0800 Subject: [PATCH 04/32] fix: Doctor live probe doesn't block UI, export reuses cached result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runDiagnosis() no longer includes live probe — returns fast (~1s) - Live probe available via GET /api/doctor?live=true (opt-in) - Dialog fetches fast probes first, renders immediately, then fetches live probe in background with "Running live connectivity test..." spinner - Live probe result appends to existing probes when ready - Export route uses cached getLastDiagnosisResult() instead of re-running diagnosis (avoids duplicate CLI spawn + token consumption) - Added probe name mapping for live probe (en/zh) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/doctor/export/route.ts | 11 +++-- src/app/api/doctor/route.ts | 23 +++++++++-- .../settings/ProviderDoctorDialog.tsx | 41 ++++++++++++++++++- src/lib/provider-doctor.ts | 28 +++++++++++-- 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/app/api/doctor/export/route.ts b/src/app/api/doctor/export/route.ts index 509b7704..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, getLastLiveProbeError } 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,11 +100,10 @@ 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(); diff --git a/src/app/api/doctor/route.ts b/src/app/api/doctor/route.ts index 2c3b04f2..126e8c39 100644 --- a/src/app/api/doctor/route.ts +++ b/src/app/api/doctor/route.ts @@ -1,12 +1,29 @@ -import { NextResponse } from 'next/server'; -import { runDiagnosis } from '@/lib/provider-doctor'; +import { NextRequest, NextResponse } from 'next/server'; +import { runDiagnosis, runLiveProbe } 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(); + } + return NextResponse.json(result); } catch (error) { console.error('[doctor] Diagnosis failed:', error); diff --git a/src/components/settings/ProviderDoctorDialog.tsx b/src/components/settings/ProviderDoctorDialog.tsx index 41b691d8..7a4ea734 100644 --- a/src/components/settings/ProviderDoctorDialog.tsx +++ b/src/components/settings/ProviderDoctorDialog.tsx @@ -72,6 +72,7 @@ function transformApiResponse(raw: Record, isZh: boolean): Diag provider: { en: "Provider/Model", zh: "服务商/模型" }, features: { en: "Feature Compatibility", zh: "功能兼容性" }, network: { en: "Network/Endpoint", zh: "网络/端点" }, + live: { en: "Live Test", zh: "实际连通测试" }, }; const probes: Probe[] = rawProbes.map((p) => { @@ -155,18 +156,20 @@ export function ProviderDoctorDialog({ open, onOpenChange }: ProviderDoctorDialo const [expandedProbes, setExpandedProbes] = useState>(new Set()); const [repairingActions, setRepairingActions] = useState>(new Set()); + const [liveProbeRunning, setLiveProbeRunning] = useState(false); + const fetchDiagnostics = useCallback(async () => { setLoading(true); setError(null); setResult(null); setExpandedProbes(new Set()); try { + // Fast probes first (~1s) — renders immediately const res = await fetch("/api/doctor"); if (!res.ok) throw new Error("Diagnostic request failed"); const raw = await res.json(); const data = transformApiResponse(raw, isZh); setResult(data); - // Auto-expand probes that have findings const toExpand = new Set(); data.probes.forEach((probe, i) => { if (probe.status !== "pass" && probe.findings.length > 0) { @@ -174,6 +177,36 @@ export function ProviderDoctorDialog({ open, onOpenChange }: ProviderDoctorDialo } }); setExpandedProbes(toExpand); + + // Live probe runs separately (up to 15s) — appends when done + setLiveProbeRunning(true); + fetch("/api/doctor?live=true") + .then((r) => r.ok ? r.json() : null) + .then((liveRaw) => { + if (!liveRaw) return; + const liveData = transformApiResponse(liveRaw, isZh); + // Find the live probe (last one, not in initial result) + const liveProbe = liveData.probes.find((p) => p.name.includes("Live") || p.name.includes("运行")); + if (liveProbe) { + setResult((prev) => { + if (!prev) return prev; + const updated = { ...prev, probes: [...prev.probes, liveProbe] }; + if (liveProbe.status === "error") updated.overall = "error"; + else if (liveProbe.status === "warn" && prev.overall === "pass") updated.overall = "warn"; + return updated; + }); + if (liveProbe.status !== "pass") { + setExpandedProbes((prev) => { + const next = new Set(prev); + // Live probe is the last one + setResult((r) => { if (r) next.add(r.probes.length - 1); return r; }); + return next; + }); + } + } + }) + .catch(() => {}) + .finally(() => setLiveProbeRunning(false)); } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); } finally { @@ -340,6 +373,12 @@ export function ProviderDoctorDialog({ open, onOpenChange }: ProviderDoctorDialo ); })} + {liveProbeRunning && ( +
+ + {isZh ? "正在运行实际连通性测试..." : "Running live connectivity test..."} +
+ )} )} diff --git a/src/lib/provider-doctor.ts b/src/lib/provider-doctor.ts index 0c49b5ab..0d61f82d 100644 --- a/src/lib/provider-doctor.ts +++ b/src/lib/provider-doctor.ts @@ -640,6 +640,13 @@ 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; +} + /** * Sanitize env values: strip control chars and drop non-string values. */ @@ -973,6 +980,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(); @@ -982,7 +996,6 @@ export async function runDiagnosis(): Promise { runProviderProbe(), runFeaturesProbe(), runNetworkProbe(), - runLiveProbe(), ]); let overallSeverity: Severity = 'ok'; @@ -991,15 +1004,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 }; From 116eeaee83a2c731888bc8d9002badf3e6056d62 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Mon, 16 Mar 2026 23:24:40 +0800 Subject: [PATCH 05/32] fix: live probe i18n match + race condition on re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Live probe detection no longer uses name string matching (broke in Chinese where "Live"/"运行" didn't match "实际连通测试"). Instead uses probe count comparison: live probe is the extra one beyond the fast probe count. - Added diagnosticRunRef counter to discard stale live probe responses when user clicks "Re-run" or closes/reopens dialog before the previous live probe finishes. - Guard against double-append if live probe result arrives twice. - Reset liveProbeRunning on new run start. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/ProviderDoctorDialog.tsx | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/components/settings/ProviderDoctorDialog.tsx b/src/components/settings/ProviderDoctorDialog.tsx index 7a4ea734..9829bd5e 100644 --- a/src/components/settings/ProviderDoctorDialog.tsx +++ b/src/components/settings/ProviderDoctorDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { Dialog, DialogContent, @@ -157,16 +157,21 @@ export function ProviderDoctorDialog({ open, onOpenChange }: ProviderDoctorDialo const [repairingActions, setRepairingActions] = useState>(new Set()); const [liveProbeRunning, setLiveProbeRunning] = useState(false); + // Monotonic counter to discard stale live probe responses on re-run + const diagnosticRunRef = useRef(0); const fetchDiagnostics = useCallback(async () => { + const runId = ++diagnosticRunRef.current; setLoading(true); setError(null); setResult(null); setExpandedProbes(new Set()); + setLiveProbeRunning(false); try { // Fast probes first (~1s) — renders immediately const res = await fetch("/api/doctor"); if (!res.ok) throw new Error("Diagnostic request failed"); + if (runId !== diagnosticRunRef.current) return; // stale const raw = await res.json(); const data = transformApiResponse(raw, isZh); setResult(data); @@ -177,36 +182,40 @@ export function ProviderDoctorDialog({ open, onOpenChange }: ProviderDoctorDialo } }); setExpandedProbes(toExpand); + const fastProbeCount = data.probes.length; // Live probe runs separately (up to 15s) — appends when done setLiveProbeRunning(true); fetch("/api/doctor?live=true") .then((r) => r.ok ? r.json() : null) .then((liveRaw) => { + // Discard if a newer run started while we were waiting + if (runId !== diagnosticRunRef.current) return; if (!liveRaw) return; const liveData = transformApiResponse(liveRaw, isZh); - // Find the live probe (last one, not in initial result) - const liveProbe = liveData.probes.find((p) => p.name.includes("Live") || p.name.includes("运行")); + // Live probe is the extra probe beyond the fast probes + const liveProbe = liveData.probes.length > fastProbeCount + ? liveData.probes[liveData.probes.length - 1] + : undefined; if (liveProbe) { setResult((prev) => { if (!prev) return prev; + // Don't append if already has a live probe (guard against double-append) + if (prev.probes.length > fastProbeCount) return prev; const updated = { ...prev, probes: [...prev.probes, liveProbe] }; if (liveProbe.status === "error") updated.overall = "error"; else if (liveProbe.status === "warn" && prev.overall === "pass") updated.overall = "warn"; return updated; }); if (liveProbe.status !== "pass") { - setExpandedProbes((prev) => { - const next = new Set(prev); - // Live probe is the last one - setResult((r) => { if (r) next.add(r.probes.length - 1); return r; }); - return next; - }); + setExpandedProbes((prev) => new Set([...prev, fastProbeCount])); } } }) .catch(() => {}) - .finally(() => setLiveProbeRunning(false)); + .finally(() => { + if (runId === diagnosticRunRef.current) setLiveProbeRunning(false); + }); } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); } finally { From 29a144395c5ee486b35a537f07858bdc815943f7 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Mon, 16 Mar 2026 23:42:18 +0800 Subject: [PATCH 06/32] fix: export includes live probe, error links to diagnostics page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doctor/route.ts: setLastDiagnosisResult() after live probe appends, so export includes the complete diagnosis with live probe results - provider-doctor.ts: export setLastDiagnosisResult for route to use - useSSEStream.ts + chat/page.tsx: error guidance changed from plain text to markdown links — [Run Provider Diagnostics](/settings#providers) and [Provider Setup Guide](wiki link) - ProviderDoctorDialog.tsx: bottom guidance now includes link to Provider Setup Guide wiki (zh: 服务商配置指南) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/doctor/route.ts | 4 +++- src/app/chat/page.tsx | 2 +- .../settings/ProviderDoctorDialog.tsx | 20 +++++++++++++++++++ src/hooks/useSSEStream.ts | 2 +- src/lib/provider-doctor.ts | 4 ++++ 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/app/api/doctor/route.ts b/src/app/api/doctor/route.ts index 126e8c39..ceebb7b3 100644 --- a/src/app/api/doctor/route.ts +++ b/src/app/api/doctor/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { runDiagnosis, runLiveProbe } from '@/lib/provider-doctor'; +import { runDiagnosis, runLiveProbe, setLastDiagnosisResult } from '@/lib/provider-doctor'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -22,6 +22,8 @@ export async function GET(request: NextRequest) { 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); diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index c4bc1abf..4801733d 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -521,7 +521,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://github.com/op7418/CodePilot/wiki/Provider-Setup).'; } } else { errorDisplay = event.data; diff --git a/src/components/settings/ProviderDoctorDialog.tsx b/src/components/settings/ProviderDoctorDialog.tsx index 9829bd5e..37446922 100644 --- a/src/components/settings/ProviderDoctorDialog.tsx +++ b/src/components/settings/ProviderDoctorDialog.tsx @@ -410,6 +410,16 @@ export function ProviderDoctorDialog({ open, onOpenChange }: ProviderDoctorDialo GitHub Issues {" "}提交问题报告,并附上导出的日志文件。 +
+ 📖 查看{" "} + + 服务商配置指南 + ) : ( <> @@ -426,6 +436,16 @@ export function ProviderDoctorDialog({ open, onOpenChange }: ProviderDoctorDialo open a GitHub Issue {" "}and attach the exported log file. +
+ 📖 See the{" "} + + Provider Setup Guide + )} diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts index a04b1a1b..f47f6816 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://github.com/op7418/CodePilot/wiki/Provider-Setup).'; } } else { errorDisplay = event.data; diff --git a/src/lib/provider-doctor.ts b/src/lib/provider-doctor.ts index 0d61f82d..c1250140 100644 --- a/src/lib/provider-doctor.ts +++ b/src/lib/provider-doctor.ts @@ -647,6 +647,10 @@ 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. */ From 1a6f59ed66b015c3edae9524a69e09f7a5dd6c4d Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Mon, 16 Mar 2026 23:43:53 +0800 Subject: [PATCH 07/32] fix: update provider docs URL to codepilot.sh Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/chat/page.tsx | 2 +- src/components/settings/ProviderDoctorDialog.tsx | 4 ++-- src/hooks/useSSEStream.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 4801733d..1edf6a9b 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -521,7 +521,7 @@ export default function NewChatPage() { 'CLI_NOT_FOUND', 'UNSUPPORTED_FEATURE', ]); if (diagCategories.has(parsed.category)) { - errorDisplay += '\n\n💡 [Run Provider Diagnostics](/settings#providers) to troubleshoot, or check the [Provider Setup Guide](https://github.com/op7418/CodePilot/wiki/Provider-Setup).'; + errorDisplay += '\n\n💡 [Run Provider Diagnostics](/settings#providers) to troubleshoot, or check the [Provider Setup Guide](https://www.codepilot.sh/zh/docs/providers).'; } } else { errorDisplay = event.data; diff --git a/src/components/settings/ProviderDoctorDialog.tsx b/src/components/settings/ProviderDoctorDialog.tsx index 37446922..16e6b7d0 100644 --- a/src/components/settings/ProviderDoctorDialog.tsx +++ b/src/components/settings/ProviderDoctorDialog.tsx @@ -413,7 +413,7 @@ export function ProviderDoctorDialog({ open, onOpenChange }: ProviderDoctorDialo
📖 查看{" "}
📖 See the{" "}
<7418@openclaw.ai> Date: Mon, 16 Mar 2026 23:55:32 +0800 Subject: [PATCH 08/32] fix: correct issue tracker URL + locale-aware docs links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Doctor dialog: GitHub Issues link → op7418/CodePilot/issues (was anthropics/claude-code/issues) - English docs link → codepilot.sh/docs/providers (no /zh/ prefix) - Chinese docs link stays codepilot.sh/zh/docs/providers - Chat error guidance also uses locale-neutral docs URL Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/chat/page.tsx | 2 +- src/components/settings/ProviderDoctorDialog.tsx | 6 +++--- src/hooks/useSSEStream.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 1edf6a9b..dd881bd5 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -521,7 +521,7 @@ export default function NewChatPage() { 'CLI_NOT_FOUND', 'UNSUPPORTED_FEATURE', ]); if (diagCategories.has(parsed.category)) { - errorDisplay += '\n\n💡 [Run Provider Diagnostics](/settings#providers) to troubleshoot, or check the [Provider Setup Guide](https://www.codepilot.sh/zh/docs/providers).'; + 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/components/settings/ProviderDoctorDialog.tsx b/src/components/settings/ProviderDoctorDialog.tsx index 16e6b7d0..b492e037 100644 --- a/src/components/settings/ProviderDoctorDialog.tsx +++ b/src/components/settings/ProviderDoctorDialog.tsx @@ -402,7 +402,7 @@ export function ProviderDoctorDialog({ open, onOpenChange }: ProviderDoctorDialo : "如果您仍然遇到问题,"} 请先点击「导出日志」,然后前往{" "}
📖 See the{" "}
<7418@openclaw.ai> Date: Tue, 17 Mar 2026 00:00:35 +0800 Subject: [PATCH 09/32] chore: bump version to 0.38.2 Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58087fef..11690ca0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "codepilot", - "version": "0.38.1", + "version": "0.38.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.38.1", + "version": "0.38.2", + "license": "BUSL-1.1", "workspaces": [ "apps/*", "packages/*" diff --git a/package.json b/package.json index 6f1dd647..420f68c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.38.1", + "version": "0.38.2", "private": true, "license": "BUSL-1.1", "workspaces": [ From c8a15813c4c52fd77a7bb702f3b7eccb99093cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= Date: Thu, 19 Mar 2026 07:03:20 +0800 Subject: [PATCH 10/32] feat: add Xiaomi MiMo provider preset + update MiniMax to M2.7 - Add Xiaomi MiMo (MiMo-V2-Pro) as a new provider preset with XiaomiMiMo brand icon from @lobehub/icons - Update MiniMax from M2.5 to M2.7 per latest official docs; fix CN base URL from /anthropic/v1 to /anthropic - Refactor provider model mapping: use catalog upstreamModelId + defaultRoleModels instead of hardcoding ANTHROPIC_MODEL in defaultEnvOverrides, preventing duplicate entries in model selector - Fix provider-resolver to fall back to catalog defaultRoleModels when role_models_json is empty, ensuring sdkProxyOnly providers correctly inject ANTHROPIC_MODEL / ANTHROPIC_DEFAULT_*_MODEL - Add upstreamModelId-aware dedup in /api/providers/models to prevent duplicate model entries from env overrides vs catalog - Add protocol guard to fuzzy (hostname) matching in both findPresetForLegacy() and getDefaultModelsForProvider() to prevent cross-protocol misclassification of custom OpenAI-compatible providers sharing the same host - Bump version to 0.38.3 Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 109 +------------------ package.json | 2 +- src/app/api/providers/models/route.ts | 8 +- src/components/settings/provider-presets.tsx | 21 +++- src/lib/provider-catalog.ts | 77 ++++++++++--- src/lib/provider-doctor.ts | 4 +- src/lib/provider-resolver.ts | 16 ++- 7 files changed, 106 insertions(+), 131 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11690ca0..79faec1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.38.2", + "version": "0.38.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.38.2", + "version": "0.38.3", "license": "BUSL-1.1", "workspaces": [ "apps/*", @@ -2867,72 +2867,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", @@ -11919,15 +11853,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", @@ -21290,36 +21215,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", diff --git a/package.json b/package.json index 420f68c7..b37cb8c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.38.2", + "version": "0.38.3", "private": true, "license": "BUSL-1.1", "workspaces": [ 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/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/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 c1250140..987600bc 100644 --- a/src/lib/provider-doctor.ts +++ b/src/lib/provider-doctor.ts @@ -391,7 +391,7 @@ async function runProviderProbe(): Promise { // 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); + const matchedPreset = findPresetForLegacy(p.base_url, p.provider_type, protocol as Protocol); const presetHandlesModels = matchedPreset && ( matchedPreset.key === 'anthropic-official' || matchedPreset.defaultRoleModels?.default || @@ -408,7 +408,7 @@ async function runProviderProbe(): Promise { } // Check B: sdkProxyOnly provider warning - const matchedPreset = findPresetForLegacy(p.base_url, p.provider_type); + const matchedPreset = findPresetForLegacy(p.base_url, p.provider_type, protocol as Protocol); if (matchedPreset?.sdkProxyOnly) { findings.push({ severity: 'ok', diff --git a/src/lib/provider-resolver.ts b/src/lib/provider-resolver.ts index f8da6e9e..d45d8d9f 100644 --- a/src/lib/provider-resolver.ts +++ b/src/lib/provider-resolver.ts @@ -499,6 +499,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 { @@ -591,8 +602,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); From b5d558d0a34dd9b291591444497ec55a6fce281a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= Date: Fri, 20 Mar 2026 21:40:02 +0800 Subject: [PATCH 11/32] =?UTF-8?q?fix:=20#341=20#343=20#346=20#347=20?= =?UTF-8?q?=E2=80=94=20provider=20fixes=20+=20global=20default=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug Fixes: - #341: /api/setup now recognizes Claude Code CLI as a valid provider via findClaudeBinary(), fixing 'no provider configured' on chat page - #343/#346: Session PATCH auto-clears stale sdk_session_id when provider_id or model changes, preventing resume failures - #347: Introduced global default model to replace per-provider defaults and 'default provider' concept New Features: - Global default model setting in provider page (all providers, all models) - 'Default' tag in model selector dropdown and chat input button - modelReady gate prevents race condition on new conversation page Error Classification: - New SESSION_STATE_ERROR type for stale session / resume failures - PROCESS_CRASH reclassified when stderr contains session keywords - No longer misleads users to check API Key for session state issues Provider Architecture: - getDefaultProviderId() reads global_default_model_provider first, falls back to legacy default_provider_id - setDefaultProviderId() writes both keys + clears global_default_model so repair/delete flows land on the correct primary key - provider-resolver validates global default model belongs to current provider before applying (prevents cross-provider model leaks) - New conversation page handles provider-set-but-model-cleared state (e.g. after doctor repair) by using provider's first available model UI: - Select component: position=popper + viewport max-h-16rem fixes unbounded dropdown growth on scroll - ProviderOptionsSection simplified to thinking mode + 1M context only - Removed 'default provider' Badge, confirm dialog, banner, and 'apply to current session' button (superseded by global default model) Tests: - 443 pass / 0 fail (was 434/2 before this change) - Fixed 2 pre-existing env pollution failures in toAiSdkConfig tests - Added 7 global default model tests (env + DB provider branches) - Updated stale-default-provider tests to isolate global_default_model_provider Docs: - New: docs/handover/global-default-model.md - Updated: provider-error-doctor.md, bridge-system.md - New: CHANGELOG.md for v0.38.4 --- AGENTS.md | 86 ++++++ CHANGELOG.md | 16 ++ docs/handover/bridge-system.md | 2 +- docs/handover/global-default-model.md | 58 ++++ docs/handover/provider-error-doctor.md | 8 +- package-lock.json | 4 +- package.json | 2 +- src/__tests__/unit/provider-resolver.test.ts | 271 +++++++++++++++--- .../unit/stale-default-provider.test.ts | 11 +- src/app/api/chat/sessions/[id]/route.ts | 20 ++ src/app/api/setup/route.ts | 20 +- src/app/chat/page.tsx | 231 ++++++++++----- src/components/chat/MessageInput.tsx | 9 +- src/components/chat/ModelSelectorDropdown.tsx | 35 ++- src/components/settings/ProviderManager.tsx | 158 ++++++++-- .../settings/ProviderOptionsSection.tsx | 28 +- src/components/ui/select.tsx | 15 +- src/hooks/useProviderModels.ts | 30 +- src/i18n/en.ts | 3 + src/i18n/zh.ts | 3 + src/lib/db.ts | 33 ++- src/lib/error-classifier.ts | 63 +++- src/lib/provider-resolver.ts | 25 +- src/types/index.ts | 6 +- 24 files changed, 949 insertions(+), 188 deletions(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 docs/handover/global-default-model.md 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/docs/handover/bridge-system.md b/docs/handover/bridge-system.md index 8520396d..feff07a3 100644 --- a/docs/handover/bridge-system.md +++ b/docs/handover/bridge-system.md @@ -234,7 +234,7 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i | 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) | 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/package-lock.json b/package-lock.json index 79faec1d..619a6bbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.38.3", + "version": "0.38.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.38.3", + "version": "0.38.4", "license": "BUSL-1.1", "workspaces": [ "apps/*", diff --git a/package.json b/package.json index b37cb8c4..da48d06f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.38.3", + "version": "0.38.4", "private": true, "license": "BUSL-1.1", "workspaces": [ 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/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/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/page.tsx b/src/app/chat/page.tsx index dd881bd5..3dbf7184 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -44,24 +44,26 @@ export default function NewChatPage() { const [recentProjects, setRecentProjects] = useState([]); const [hasProvider, setHasProvider] = useState(true); // assume true until checked const [mode] = 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 +93,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 +243,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 +253,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 +405,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') }); @@ -580,7 +677,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 +757,7 @@ export default function NewChatPage() { onSend={sendFirstMessage} onCommand={handleCommand} onStop={stopStreaming} - disabled={false} + disabled={!modelReady} isStreaming={isStreaming} modelName={currentModel} onModelChange={setCurrentModel} 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/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/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/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/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/i18n/en.ts b/src/i18n/en.ts index 479d50f5..c1d4378f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -924,6 +924,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..132b7dc7 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -921,6 +921,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/db.ts b/src/lib/db.ts index 7329197b..89e02c21 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -804,11 +804,22 @@ 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 { @@ -1157,10 +1168,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 +1196,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' : ''); 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/provider-resolver.ts b/src/lib/provider-resolver.ts index d45d8d9f..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. @@ -528,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; diff --git a/src/types/index.ts b/src/types/index.ts index 424f08ff..62a62788 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 { From 2a6888d8a9b219b76804909be5be8510909e7fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= Date: Sun, 22 Mar 2026 00:11:00 +0800 Subject: [PATCH 12/32] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=204=20=E4=B8=AA?= =?UTF-8?q?=20UI/=E9=80=BB=E8=BE=91=20bug=20+=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E8=A1=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — 去掉 chat list 文件夹双击打开交互: - ProjectGroupHeader: 移除 onDoubleClick 处理和「打开文件管理器」tooltip - 清理未使用的 useClientPlatform import Bug 2 — 文件树加号按钮改为真实文件附件: - FileTreeAttachmentBridge: 改用 /api/files/raw 读取文件二进制, 通过 attachments.add() 添加为 File 对象(绿色胶囊), 失败时降级为 @mention - 不再使用 preview API(会截断/纯文本化文件内容) Bug 3 — 每日询问开关不生效 + 默认关闭: - useAssistantTrigger: needsCheckIn 加入 dailyCheckInEnabled === true 检查 - assistant-workspace: DEFAULT_STATE 设 dailyCheckInEnabled: false, needsDailyCheckIn() 改为 !== true 判断 - AssistantWorkspaceSection: Switch 状态从 !== false 改为 === true - 新增 v3→v4 schema 迁移: 强制所有老用户 dailyCheckInEnabled=false - onboarding-processor: processOnboarding 写回 schemaVersion=4(避免 redo onboarding 触发 v3→v4 迁移静默重置开关) Bug 4 — 授权框溢出、高度失控、多条堆叠: - PermissionPrompt: 新增 ToolInputDisplay 组件,支持长内容折叠/展开 (max 8 行 / 500 字符,Show more / Collapse 按钮) - formatToolInput: Write/Edit 等多字段工具显示完整 JSON(而非仅 file_path) - 外层容器加 max-h-[50vh] overflow-y-auto 防止撑出视口 - 已审批的 generic tools 仅显示一行状态文字,不保留完整 Confirmation UI 安全修补 (Codex P1): - /api/files/raw: 添加 os.homedir() 路径范围校验, 请求 home 目录外的路径返回 403 测试: - 更新 timezone-boundaries / assistant-workspace / onboarding-completion 测试中的 schemaVersion 和 dailyCheckInEnabled 字段 - 新增 dailyCheckInEnabled 默认关闭的测试用例 --- .../unit/assistant-workspace.test.ts | 21 ++-- .../unit/onboarding-completion.test.ts | 6 +- .../unit/timezone-boundaries.test.ts | 16 +-- src/app/api/files/raw/route.ts | 19 ++- src/components/chat/MessageInputParts.tsx | 31 ++++- src/components/chat/PermissionPrompt.tsx | 111 ++++++++++++++---- src/components/layout/ProjectGroupHeader.tsx | 17 --- .../settings/AssistantWorkspaceSection.tsx | 2 +- src/hooks/useAssistantTrigger.ts | 2 +- src/lib/assistant-workspace.ts | 32 ++++- src/lib/onboarding-processor.ts | 2 +- 11 files changed, 193 insertions(+), 66 deletions(-) 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/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/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/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/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/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/ProjectGroupHeader.tsx b/src/components/layout/ProjectGroupHeader.tsx index baedd09e..45c614cf 100644 --- a/src/components/layout/ProjectGroupHeader.tsx +++ b/src/components/layout/ProjectGroupHeader.tsx @@ -16,7 +16,6 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { useTranslation } from '@/hooks/useTranslation'; -import { useClientPlatform } from '@/hooks/useClientPlatform'; interface ProjectGroupHeaderProps { workingDirectory: string; @@ -42,7 +41,6 @@ export function ProjectGroupHeader({ onCreateSession, }: ProjectGroupHeaderProps) { const { t } = useTranslation(); - const { fileManagerName } = useClientPlatform(); return ( @@ -53,20 +51,6 @@ export function ProjectGroupHeader({ "hover:bg-accent/50" )} onClick={onToggle} - onDoubleClick={(e) => { - 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} > @@ -115,7 +99,6 @@ export function ProjectGroupHeader({

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

- {workingDirectory &&

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

}
); diff --git a/src/components/settings/AssistantWorkspaceSection.tsx b/src/components/settings/AssistantWorkspaceSection.tsx index 6378f165..2c6d3cab 100644 --- a/src/components/settings/AssistantWorkspaceSection.tsx +++ b/src/components/settings/AssistantWorkspaceSection.tsx @@ -436,7 +436,7 @@ export function AssistantWorkspaceSection() { lastCheckInDate={workspace.state?.lastCheckInDate ?? null} checkInDoneToday={checkInDoneToday} creatingSession={creatingSession} - autoTriggerEnabled={workspace.state?.dailyCheckInEnabled !== false} + autoTriggerEnabled={workspace.state?.dailyCheckInEnabled === true} onStartCheckIn={handleStartCheckIn} onAutoTriggerChange={async (enabled) => { try { 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/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/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); } From fcef32706adc56f0eaa487c79544bbefadcb585e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= Date: Sun, 22 Mar 2026 12:16:00 +0800 Subject: [PATCH 13/32] =?UTF-8?q?fix:=20chat=20latency=20remediation=20?= =?UTF-8?q?=E2=80=94=20effort=20default,=20MCP=20persistent=20toggle,=20mo?= =?UTF-8?q?de=20convergence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Chat mode entry convergence - Remove initialMode prop from ChatView, hardcode to 'code' - Remove sessionMode state from chat/[id]/page.tsx and SplitColumn.tsx - Simplify effectiveMode in chat route to always use 'code' / acceptEdits - Comment out messageInput.modeCode/modePlan i18n keys (kept for bridge) - DB schema and bridge /mode command unchanged Phase 2: MCP persistent enable/disable toggle - Add enabled?: boolean to MCPServerConfig type - MCP route GET now reads project-level .mcp.json (tagged _source: 'project') - Project server enabled state persisted via mcpServerOverrides in settings.json - PUT handler routes project-source servers to overrides, not .mcp.json - loadMcpServers() in chat route and bridge applies overrides and filters disabled - McpServerList.tsx: add Switch toggle per server card, opacity-50 when disabled - McpManager.tsx: add handlePersistentToggle with res.ok check and revert on failure - i18n: add mcp.enabled / mcp.disabled keys Phase 3: First-token latency optimization - Pin .mcp.json chrome-devtools-mcp to @0.20.3 (was @latest, 10s+ cold start) - Default effort to 'medium' when UI doesn't specify (prevents inheriting 'high' from ~/.claude/settings.json via settingSources) - Emit visible resume status event with notification: true before SDK resume wait - Defer captureCapabilities() to first assistant message with 5-min TTL cache - Add isCacheFresh() to agent-sdk-capabilities.ts Tested: typecheck pass, 444/444 unit tests pass, Playwright UI verification confirms no mode selector and MCP switch toggles visible. --- .mcp.json | 3 +- docs/exec-plans/README.md | 2 + .../active/chat-latency-remediation.md | 176 +++++ .../active/weixin-bridge-channel.md | 599 ++++++++++++++++++ docs/research/README.md | 2 + .../chat-latency-investigation-2026-03-20.md | 249 ++++++++ ...t-latency-remediation-review-2026-03-22.md | 153 +++++ ...eixin-openclaw-plugin-review-2026-03-22.md | 316 +++++++++ src/app/api/chat/route.ts | 36 +- src/app/api/plugins/mcp/route.ts | 31 +- src/app/chat/[id]/page.tsx | 5 +- src/components/chat/ChatView.tsx | 6 +- src/components/layout/SplitColumn.tsx | 3 - src/components/plugins/McpManager.tsx | 21 + 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 | 13 + src/lib/claude-client.ts | 33 +- src/types/index.ts | 2 + .../openclaw-weixin-cli-1.0.2.tgz" | Bin 0 -> 2375 bytes .../weixin-openclaw-cli/package/LICENSE" | 21 + .../weixin-openclaw-cli/package/cli.mjs" | 124 ++++ .../weixin-openclaw-cli/package/package.json" | 17 + .../openclaw-weixin-1.0.2.tgz" | Bin 0 -> 47431 bytes .../package/CHANGELOG.md" | 5 + .../package/CHANGELOG.zh_CN.md" | 3 + .../weixin-openclaw-package/package/LICENSE" | 21 + .../package/README.md" | 271 ++++++++ .../package/README.zh_CN.md" | 271 ++++++++ .../weixin-openclaw-package/package/index.ts" | 27 + .../package/openclaw.plugin.json" | 9 + .../package/package.json" | 55 ++ .../package/src/api/api.ts" | 240 +++++++ .../package/src/api/config-cache.ts" | 79 +++ .../package/src/api/session-guard.ts" | 58 ++ .../package/src/api/types.ts" | 222 +++++++ .../package/src/auth/accounts.ts" | 289 +++++++++ .../package/src/auth/login-qr.ts" | 333 ++++++++++ .../package/src/auth/pairing.ts" | 120 ++++ .../package/src/cdn/aes-ecb.ts" | 21 + .../package/src/cdn/cdn-upload.ts" | 77 +++ .../package/src/cdn/cdn-url.ts" | 17 + .../package/src/cdn/pic-decrypt.ts" | 85 +++ .../package/src/cdn/upload.ts" | 155 +++++ .../package/src/channel.ts" | 380 +++++++++++ .../package/src/config/config-schema.ts" | 22 + .../package/src/log-upload.ts" | 126 ++++ .../package/src/media/media-download.ts" | 141 +++++ .../package/src/media/mime.ts" | 76 +++ .../package/src/media/silk-transcode.ts" | 74 +++ .../package/src/messaging/debug-mode.ts" | 69 ++ .../package/src/messaging/error-notice.ts" | 31 + .../package/src/messaging/inbound.ts" | 171 +++++ .../package/src/messaging/process-message.ts" | 481 ++++++++++++++ .../package/src/messaging/send-media.ts" | 72 +++ .../package/src/messaging/send.ts" | 267 ++++++++ .../package/src/messaging/slash-commands.ts" | 110 ++++ .../package/src/monitor/monitor.ts" | 221 +++++++ .../package/src/runtime.ts" | 70 ++ .../package/src/storage/state-dir.ts" | 11 + .../package/src/storage/sync-buf.ts" | 81 +++ .../package/src/util/logger.ts" | 143 +++++ .../package/src/util/random.ts" | 17 + .../package/src/util/redact.ts" | 46 ++ .../package/src/vendor.d.ts" | 25 + 67 files changed, 6796 insertions(+), 47 deletions(-) create mode 100644 docs/exec-plans/active/chat-latency-remediation.md create mode 100644 docs/exec-plans/active/weixin-bridge-channel.md create mode 100644 docs/research/chat-latency-investigation-2026-03-20.md create mode 100644 docs/research/chat-latency-remediation-review-2026-03-22.md create mode 100644 docs/research/weixin-openclaw-plugin-review-2026-03-22.md create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-cli/openclaw-weixin-cli-1.0.2.tgz" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-cli/package/LICENSE" create mode 100755 "\350\265\204\346\226\231/weixin-openclaw-cli/package/cli.mjs" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-cli/package/package.json" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/openclaw-weixin-1.0.2.tgz" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/CHANGELOG.md" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/CHANGELOG.zh_CN.md" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/LICENSE" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/README.md" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/README.zh_CN.md" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/index.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/openclaw.plugin.json" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/package.json" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/api.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/config-cache.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/session-guard.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/types.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/accounts.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/login-qr.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/pairing.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/aes-ecb.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/cdn-upload.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/cdn-url.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/pic-decrypt.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/upload.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/channel.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/config/config-schema.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/log-upload.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/media-download.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/mime.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/silk-transcode.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/debug-mode.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/error-notice.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/inbound.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/process-message.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/send-media.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/send.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/slash-commands.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/monitor/monitor.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/runtime.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/storage/state-dir.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/storage/sync-buf.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/logger.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/random.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/redact.ts" create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/vendor.d.ts" 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/docs/exec-plans/README.md b/docs/exec-plans/README.md index 740669d1..9644900d 100644 --- a/docs/exec-plans/README.md +++ b/docs/exec-plans/README.md @@ -44,8 +44,10 @@ | 文件 | 主题 | 状态 | |------|------|------| +| 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 待开始 | ### 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/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/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/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 42ab31ee..2cfa686a 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -30,6 +30,13 @@ function loadMcpServers(): Record | undefined { ...((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 in env values against DB settings for (const server of Object.values(merged)) { if (server.env) { @@ -42,6 +49,12 @@ function loadMcpServers(): Record | undefined { } } } + // Filter out persistently disabled servers + for (const [name, server] of Object.entries(merged)) { + if (server.enabled === false) { + delete merged[name]; + } + } return Object.keys(merged).length > 0 ? merged : undefined; } catch { return undefined; @@ -153,23 +166,10 @@ export async function POST(request: NextRequest) { updateSessionProviderId(session_id, persistProviderId); } - // Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools) - 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; - } + // Desktop main chat always uses 'code' mode with acceptEdits permissions. + // Bridge sessions may override mode via conversation-engine independently. + const permissionMode = 'acceptEdits'; + const systemPromptOverride: string | undefined = undefined; const abortController = new AbortController(); @@ -386,7 +386,7 @@ Start by greeting the user and asking the first question. 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/plugins/mcp/route.ts b/src/app/api/plugins/mcp/route.ts index acdd48aa..419c119b 100644 --- a/src/app/api/plugins/mcp/route.ts +++ b/src/app/api/plugins/mcp/route.ts @@ -47,15 +47,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 +94,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 +114,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) diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index a14c3f04..a34b93a8 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -20,7 +20,6 @@ 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 { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle } = usePanel(); @@ -33,7 +32,6 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { setWorkingDirectory(''); setSessionModel(''); setSessionProviderId(''); - setSessionMode(''); setSessionInfoLoaded(false); async function loadSession() { @@ -53,7 +51,6 @@ 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'); } } catch { @@ -127,7 +124,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { return (
- +
); } diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 4870077b..2c2be71b 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -31,12 +31,11 @@ interface ChatViewProps { initialMessages?: Message[]; initialHasMore?: boolean; modelName?: string; - initialMode?: string; providerId?: string; initialPermissionProfile?: 'default' | 'full_access'; } -export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId, initialPermissionProfile }: ChatViewProps) { +export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, providerId, initialPermissionProfile }: ChatViewProps) { const { setStreamingSessionId, workingDirectory, setPendingApprovalSessionId } = usePanel(); const { t } = useTranslation(); const router = useRouter(); @@ -48,7 +47,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal const [hasMore, setHasMore] = useState(initialHasMore); const [loadingMore, setLoadingMore] = useState(false); const loadingMoreRef = useRef(false); - const [mode, setMode] = useState(initialMode || 'code'); + const [mode, setMode] = useState('code'); // Desktop chat always uses 'code' const [currentModel, setCurrentModel] = useState(() => modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet'); const [currentProviderId, setCurrentProviderId] = useState(() => providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || ''); const [selectedEffort, setSelectedEffort] = useState(undefined); @@ -147,7 +146,6 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal } }, [initialMessages]); - useEffect(() => { if (initialMode) setMode(initialMode); }, [initialMode]); useEffect(() => { setHasMore(initialHasMore); }, [initialHasMore]); const buildThinkingConfig = useCallback((): { type: string } | undefined => { 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/plugins/McpManager.tsx b/src/components/plugins/McpManager.tsx index ee9954e6..55b85f7f 100644 --- a/src/components/plugins/McpManager.tsx +++ b/src/components/plugins/McpManager.tsx @@ -95,6 +95,26 @@ export function McpManager() { setEditorOpen(true); } + const handlePersistentToggle = useCallback(async (name: string, enabled: boolean) => { + const updated = { ...servers }; + updated[name] = { ...updated[name], enabled }; + setServers(updated); + try { + const res = await fetch('/api/plugins/mcp', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mcpServers: updated }), + }); + if (!res.ok) { + throw new Error(`Server returned ${res.status}`); + } + } catch (err) { + console.error('Failed to toggle MCP server:', err); + // Revert on failure + fetchServers(); + } + }, [servers, fetchServers]); + async function handleDelete(name: string) { try { const res = await fetch(`/api/plugins/mcp/${encodeURIComponent(name)}`, { @@ -246,6 +266,7 @@ export function McpManager() { servers={servers} onEdit={handleEdit} onDelete={handleDelete} + onToggleEnabled={handlePersistentToggle} runtimeStatus={runtimeStatus} activeSessionId={activeSessionId || undefined} /> diff --git a/src/components/plugins/McpServerList.tsx b/src/components/plugins/McpServerList.tsx index c6999a95..aefe3574 100644 --- a/src/components/plugins/McpServerList.tsx +++ b/src/components/plugins/McpServerList.tsx @@ -4,6 +4,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/com import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Trash, PencilSimple, HardDrives, WifiHigh, Globe, ArrowsClockwise, SpinnerGap } from "@/components/ui/icon"; +import { Switch } from '@/components/ui/switch'; import { useTranslation } from '@/hooks/useTranslation'; import type { TranslationKey } from '@/i18n'; import type { MCPServer } from '@/types'; @@ -19,6 +20,7 @@ interface McpServerListProps { servers: Record; onEdit: (name: string, server: MCPServer) => void; onDelete: (name: string) => void; + onToggleEnabled?: (name: string, enabled: boolean) => void; runtimeStatus?: McpRuntimeStatus[]; activeSessionId?: string; } @@ -52,7 +54,7 @@ function getStatusBadge(status: McpRuntimeStatus['status']) { } } -export function McpServerList({ servers, onEdit, onDelete, runtimeStatus, activeSessionId }: McpServerListProps) { +export function McpServerList({ servers, onEdit, onDelete, onToggleEnabled, runtimeStatus, activeSessionId }: McpServerListProps) { const { t } = useTranslation(); const entries = Object.entries(servers); const [reconnecting, setReconnecting] = useState>(new Set()); @@ -127,11 +129,20 @@ export function McpServerList({ servers, onEdit, onDelete, runtimeStatus, active const isReconnecting = reconnecting.has(name); const isToggling = toggling.has(name); + const isDisabled = server.enabled === false; + return ( - +
+ {onToggleEnabled && ( + onToggleEnabled(name, checked)} + /> + )} {name} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c1d4378f..a9270f64 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -45,8 +45,8 @@ const en = { 'messageInput.reviewDesc': 'Review code quality', 'messageInput.terminalSetupDesc': 'Configure terminal settings', 'messageInput.memoryDesc': 'Edit project memory file', - 'messageInput.modeCode': 'Code', - 'messageInput.modePlan': 'Plan', + // 'messageInput.modeCode': 'Code', // Reserved: mode UI removed in latency remediation + // 'messageInput.modePlan': 'Plan', // Reserved: mode UI removed in latency remediation 'messageInput.aiSuggested': 'AI Suggested', // ── Streaming message ─────────────────────────────────────── @@ -917,6 +917,8 @@ const en = { 'mcp.noRuntimeStatus': 'No runtime status available', 'mcp.reconnect': 'Reconnect', 'mcp.enable': 'Enable', + 'mcp.enabled': 'Enabled', + 'mcp.disabled': 'Disabled', // ── SDK Capabilities: Thinking ──────────────────────────── 'settings.thinkingMode': 'Thinking Mode', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 132b7dc7..153d34dd 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -42,8 +42,8 @@ const zh: Record = { 'messageInput.reviewDesc': '审查代码质量', 'messageInput.terminalSetupDesc': '配置终端设置', 'messageInput.memoryDesc': '编辑项目记忆文件', - 'messageInput.modeCode': '代码', - 'messageInput.modePlan': '计划', + // 'messageInput.modeCode': '代码', // Reserved: mode UI removed in latency remediation + // 'messageInput.modePlan': '计划', // Reserved: mode UI removed in latency remediation 'messageInput.aiSuggested': 'AI 推荐', // ── Streaming message ─────────────────────────────────────── @@ -914,6 +914,8 @@ const zh: Record = { 'mcp.noRuntimeStatus': '暂无运行状态信息', 'mcp.reconnect': '重连', 'mcp.enable': '启用', + 'mcp.enabled': '已启用', + 'mcp.disabled': '已禁用', // ── SDK Capabilities: Thinking ──────────────────────────── 'settings.thinkingMode': '思考模式', 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/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts index 005b31ea..0c8d2a80 100644 --- a/src/lib/bridge/conversation-engine.ts +++ b/src/lib/bridge/conversation-engine.ts @@ -44,6 +44,13 @@ function loadMcpServers(): Record | undefined { ...((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 in env values against DB settings for (const server of Object.values(merged)) { if (server.env) { @@ -56,6 +63,12 @@ function loadMcpServers(): Record | undefined { } } } + // Filter out persistently disabled servers + for (const [name, server] of Object.entries(merged)) { + if (server.enabled === false) { + delete merged[name]; + } + } return Object.keys(merged).length > 0 ? merged : undefined; } catch { return undefined; diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 9979e91e..d4657a77 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -17,7 +17,7 @@ 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'; @@ -542,9 +542,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 +839,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/types/index.ts b/src/types/index.ts index 62a62788..d110dd64 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -540,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 { 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 0000000000000000000000000000000000000000..bf0a3ae4742ae426ab1548f7bf16f4825d5d1913 GIT binary patch literal 2375 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..50141bc2f725ce2f50776b72d0d9fae98e5c819f GIT binary patch literal 47431 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

|7{xhw@Z3VtOIz-S?B11Bm8PJhK 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 bM-x5%_xRuAe~` (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; +} From e72c165ac2945d1a239e7e59d5f4d3c969ac3164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Sun, 22 Mar 2026 12:49:35 +0800 Subject: [PATCH 14/32] fix: prevent widget MCP on plain chat, clarify MCP toggle scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - needsWidgetSpecs: remove systemPrompt keyword check that caused false positives; only trigger on user prompt keywords, conversation history show-widget markers, or explicit imageAgentMode flag - MCP manager: replace hardcoded description with i18n key that explains toggle controls CodePilot injection layer only; SDK may still load servers from ~/.claude.json independently - i18n: add mcp.managerDesc in en/zh Tested: typecheck pass, 444/444 unit tests pass, 5/5 smoke tests pass. Verified plain '你好' chat SSE contains no codepilot-widget. --- .claude/worktrees/wechat-bridge | 1 + src/components/plugins/McpManager.tsx | 2 +- src/i18n/en.ts | 1 + src/i18n/zh.ts | 1 + src/lib/claude-client.ts | 6 +++--- 5 files changed, 7 insertions(+), 4 deletions(-) create mode 160000 .claude/worktrees/wechat-bridge diff --git a/.claude/worktrees/wechat-bridge b/.claude/worktrees/wechat-bridge new file mode 160000 index 00000000..b5d558d0 --- /dev/null +++ b/.claude/worktrees/wechat-bridge @@ -0,0 +1 @@ +Subproject commit b5d558d0a34dd9b291591444497ec55a6fce281a diff --git a/src/components/plugins/McpManager.tsx b/src/components/plugins/McpManager.tsx index 55b85f7f..f43256fd 100644 --- a/src/components/plugins/McpManager.tsx +++ b/src/components/plugins/McpManager.tsx @@ -228,7 +228,7 @@ export function McpManager() { )} </div> <p className="text-sm text-muted-foreground mt-0.5"> - Configure Model Context Protocol servers for Claude + {t('mcp.managerDesc' as TranslationKey)} </p> </div> <Button size="sm" className="gap-1" onClick={handleAdd}> diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a9270f64..64cb2578 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -919,6 +919,7 @@ const en = { 'mcp.enable': 'Enable', 'mcp.enabled': 'Enabled', 'mcp.disabled': 'Disabled', + 'mcp.managerDesc': 'Toggle controls CodePilot MCP injection. Servers in your Claude Code config (~/.claude.json) may still be loaded by the SDK independently.', // ── SDK Capabilities: Thinking ──────────────────────────── 'settings.thinkingMode': 'Thinking Mode', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 153d34dd..fb8abd3f 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -916,6 +916,7 @@ const zh: Record<TranslationKey, string> = { 'mcp.enable': '启用', 'mcp.enabled': '已启用', 'mcp.disabled': '已禁用', + 'mcp.managerDesc': '开关控制 CodePilot 注入的 MCP 服务。Claude Code 配置(~/.claude.json)中的服务可能仍会被 SDK 自动加载。', // ── SDK Capabilities: Thinking ──────────────────────────── 'settings.thinkingMode': '思考模式', diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index d4657a77..6f27b1cb 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -519,12 +519,12 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin if (generativeUI !== false) { const needsWidgetSpecs = (() => { 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; })(); From a38809b539ad06d0549e60c4bd75a1479ac3c094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Sun, 22 Mar 2026 17:51:29 +0800 Subject: [PATCH 15/32] chore: remove stale worktree gitlink, ignore .claude/, fix MCP scope text - Remove .claude/worktrees/wechat-bridge gitlink accidentally committed - Add .claude/ to .gitignore to prevent worktrees and session data from being tracked - Update mcp.managerDesc i18n: broaden wording from "~/.claude.json" to "Claude Code config via settingSources" to accurately reflect SDK inheritance from user/project/local settings sources Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .claude/worktrees/wechat-bridge | 1 - .gitignore | 3 +++ src/i18n/en.ts | 2 +- src/i18n/zh.ts | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) delete mode 160000 .claude/worktrees/wechat-bridge diff --git a/.claude/worktrees/wechat-bridge b/.claude/worktrees/wechat-bridge deleted file mode 160000 index b5d558d0..00000000 --- a/.claude/worktrees/wechat-bridge +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b5d558d0a34dd9b291591444497ec55a6fce281a 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/src/i18n/en.ts b/src/i18n/en.ts index 64cb2578..1fcbe1fc 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -919,7 +919,7 @@ const en = { 'mcp.enable': 'Enable', 'mcp.enabled': 'Enabled', 'mcp.disabled': 'Disabled', - 'mcp.managerDesc': 'Toggle controls CodePilot MCP injection. Servers in your Claude Code config (~/.claude.json) may still be loaded by the SDK independently.', + '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', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index fb8abd3f..80e8ae3c 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -916,7 +916,7 @@ const zh: Record<TranslationKey, string> = { 'mcp.enable': '启用', 'mcp.enabled': '已启用', 'mcp.disabled': '已禁用', - 'mcp.managerDesc': '开关控制 CodePilot 注入的 MCP 服务。Claude Code 配置(~/.claude.json)中的服务可能仍会被 SDK 自动加载。', + 'mcp.managerDesc': '开关控制 CodePilot 注入的 MCP 服务。Claude Code 自身配置中的服务仍可能被 SDK 通过 settingSources 自动加载。', // ── SDK Capabilities: Thinking ──────────────────────────── 'settings.thinkingMode': '思考模式', From 6a98c127ec03bdb853d1296cda3a14d3af410853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Mon, 23 Mar 2026 00:35:23 +0800 Subject: [PATCH 16/32] fix: self-heal bridge working directories Sync stale bridge cwd state across channel router, Claude client, and session persistence. - add shared working-directory resolution helper and tests - heal invalid binding/session cwd values before bridge resume - sync sdk_cwd when session working directory changes --- src/__tests__/unit/working-directory.test.ts | 65 +++++++++++++++++ src/lib/bridge/channel-router.ts | 74 ++++++++++++++++++-- src/lib/claude-client.ts | 25 +++++-- src/lib/db.ts | 4 +- src/lib/working-directory.ts | 73 +++++++++++++++++++ 5 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/unit/working-directory.test.ts create mode 100644 src/lib/working-directory.ts 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/lib/bridge/channel-router.ts b/src/lib/bridge/channel-router.ts index b1ec5aa4..64ba7073 100644 --- a/src/lib/bridge/channel-router.ts +++ b/src/lib/bridge/channel-router.ts @@ -15,7 +15,14 @@ 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. @@ -26,7 +33,51 @@ export function resolve(address: ChannelAddress): ChannelBinding { 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<Pick<ChannelBinding, 'workingDirectory' | 'sdkSessionId'>> = {}; + + 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/claude-client.ts b/src/lib/claude-client.ts index 6f27b1cb..d31b7ac8 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -23,6 +23,7 @@ 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<strin }); try { + const resolvedWorkingDirectory = resolveWorkingDirectory([ + { path: workingDirectory, source: 'requested' }, + ]); + + if (workingDirectory && resolvedWorkingDirectory.source !== 'requested') { + console.warn( + `[claude-client] Working directory "${workingDirectory}" is unavailable, falling back to "${resolvedWorkingDirectory.path}"`, + ); + } + // Build env for the Claude Code subprocess. // Start with process.env (includes user shell env from Electron's loadUserShellEnv). // Then overlay any API config the user set in CodePilot settings (optional). @@ -451,7 +462,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin const skipPermissions = globalSkip || !!sessionBypassPermissions; const queryOptions: Options = { - cwd: workingDirectory || os.homedir(), + cwd: resolvedWorkingDirectory.path, abortController, includePartialMessages: true, permissionMode: skipPermissions @@ -575,8 +586,10 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin // Resume depends on session context (cwd/project scope), so if the // original working_directory no longer exists, resume will fail. let shouldResume = !!sdkSessionId; - if (shouldResume && workingDirectory && !fs.existsSync(workingDirectory)) { - console.warn(`[claude-client] Working directory "${workingDirectory}" does not exist, skipping resume`); + if (shouldResume && workingDirectory && resolvedWorkingDirectory.source !== 'requested') { + console.warn( + `[claude-client] Working directory "${workingDirectory}" does not exist, skipping resume`, + ); shouldResume = false; if (sessionId) { try { updateSdkSessionId(sessionId, ''); } catch { /* best effort */ } @@ -661,7 +674,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin const telegramOpts = { sessionId, sessionTitle: undefined as string | undefined, - workingDirectory, + workingDirectory: resolvedWorkingDirectory.path, }; // No queryOptions.hooks — all hook types (Notification, PostToolUse) use @@ -709,7 +722,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin let textPrompt = basePrompt; if (nonImageFiles.length > 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})]`) @@ -726,7 +739,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin const textWithImageRefs = imageAgentMode ? textPrompt : (() => { - 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})]`) diff --git a/src/lib/db.ts b/src/lib/db.ts index 89e02c21..167164a9 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -825,7 +825,9 @@ export function setDefaultProviderId(id: string): void { 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); + db.prepare( + 'UPDATE chat_sessions SET working_directory = ?, sdk_cwd = ?, project_name = ? WHERE id = ?' + ).run(workingDirectory, workingDirectory, projectName, id); } export function updateSessionMode(id: string, mode: string): void { 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<WorkingDirectorySource, 'home' | 'process'>; +} + +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, + }; +} From f7434d153b9c66a95d50323f588593c0fb5b274d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Mon, 23 Mar 2026 00:35:33 +0800 Subject: [PATCH 17/32] feat: add WeChat bridge channel Implement the native WeChat bridge adapter and settings flow using the reverse-engineered OpenClaw plugin protocol. - add multi-account WeChat bridge adapter, auth APIs, persistence, and routing - wire bridge manager, settings UI, i18n, and delivery/error handling - add WeChat unit tests and handover docs for protocol behavior --- docs/handover/bridge-system.md | 34 ++ package-lock.json | 207 ++++++- package.json | 2 + src/__tests__/unit/weixin-adapter-ack.test.ts | 90 +++ src/__tests__/unit/weixin-api.test.ts | 53 ++ src/__tests__/unit/weixin-ids.test.ts | 54 ++ src/__tests__/unit/weixin-media.test.ts | 77 +++ .../unit/weixin-session-guard.test.ts | 55 ++ src/app/api/bridge/route.ts | 4 +- src/app/api/bridge/settings/route.ts | 2 + .../weixin/accounts/[accountId]/route.ts | 95 ++++ src/app/api/settings/weixin/accounts/route.ts | 28 + .../api/settings/weixin/login/start/route.ts | 19 + .../api/settings/weixin/login/wait/route.ts | 58 ++ src/app/api/settings/weixin/route.ts | 45 ++ src/components/bridge/BridgeLayout.tsx | 6 +- src/components/bridge/BridgeSection.tsx | 41 +- src/components/bridge/WeixinBridgeSection.tsx | 406 ++++++++++++++ src/hooks/useBridgeStatus.ts | 13 +- src/i18n/en.ts | 48 ++ src/i18n/zh.ts | 48 ++ src/lib/bridge/adapters/index.ts | 1 + src/lib/bridge/adapters/weixin-adapter.ts | 525 ++++++++++++++++++ src/lib/bridge/adapters/weixin/weixin-api.ts | 268 +++++++++ src/lib/bridge/adapters/weixin/weixin-auth.ts | 173 ++++++ src/lib/bridge/adapters/weixin/weixin-ids.ts | 29 + .../bridge/adapters/weixin/weixin-media.ts | 194 +++++++ .../adapters/weixin/weixin-session-guard.ts | 71 +++ .../bridge/adapters/weixin/weixin-types.ts | 188 +++++++ src/lib/bridge/bridge-manager.ts | 59 +- src/lib/bridge/channel-router.ts | 50 +- src/lib/bridge/conversation-engine.ts | 43 +- src/lib/bridge/permission-broker.ts | 2 +- src/lib/bridge/types.ts | 1 + src/lib/db.ts | 136 ++++- src/types/index.ts | 24 + 36 files changed, 3126 insertions(+), 23 deletions(-) create mode 100644 src/__tests__/unit/weixin-adapter-ack.test.ts create mode 100644 src/__tests__/unit/weixin-api.test.ts create mode 100644 src/__tests__/unit/weixin-ids.test.ts create mode 100644 src/__tests__/unit/weixin-media.test.ts create mode 100644 src/__tests__/unit/weixin-session-guard.test.ts create mode 100644 src/app/api/settings/weixin/accounts/[accountId]/route.ts create mode 100644 src/app/api/settings/weixin/accounts/route.ts create mode 100644 src/app/api/settings/weixin/login/start/route.ts create mode 100644 src/app/api/settings/weixin/login/wait/route.ts create mode 100644 src/app/api/settings/weixin/route.ts create mode 100644 src/components/bridge/WeixinBridgeSection.tsx create mode 100644 src/lib/bridge/adapters/weixin-adapter.ts create mode 100644 src/lib/bridge/adapters/weixin/weixin-api.ts create mode 100644 src/lib/bridge/adapters/weixin/weixin-auth.ts create mode 100644 src/lib/bridge/adapters/weixin/weixin-ids.ts create mode 100644 src/lib/bridge/adapters/weixin/weixin-media.ts create mode 100644 src/lib/bridge/adapters/weixin/weixin-session-guard.ts create mode 100644 src/lib/bridge/adapters/weixin/weixin-types.ts diff --git a/docs/handover/bridge-system.md b/docs/handover/bridge-system.md index feff07a3..f1691cf6 100644 --- a/docs/handover/bridge-system.md +++ b/docs/handover/bridge-system.md @@ -110,6 +110,40 @@ Discord 消息 → discord.js Client (Gateway WebSocket) - **授权默认拒绝**:空白允许列表 = 拒绝所有(安全优先,同飞书模式) - **`!` 命令别名**:在 adapter 层规范化为 `/` 命令后入队——bridge-manager 命令处理器无需改动 +### WeChat Adapter + +**Architecture**: Native `BaseChannelAdapter` implementation using HTTP long-polling. + +**Key files**: +- `src/lib/bridge/adapters/weixin-adapter.ts` — Main adapter (multi-account worker model) +- `src/lib/bridge/adapters/weixin/weixin-api.ts` — HTTP protocol client +- `src/lib/bridge/adapters/weixin/weixin-auth.ts` — QR code login flow +- `src/lib/bridge/adapters/weixin/weixin-media.ts` — AES-128-ECB media encryption/decryption +- `src/lib/bridge/adapters/weixin/weixin-ids.ts` — Synthetic chatId encode/decode +- `src/lib/bridge/adapters/weixin/weixin-session-guard.ts` — Account pause management + +**Multi-account model**: Each QR-linked WeChat account runs its own long-polling worker. Accounts are stored in the `weixin_accounts` table. The adapter uses synthetic chatId format `weixin::<accountId>::<peerUserId>` to isolate conversations across accounts without modifying the `channel_bindings` schema. + +**Data persistence**: +- `weixin_accounts` — Bot credentials, base URLs, enabled status +- `weixin_context_tokens` — Per-(account, peer) context tokens (required for sending messages) +- `channel_offsets` with key `weixin:<accountId>` — Long-poll cursor (`get_updates_buf`) + +**Authentication**: QR code login via WeChat ilink bot API. The QR login flow is managed by `weixin-auth.ts` with active sessions stored in `globalThis` to survive Next.js HMR. Login results are persisted to `weixin_accounts`. + +**Message flow**: +- Inbound: `getUpdates` long-poll → message standardization → context_token persistence → media decryption → `InboundMessage` queue +- Outbound: Decode synthetic chatId → retrieve context_token from DB → `sendTextMessage` (plain text only) + +**Media**: AES-128-ECB encryption for CDN upload/download. Inbound media (images, files, videos, voice) is decrypted and converted to `FileAttachment`. Outbound media is text-only in this version. + +**Known limitations**: +- Private chat only (no groups) +- No streaming preview (WeChat doesn't support message editing) +- No inline buttons (permissions use `/perm` text fallback) +- Session expiry (errcode -14) pauses account for 60 minutes +- Real QR code scanning requires a WeChat account with ilink bot access + ### Telegram ``` diff --git a/package-lock.json b/package-lock.json index 619a6bbe..e9b494a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@streamdown/code": "^1.0.1", "@streamdown/math": "^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", @@ -43,6 +44,7 @@ "nanoid": "^5.1.6", "next": "16.1.6", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", @@ -9127,6 +9129,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", @@ -11278,6 +11289,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", @@ -12486,6 +12506,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", @@ -12749,6 +12778,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", @@ -20817,6 +20852,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", @@ -20933,7 +20977,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" @@ -21118,6 +21161,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", @@ -21448,6 +21500,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", @@ -22824,6 +23011,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", @@ -23310,6 +23503,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", @@ -26128,6 +26327,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 da48d06f..082a699b 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@streamdown/code": "^1.0.1", "@streamdown/math": "^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", @@ -69,6 +70,7 @@ "nanoid": "^5.1.6", "next": "16.1.6", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", 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/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/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<string | null> { + 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<string | null> { + 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<string, string> = {}; + 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<string, string> }; + 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/components/bridge/BridgeLayout.tsx b/src/components/bridge/BridgeLayout.tsx index 06276bab..2d6919c2 100644 --- a/src/components/bridge/BridgeLayout.tsx +++ b/src/components/bridge/BridgeLayout.tsx @@ -9,10 +9,11 @@ import { TelegramBridgeSection } from "./TelegramBridgeSection"; import { FeishuBridgeSection } from "./FeishuBridgeSection"; import { DiscordBridgeSection } from "./DiscordBridgeSection"; import { QqBridgeSection } from "./QqBridgeSection"; +import { WeixinBridgeSection } from "./WeixinBridgeSection"; import { useTranslation } from "@/hooks/useTranslation"; import type { TranslationKey } from "@/i18n"; -type Section = "bridge" | "telegram" | "feishu" | "discord" | "qq"; +type Section = "bridge" | "telegram" | "feishu" | "discord" | "qq" | "weixin"; interface SidebarItem { id: Section; @@ -26,6 +27,7 @@ const sidebarItems: SidebarItem[] = [ { id: "feishu", label: "Feishu", icon: ChatTeardrop }, { id: "discord", label: "Discord", icon: GameController }, { id: "qq", label: "QQ", icon: ChatsCircle }, + { id: "weixin", label: "WeChat", icon: ChatTeardrop }, ]; function getSectionFromHash(): Section { @@ -55,6 +57,7 @@ export function BridgeLayout() { 'Feishu': 'bridge.feishuSettings', 'Discord': 'bridge.discordSettings', 'QQ': 'bridge.qqSettings', + 'WeChat': 'bridge.weixinSettings', }; const handleSectionChange = useCallback((section: Section) => { @@ -98,6 +101,7 @@ export function BridgeLayout() { {activeSection === "feishu" && <FeishuBridgeSection />} {activeSection === "discord" && <DiscordBridgeSection />} {activeSection === "qq" && <QqBridgeSection />} + {activeSection === "weixin" && <WeixinBridgeSection />} </div> </div> </div> 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<string, string> = { + 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() { ) : ( <Button size="sm" - onClick={startBridge} + onClick={handleStartBridge} disabled={starting} > {starting ? ( @@ -326,6 +350,21 @@ export function BridgeSection() { /> </div> + <div className="flex items-center justify-between border-t border-border/30 pt-3"> + <div className="flex items-center gap-3"> + <ChatTeardrop size={16} className="text-muted-foreground" /> + <div> + <p className="text-sm">{t("bridge.weixinChannel")}</p> + <p className="text-xs text-muted-foreground">{t("bridge.weixinChannelDesc")}</p> + </div> + </div> + <Switch + checked={isWeixinEnabled} + onCheckedChange={handleToggleWeixin} + disabled={saving} + /> + </div> + <FieldRow label={t("bridge.autoStart")} description={t("bridge.autoStartDesc")} diff --git a/src/components/bridge/WeixinBridgeSection.tsx b/src/components/bridge/WeixinBridgeSection.tsx new file mode 100644 index 00000000..ba3a66a4 --- /dev/null +++ b/src/components/bridge/WeixinBridgeSection.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { SpinnerGap, CheckCircle, Warning, Trash, Plus, Code } from "@/components/ui/icon"; +import { useTranslation } from "@/hooks/useTranslation"; +import { showToast } from "@/hooks/useToast"; +import { SettingsCard } from "@/components/patterns/SettingsCard"; +import { StatusBanner } from "@/components/patterns/StatusBanner"; + +interface WeixinAccount { + account_id: string; + user_id: string; + base_url: string; + cdn_base_url: string; + name: string; + enabled: boolean; + has_token: boolean; + last_login_at: string | null; + created_at: string; +} + +export function WeixinBridgeSection() { + const { t } = useTranslation(); + const [accounts, setAccounts] = useState<WeixinAccount[]>([]); + + // QR Login state + const [qrImage, setQrImage] = useState<string | null>(null); + const [qrSessionId, setQrSessionId] = useState<string | null>(null); + const [qrStatus, setQrStatus] = useState<string>(""); + const [qrBridgeError, setQrBridgeError] = useState<string | null>(null); + const [qrLoading, setQrLoading] = useState(false); + const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); + const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null); + + const fetchAccounts = useCallback(async () => { + try { + const res = await fetch("/api/settings/weixin/accounts"); + if (res.ok) { + const data = await res.json(); + setAccounts(data.accounts || []); + } + } catch { /* ignore */ } + }, []); + + useEffect(() => { + fetchAccounts(); + }, [fetchAccounts]); + + // Cleanup poll timer on unmount + useEffect(() => { + return () => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + } + }; + }, []); + + const formatToastMessage = useCallback((fallback: string, detail?: string) => { + return detail ? `${fallback}: ${detail}` : fallback; + }, []); + + const handleToggleAccount = async (accountId: string, enabled: boolean) => { + try { + const res = await fetch(`/api/settings/weixin/accounts/${accountId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + const data = await res.json().catch(() => null) as + | { error?: string; account_updated?: boolean } + | null; + + if (!res.ok) { + if (data?.account_updated) { + fetchAccounts(); + showToast({ + type: "warning", + message: formatToastMessage(t("weixin.accountUpdateSavedRestartFailed"), data.error), + }); + return; + } + showToast({ + type: "error", + message: data?.error || t("weixin.accountUpdateFailed"), + }); + return; + } + + fetchAccounts(); + } catch (err) { + showToast({ + type: "error", + message: err instanceof Error ? err.message : t("weixin.accountUpdateFailed"), + }); + } + }; + + const handleDeleteAccount = async (accountId: string) => { + try { + const res = await fetch(`/api/settings/weixin/accounts/${accountId}`, { + method: "DELETE", + }); + const data = await res.json().catch(() => null) as + | { error?: string; account_deleted?: boolean } + | null; + + if (!res.ok) { + if (data?.account_deleted) { + setDeleteConfirm(null); + fetchAccounts(); + showToast({ + type: "warning", + message: formatToastMessage(t("weixin.accountDeleteSavedRestartFailed"), data.error), + }); + return; + } + showToast({ + type: "error", + message: data?.error || t("weixin.accountDeleteFailed"), + }); + return; + } + + setDeleteConfirm(null); + fetchAccounts(); + } catch (err) { + showToast({ + type: "error", + message: err instanceof Error ? err.message : t("weixin.accountDeleteFailed"), + }); + } + }; + + // ── QR Login Flow ─────────────────────────────────────── + + const pollQrStatus = useCallback(async (sessionId: string) => { + try { + const res = await fetch("/api/settings/weixin/login/wait", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId }), + }); + const data = await res.json().catch(() => null) as + | { + status?: string; + qr_image?: string; + error?: string; + bridge_restart_error?: string; + } + | null; + if (!res.ok) { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + setQrStatus("failed"); + setQrBridgeError(null); + showToast({ + type: "error", + message: data?.error || t("weixin.qrFailed"), + }); + return; + } + if (!data?.status) return; + + setQrStatus(data.status); + setQrBridgeError(data.bridge_restart_error || null); + + if (data.qr_image && data.status === "waiting") { + setQrImage(data.qr_image); + } + + if (data.status === "confirmed" || data.status === "failed") { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (data.status === "confirmed") { + fetchAccounts(); + if (data.bridge_restart_error) { + showToast({ + type: "warning", + message: formatToastMessage(t("weixin.qrConfirmedRestartFailed"), data.bridge_restart_error), + }); + } else { + // Auto-close QR panel after success + setTimeout(() => { + setQrImage(null); + setQrSessionId(null); + setQrStatus(""); + setQrBridgeError(null); + }, 2000); + } + } else if (data.status === "failed" && data.error) { + showToast({ + type: "error", + message: data.error, + }); + } + } + } catch { /* ignore */ } + }, [fetchAccounts, formatToastMessage, t]); + + const startQrLogin = async () => { + setQrLoading(true); + setQrStatus(""); + setQrBridgeError(null); + try { + const res = await fetch("/api/settings/weixin/login/start", { method: "POST" }); + if (!res.ok) throw new Error("Failed to start QR login"); + const data = await res.json(); + setQrImage(data.qr_image); + setQrSessionId(data.session_id); + setQrStatus("waiting"); + + // Start polling + if (pollTimerRef.current) clearInterval(pollTimerRef.current); + pollTimerRef.current = setInterval(() => pollQrStatus(data.session_id), 3000); + } catch (err) { + setQrStatus("failed"); + showToast({ + type: "error", + message: err instanceof Error ? err.message : t("weixin.qrFailed"), + }); + } finally { + setQrLoading(false); + } + }; + + const cancelQrLogin = () => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + setQrImage(null); + setQrSessionId(null); + setQrStatus(""); + setQrBridgeError(null); + }; + + // Suppress unused variable warning for qrSessionId + void qrSessionId; + + return ( + <div className="max-w-3xl space-y-6"> + {/* Risk Warning */} + <StatusBanner variant="warning" className="text-sm"> + <Warning size={16} className="shrink-0 mr-2 mt-0.5" /> + <span>{t("weixin.riskWarning")}</span> + </StatusBanner> + + {/* Accounts Section */} + <SettingsCard + title={t("weixin.accounts")} + description={t("weixin.accountsDesc")} + > + {accounts.length === 0 ? ( + <p className="text-sm text-muted-foreground py-2">{t("weixin.noAccounts")}</p> + ) : ( + <div className="space-y-2"> + {accounts.map(account => ( + <div + key={account.account_id} + className="flex items-center justify-between rounded-md border border-border/30 px-3 py-2" + > + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium truncate">{account.name || account.account_id}</p> + <p className="text-xs text-muted-foreground"> + {account.enabled ? t("weixin.accountActive") : t("weixin.accountPaused")} + {account.has_token ? "" : ` · ${t("weixin.accountExpired")}`} + </p> + </div> + <div className="flex items-center gap-2 ml-3"> + <Switch + checked={account.enabled} + onCheckedChange={(checked) => handleToggleAccount(account.account_id, checked)} + /> + {deleteConfirm === account.account_id ? ( + <div className="flex items-center gap-1"> + <Button + size="sm" + variant="destructive" + onClick={() => handleDeleteAccount(account.account_id)} + > + {t("common.delete")} + </Button> + <Button + size="sm" + variant="ghost" + onClick={() => setDeleteConfirm(null)} + > + {t("common.cancel")} + </Button> + </div> + ) : ( + <Button + size="sm" + variant="ghost" + onClick={() => setDeleteConfirm(account.account_id)} + className="text-muted-foreground hover:text-destructive" + > + <Trash size={14} /> + </Button> + )} + </div> + </div> + ))} + </div> + )} + + {/* Add Account / QR Login */} + {!qrImage ? ( + <Button + size="sm" + onClick={startQrLogin} + disabled={qrLoading} + className="mt-3" + > + {qrLoading ? ( + <SpinnerGap size={14} className="animate-spin mr-1.5" /> + ) : ( + <Plus size={14} className="mr-1.5" /> + )} + {t("weixin.addAccount")} + </Button> + ) : ( + <div className="mt-3 rounded-md border border-border/50 p-4 space-y-3"> + <div className="flex items-center justify-between"> + <h3 className="text-sm font-medium flex items-center gap-2"> + <Code size={16} /> + {t("weixin.qrLogin")} + </h3> + <Button size="sm" variant="ghost" onClick={cancelQrLogin}> + {t("common.cancel")} + </Button> + </div> + + {/* QR Code Image */} + <div className="flex justify-center"> + <img + src={qrImage.startsWith("data:") ? qrImage : qrImage.startsWith("http") ? qrImage : `data:image/png;base64,${qrImage}`} + alt="WeChat QR Code" + className="w-48 h-48 rounded-md border border-border/30" + /> + </div> + + {/* Status */} + <div className="text-center"> + {qrStatus === "waiting" && ( + <StatusBanner variant="info"> + <SpinnerGap size={14} className="animate-spin mr-1.5 inline" /> + {t("weixin.qrWaiting")} + </StatusBanner> + )} + {qrStatus === "scanned" && ( + <StatusBanner variant="info"> + <CheckCircle size={14} className="mr-1.5 inline text-primary" /> + {t("weixin.qrScanned")} + </StatusBanner> + )} + {qrStatus === "confirmed" && ( + <StatusBanner variant="success"> + <CheckCircle size={14} className="mr-1.5 inline" /> + {t("weixin.qrConfirmed")} + </StatusBanner> + )} + {qrStatus === "expired" && ( + <StatusBanner variant="warning"> + <Warning size={14} className="mr-1.5 inline" /> + {t("weixin.qrExpired")} + </StatusBanner> + )} + {qrStatus === "failed" && ( + <StatusBanner variant="error"> + <Warning size={14} className="mr-1.5 inline" /> + {t("weixin.qrFailed")} + </StatusBanner> + )} + {qrBridgeError && ( + <StatusBanner variant="warning" className="mt-2"> + <Warning size={14} className="mr-1.5 inline" /> + {formatToastMessage(t("weixin.qrConfirmedRestartFailed"), qrBridgeError)} + </StatusBanner> + )} + </div> + </div> + )} + </SettingsCard> + + {/* Setup Guide */} + <SettingsCard + title={t("weixin.setupGuide")} + > + <ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground"> + <li>{t("weixin.step1")}</li> + <li>{t("weixin.step2")}</li> + <li>{t("weixin.step3")}</li> + <li>{t("weixin.step4")}</li> + <li>{t("weixin.step5")}</li> + </ol> + </SettingsCard> + </div> + ); +} 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<void>; + startBridge: () => Promise<string | null>; stopBridge: () => Promise<void>; refreshStatus: () => Promise<void>; } { @@ -62,17 +62,22 @@ export function useBridgeStatus(): { }; }, [bridgeStatus?.running, refreshStatus]); - const startBridge = useCallback(async () => { + const startBridge = useCallback(async (): Promise<string | null> => { 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/i18n/en.ts b/src/i18n/en.ts index c1d4378f..1559a763 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -685,6 +685,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 +761,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', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 132b7dc7..c012bdde 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -682,6 +682,15 @@ const zh: Record<TranslationKey, string> = { '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 +758,45 @@ const zh: Record<TranslationKey, string> = { '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': '助理工作区', 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::<accountId>::<peerUserId> + */ + +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<string, AbortController>(); + private seenMessageIds = new Map<string, Set<string>>(); + private consecutiveFailures = new Map<string, number>(); + private typingTickets = new Map<string, string>(); + + // Per-batch cursor ack tracking: hold cursor in memory until all + // messages in the batch are acknowledged by bridge-manager. + private pendingCursors = new Map<number, { + offsetKey: string; + cursor: string; + remaining: number; + sealed: boolean; + }>(); + private nextBatchId = 1; + + // ── Lifecycle ─────────────────────────────────────────────── + + async start(): Promise<void> { + 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<void> { + 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<InboundMessage | null> { + if (this.queue.length > 0) { + return this.queue.shift()!; + } + if (!this._running) return null; + + return new Promise<InboundMessage | null>((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<SendResult> { + 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<void> { + 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<void> { + 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<void> { + 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<FileAttachment[]> { + 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<void> { + 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<string, string> { + const headers: Record<string, string> = { + '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<T>( + creds: WeixinCredentials, + endpoint: string, + body: unknown, + timeoutMs: number = API_TIMEOUT_MS, + routeTag?: string, +): Promise<T> { + 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<GetUpdatesResponse> { + try { + return await weixinRequest<GetUpdatesResponse>( + 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<Record<string, unknown>>(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<GetUploadUrlResponse> { + return weixinRequest<GetUploadUrlResponse>(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<GetConfigResponse> { + return weixinRequest<GetConfigResponse>( + 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<void> { + try { + await weixinRequest<Record<string, unknown>>( + 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<QrCodeStartResponse> { + 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<QrCodeStatusResponse> { + 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<string, QrLoginSession> { + const g = globalThis as Record<string, unknown>; + if (!g[GLOBAL_KEY]) { + g[GLOBAL_KEY] = new Map<string, QrLoginSession>(); + } + return g[GLOBAL_KEY] as Map<string, QrLoginSession>; +} + +/** + * 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<QrLoginSession> { + 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::<accountId>::<peerUserId> + * 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<Buffer> { + 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<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', + 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<string, AccountPauseState>(); + +/** + * 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<void>): 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<void> { +export async function start(): Promise<StartResult> { 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<void> { 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<void> { 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<void> { } console.log(`[bridge-manager] Bridge started with ${startedCount} adapter(s)`); + return { started: true }; } /** @@ -333,6 +372,18 @@ export async function stop(): Promise<void> { 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<StartResult> { + 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..b666a318 100644 --- a/src/lib/bridge/channel-router.ts +++ b/src/lib/bridge/channel-router.ts @@ -5,6 +5,7 @@ * the corresponding ChannelBinding (and underlying chat_session). */ +import fs from 'fs'; import type { ChannelAddress, ChannelBinding, ChannelType } from './types'; import { getChannelBinding, @@ -15,20 +16,61 @@ import { createSession, getSetting, updateSessionProviderId, + updateSessionWorkingDirectory, + updateSdkSessionId, } from '../db'; +/** + * Resolve the first existing directory from a list of candidates. + */ +function resolveValidCwd(...candidates: (string | undefined | null)[]): string { + for (const dir of candidates) { + if (dir && fs.existsSync(dir)) return dir; + } + return process.env.HOME || ''; +} + /** * 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; - // Session was deleted — recreate - return createBinding(address); + if (!session) { + // Session was deleted — recreate + return createBinding(address); + } + + // Self-heal: validate workingDirectory and fix stale state + const currentCwd = existing.workingDirectory; + if (currentCwd && !fs.existsSync(currentCwd)) { + const validCwd = resolveValidCwd( + session.working_directory, + getSetting('bridge_default_work_dir'), + ); + console.log(`[channel-router] Self-healing stale cwd "${currentCwd}" → "${validCwd}" for binding ${existing.id}`); + + // Update binding + updateChannelBinding(existing.id, { + workingDirectory: validCwd, + sdkSessionId: '', // Clear resume — old session context is invalid + }); + + // Update session + updateSessionWorkingDirectory(existing.codepilotSessionId, validCwd); + updateSdkSessionId(existing.codepilotSessionId, ''); + + return { + ...existing, + workingDirectory: validCwd, + sdkSessionId: '', + }; + } + + return existing; } return createBinding(address); } diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts index 005b31ea..4a9bbc0b 100644 --- a/src/lib/bridge/conversation-engine.ts +++ b/src/lib/bridge/conversation-engine.ts @@ -100,6 +100,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. @@ -216,13 +227,30 @@ export async function processMessage( // user-level MCP tools, matching the desktop chat route behavior. const mcpServers = loadMcpServers(); + // 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, + workingDirectory: effectiveCwd, abortController, permissionMode, provider: resolvedProvider, @@ -385,10 +413,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 = [ `<b>Permission Required</b>`, 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<string, number> = { slack: 40000, feishu: 30000, qq: 2000, + weixin: 4096, }; diff --git a/src/lib/db.ts b/src/lib/db.ts index 89e02c21..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) + ); + `); } // ========================================== @@ -825,7 +852,8 @@ export function setDefaultProviderId(id: string): void { 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 { @@ -2125,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/types/index.ts b/src/types/index.ts index 62a62788..d3fc0bd5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1084,3 +1084,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; +} From 2aa3718b35390e13c9458613975c698566a33fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Mon, 23 Mar 2026 00:41:48 +0800 Subject: [PATCH 18/32] chore: prepare v0.38.5 release Finalize the local release candidate after merging the WeChat bridge work. - bump the app version from 0.38.4 to 0.38.5 - sync the root package-lock metadata for the new version - exclude research/worktree directories from the main TypeScript project --- package-lock.json | 4 ++-- package.json | 2 +- tsconfig.json | 12 +++++++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e9b494a8..37d86321 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.38.4", + "version": "0.38.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.38.4", + "version": "0.38.5", "license": "BUSL-1.1", "workspaces": [ "apps/*", diff --git a/package.json b/package.json index 082a699b..53b108a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.38.4", + "version": "0.38.5", "private": true, "license": "BUSL-1.1", "workspaces": [ 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", + "资料" + ] } From ab952eed0da8acccadba5fbaded93caba3a89721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Mon, 23 Mar 2026 01:03:53 +0800 Subject: [PATCH 19/32] chore: patch security dependencies Upgrade Next.js and release tooling to patched versions. Pin secure transitive overrides for axios, undici, mermaid, tar, hono, flatted, and lodash-es. Lock @streamdown/mermaid to 1.0.1 to preserve test compatibility while keeping mermaid patched. --- apps/site/package.json | 2 +- package-lock.json | 630 +++++++++++++++++++---------------------- package.json | 20 +- 3 files changed, 314 insertions(+), 338 deletions(-) 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/package-lock.json b/package-lock.json index 37d86321..ea9dcc22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@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", @@ -42,7 +42,7 @@ "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", @@ -76,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": { @@ -103,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", @@ -590,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" ], @@ -612,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" ], @@ -628,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" ], @@ -644,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" ], @@ -660,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" ], @@ -676,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" ], @@ -692,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" ], @@ -708,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" ], @@ -957,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" @@ -978,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", @@ -2039,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": { @@ -2761,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": { @@ -2816,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": { @@ -2844,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" @@ -4486,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", @@ -5050,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": { @@ -5151,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": { @@ -5167,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" ], @@ -5183,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" ], @@ -5199,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" ], @@ -5215,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" ], @@ -5231,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" ], @@ -5247,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" ], @@ -5263,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" ], @@ -5279,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" ], @@ -8219,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", @@ -9447,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" @@ -9792,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", @@ -9897,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" @@ -9968,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": { @@ -10196,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": { @@ -10213,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", @@ -10221,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", @@ -10243,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": { @@ -10294,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", @@ -10349,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" @@ -10737,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" } }, @@ -11014,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": { @@ -11104,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", @@ -11188,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" @@ -11386,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": { @@ -11411,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", @@ -12404,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", @@ -12832,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" @@ -13052,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", @@ -13078,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" } }, @@ -13129,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", @@ -13772,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", @@ -14694,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": { @@ -14714,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": { @@ -14810,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" }, @@ -15970,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" @@ -17287,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": { @@ -18793,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", @@ -19721,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": { @@ -19929,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": { @@ -20143,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" @@ -20162,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", @@ -20370,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": { @@ -23189,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": { @@ -23355,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": { @@ -24490,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", @@ -24923,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": { @@ -25622,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" @@ -26191,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" }, diff --git a/package.json b/package.json index 53b108a8..28866340 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@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", @@ -68,7 +68,7 @@ "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", @@ -102,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" } } From a6b1273fe23206df906ac3a889f54249c8a60c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Mon, 23 Mar 2026 01:10:44 +0800 Subject: [PATCH 20/32] docs: refresh weixin bridge handover Document the final WeChat bridge architecture, QR login flow, cursor ack semantics, cwd self-healing, settings, and API routes. --- docs/handover/bridge-system.md | 131 ++++++++++++++++++++++++--------- 1 file changed, 97 insertions(+), 34 deletions(-) diff --git a/docs/handover/bridge-system.md b/docs/handover/bridge-system.md index f1691cf6..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::<accountId>::<peerUserId>) +│ │ └── weixin-session-guard.ts # errcode -14 暂停保护 │ ├── feishu-adapter.ts # 薄代理 → ChannelPluginAdapter(FeishuChannelPlugin) │ └── discord-adapter.ts # Discord.js Client + Gateway intents + 按钮交互 + 流式预览 + 自注册 ├── markdown/ @@ -110,39 +117,76 @@ Discord 消息 → discord.js Client (Gateway WebSocket) - **授权默认拒绝**:空白允许列表 = 拒绝所有(安全优先,同飞书模式) - **`!` 命令别名**:在 adapter 层规范化为 `/` 命令后入队——bridge-manager 命令处理器无需改动 -### WeChat Adapter +### 微信(Native BaseChannelAdapter + 多账号长轮询) -**Architecture**: Native `BaseChannelAdapter` implementation using HTTP long-polling. - -**Key files**: -- `src/lib/bridge/adapters/weixin-adapter.ts` — Main adapter (multi-account worker model) -- `src/lib/bridge/adapters/weixin/weixin-api.ts` — HTTP protocol client -- `src/lib/bridge/adapters/weixin/weixin-auth.ts` — QR code login flow -- `src/lib/bridge/adapters/weixin/weixin-media.ts` — AES-128-ECB media encryption/decryption -- `src/lib/bridge/adapters/weixin/weixin-ids.ts` — Synthetic chatId encode/decode -- `src/lib/bridge/adapters/weixin/weixin-session-guard.ts` — Account pause management - -**Multi-account model**: Each QR-linked WeChat account runs its own long-polling worker. Accounts are stored in the `weixin_accounts` table. The adapter uses synthetic chatId format `weixin::<accountId>::<peerUserId>` to isolate conversations across accounts without modifying the `channel_bindings` schema. - -**Data persistence**: -- `weixin_accounts` — Bot credentials, base URLs, enabled status -- `weixin_context_tokens` — Per-(account, peer) context tokens (required for sending messages) -- `channel_offsets` with key `weixin:<accountId>` — Long-poll cursor (`get_updates_buf`) - -**Authentication**: QR code login via WeChat ilink bot API. The QR login flow is managed by `weixin-auth.ts` with active sessions stored in `globalThis` to survive Next.js HMR. Login results are persisted to `weixin_accounts`. - -**Message flow**: -- Inbound: `getUpdates` long-poll → message standardization → context_token persistence → media decryption → `InboundMessage` queue -- Outbound: Decode synthetic chatId → retrieve context_token from DB → `sendTextMessage` (plain text only) - -**Media**: AES-128-ECB encryption for CDN upload/download. Inbound media (images, files, videos, voice) is decrypted and converted to `FileAttachment`. Outbound media is text-only in this version. +``` +微信消息 → WeixinAdapter.runPollLoop(account) + → getupdates(long-poll, get_updates_buf) + → context_token 落库(weixin_context_tokens) + → 媒体解密(AES-128-ECB,可按设置关闭) + → synthetic chatId = weixin::<accountId>::<peerUserId> + → 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 <id>` 文本降级 + → deliverResponse() 纯文本分片(4096 chars, 最多 5 段) + → sendmessage({ msg, base_info }) + → handleMessage() finally + → adapter.acknowledgeUpdate(batchId) + → batch sealed + remaining=0 + → channel_offsets["weixin:<accountId>"] = get_updates_buf +``` -**Known limitations**: -- Private chat only (no groups) -- No streaming preview (WeChat doesn't support message editing) -- No inline buttons (permissions use `/perm` text fallback) -- Session expiry (errcode -14) pauses account for 60 minutes -- Real QR code scanning requires a WeChat account with ilink bot access +**关键文件** +- `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::<accountId>::<peerUserId>` synthetic chatId 编解码。 +- `src/lib/bridge/adapters/weixin/weixin-session-guard.ts`:`errcode = -14` 会话失效时暂停账号 60 分钟,避免无限重试。 + +**为什么用 synthetic chatId** +- 微信 bridge 需要多账号并存,但 `channel_bindings` 表没有单独的 account 维度。 +- 方案是把账号隔离编码进 chatId:`weixin::<accountId>::<peerUserId>`。 +- 这样 `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:<accountId>` 保存每个账号各自的 `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 <permission_request_id>`。 + +**当前限制** +- 仅支持私聊,不支持群聊语义。 +- 不支持流式预览;微信端无法像 Telegram/飞书那样持续编辑同一条消息。 +- 当前版本只做文本出站,AI 主动发图/发文件尚未接通。 +- 真实扫码联调依赖具备 ilink bot 权限的微信账号。 ### Telegram @@ -259,12 +303,20 @@ 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 | 新建会话默认模型 | @@ -296,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 命令 @@ -315,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 架构(见目录结构) From 33c8bd801655e6dc855f01bacf013e5fdf985441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Tue, 24 Mar 2026 13:55:30 +0800 Subject: [PATCH 21/32] fix: recommend native mac update builds update detection - surface runtime platform and architecture details from the updates API - select the best matching release asset for manual updates on macOS and Windows - return direct download URLs so users land on the correct installer update UX - warn when an Apple Silicon Mac is running the Intel build through Rosetta - show the recommended asset in the update banner and update dialog - send manual update actions to the matched download instead of the generic release page tests - cover release asset selection for Apple Silicon, Intel macOS, and Windows This addresses reports that some 0.38.5 Mac users become very sluggish after updating, which is likely tied to installing the wrong architecture build. --- src/__tests__/unit/update-release.test.ts | 68 ++++++++++++++++++++ src/app/api/app/updates/route.ts | 13 ++++ src/components/layout/UpdateBanner.tsx | 76 ++++++++++++++++++++--- src/components/layout/UpdateDialog.tsx | 16 ++++- src/hooks/useUpdate.ts | 6 ++ src/i18n/en.ts | 3 + src/i18n/zh.ts | 3 + src/lib/platform.ts | 41 ++++++++++++ src/lib/update-release.ts | 76 +++++++++++++++++++++++ 9 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/unit/update-release.test.ts create mode 100644 src/lib/update-release.ts 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/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/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 ( - <div className="flex items-center justify-center gap-3 border-b border-primary/20 bg-primary/10 px-4 py-1.5 text-sm"> - <span>{t('update.readyToInstall', { version: updateInfo.latestVersion })}</span> - <Button size="sm" variant="outline" className="h-6 text-xs" onClick={quitAndInstall}> - {t('update.restartNow')} - </Button> - </div> + <> + {showRosettaWarning && updateInfo && ( + <div className="flex items-center justify-center gap-3 border-b border-status-warning-border/50 bg-status-warning-muted px-4 py-2 text-sm text-status-warning-foreground"> + <span>{t('update.rosettaWarning')}</span> + {updateInfo.downloadAssetName && ( + <span className="text-xs opacity-80"> + {t('update.recommendedAsset', { asset: updateInfo.downloadAssetName })} + </span> + )} + <Button size="sm" variant="outline" className="h-7 text-xs" onClick={openRecommendedDownload}> + {t('update.getRecommendedBuild')} + </Button> + <Button size="sm" variant="ghost" className="h-7 text-xs" onClick={dismissRosettaWarning}> + {t('update.later')} + </Button> + </div> + )} + + {showReadyBanner && updateInfo && ( + <div className="flex items-center justify-center gap-3 border-b border-primary/20 bg-primary/10 px-4 py-1.5 text-sm"> + <span>{t('update.readyToInstall', { version: updateInfo.latestVersion })}</span> + <Button size="sm" variant="outline" className="h-6 text-xs" onClick={quitAndInstall}> + {t('update.restartNow')} + </Button> + </div> + )} + </> ); } 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} &rarr; Latest: v{updateInfo.latestVersion} </p> + {updateInfo.runningUnderRosetta && ( + <p className="rounded-md border border-status-warning-border bg-status-warning-muted px-2 py-1 text-xs text-status-warning-foreground"> + {t('update.rosettaWarning')} + </p> + )} + + {updateInfo.downloadAssetName && ( + <p className="text-xs text-muted-foreground"> + {t('update.recommendedAsset', { asset: updateInfo.downloadAssetName })} + </p> + )} + {/* Download progress bar */} {isDownloading && ( <div className="space-y-1"> @@ -111,10 +123,10 @@ export function UpdateDialog() { {!isNativeUpdate ? ( <Button onClick={() => { - window.open(updateInfo.releaseUrl, "_blank"); + window.open(updateInfo.downloadUrl || updateInfo.releaseUrl, "_blank"); }} > - {t('settings.viewRelease')} + {updateInfo.downloadAssetName ? t('update.getRecommendedBuild') : t('settings.viewRelease')} </Button> ) : readyToInstall ? ( <Button onClick={quitAndInstall}> 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 89b2cab3..6e47658f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -375,6 +375,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', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index ba62e2c7..6a522163 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -372,6 +372,9 @@ const zh: Record<TranslationKey, string> = { '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': '图片生成', 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/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<RuntimeArchitectureInfo, 'platform' | 'hostArch' | 'processArch'>, +): 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; +} From 8329d2a63409c793918d7487f46f7f3d5b66f810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Tue, 24 Mar 2026 20:22:13 +0800 Subject: [PATCH 22/32] refactor: unified context layer + plan mode + MCP latency optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context Assembler (new): - Extract 150-line inline prompt assembly from route.ts into context-assembler.ts - Conditional layer injection by entry point: desktop gets all 5 layers, bridge gets CLI tools but not widget prompt (IM can't render iframes) - Both route.ts and conversation-engine.ts now call assembleContext() MCP Loader (new): - Replace duplicated loadMcpServers() in route.ts and conversation-engine.ts - Delta mode: only pass servers with ${...} env placeholders to SDK; all others auto-loaded by SDK via settingSources — eliminates redundant config passing and reduces first-response latency - 30-second TTL cache on config file reads Plan Mode (completed): - Fix permissionMode hardcoded to 'acceptEdits' — now reads session.mode from DB so plan mode actually takes effect - Restore mode state on session reload (initialMode prop) - Add ModeIndicator component using Tabs for Code/Plan switching - Uncomment i18n keys for mode labels Bridge upgrade: - conversation-engine.ts now gets CLI tools context via assembleContext() - Add missing SDK options: thinking, effort, generativeUI, fileCheckpointing, context1m UI polish: - Unify action bar component styles: ModeIndicator, ImageGenToggle, ChatPermissionSelector all use consistent rounded-md h-7 sizing - Add icons to mode toggle (Code, NotePencil) and design agent (PaintBrush) Tests: 488 pass, 0 fail Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- src/__tests__/unit/context-assembler.test.ts | 164 +++++++++++++ src/__tests__/unit/mcp-loader.test.ts | 78 ++++++ src/app/api/chat/route.ts | 225 ++--------------- src/app/chat/[id]/page.tsx | 4 +- .../chat/ChatPermissionSelector.tsx | 12 +- src/components/chat/ChatView.tsx | 8 +- src/components/chat/ImageGenToggle.tsx | 4 +- src/components/chat/ModeIndicator.tsx | 30 +++ src/i18n/en.ts | 4 +- src/i18n/zh.ts | 4 +- src/lib/bridge/conversation-engine.ts | 73 ++---- src/lib/context-assembler.ts | 230 ++++++++++++++++++ src/lib/mcp-loader.ts | 136 +++++++++++ 13 files changed, 705 insertions(+), 267 deletions(-) create mode 100644 src/__tests__/unit/context-assembler.test.ts create mode 100644 src/__tests__/unit/mcp-loader.test.ts create mode 100644 src/components/chat/ModeIndicator.tsx create mode 100644 src/lib/context-assembler.ts create mode 100644 src/lib/mcp-loader.ts 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> = {}): 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: '<<SESSION>>' }), + entryPoint: 'desktop', + userPrompt: 'hello', + }); + + assert.ok(result.systemPrompt?.includes('<<SESSION>>')); + }); +}); 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/app/api/chat/route.ts b/src/app/api/chat/route.ts index 2cfa686a..91601dc8 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -4,63 +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<string, MCPServerConfig> | undefined { - try { - const readJson = (p: string): Record<string, unknown> => { - 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<string, MCPServerConfig>), - ...((settings.mcpServers || {}) as Record<string, MCPServerConfig>), - ...((projectMcp.mcpServers || {}) as Record<string, MCPServerConfig>), - }; - // Apply persistent enabled overrides for project-level servers - const settingsOverrides = (settings.mcpServerOverrides || {}) as Record<string, { enabled?: boolean }>; - for (const [name, override] of Object.entries(settingsOverrides)) { - if (merged[name] && override.enabled !== undefined) { - merged[name] = { ...merged[name], enabled: override.enabled }; - } - } - // 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 || ''; - } - } - } - } - // Filter out persistently disabled servers - for (const [name, server] of Object.entries(merged)) { - if (server.enabled === false) { - delete merged[name]; - } - } - 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; @@ -166,9 +120,11 @@ export async function POST(request: NextRequest) { updateSessionProviderId(session_id, persistProviderId); } - // Desktop main chat always uses 'code' mode with acceptEdits permissions. - // Bridge sessions may override mode via conversation-engine independently. - const permissionMode = 'acceptEdits'; + // Resolve permission mode from session's mode setting. + // 'plan' → SDK plan mode (no tool execution, planning only) + // 'code' (default) → acceptEdits (auto-approve file edits) + const sessionMode = session.mode || 'code'; + const permissionMode = sessionMode === 'plan' ? 'plan' : 'acceptEdits'; const systemPromptOverride: string | undefined = undefined; const abortController = new AbortController(); @@ -195,153 +151,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 = `<assistant-project-task type="onboarding"> -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. -</assistant-project-task>`; - } else if (needsDailyCheckIn(state)) { - // Daily check-in: instruct AI to ask 3 quick questions - assistantProjectInstructions = `<assistant-project-task type="daily-checkin"> -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. -</assistant-project-task>`; - } - - } - } - } 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 +161,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:', { diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index a34b93a8..14b4551e 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -22,6 +22,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const [sessionProviderId, setSessionProviderId] = useState<string>(''); 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(); @@ -52,6 +53,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { setSessionModel(data.session.model || ''); setSessionProviderId(data.session.provider_id || ''); 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 @@ -124,7 +126,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { return ( <div className="flex h-full min-h-0 flex-col"> - <ChatView key={id} sessionId={id} initialMessages={messages} initialHasMore={hasMore} modelName={sessionModel} providerId={sessionProviderId} initialPermissionProfile={sessionPermissionProfile} /> + <ChatView key={id} sessionId={id} initialMessages={messages} initialHasMore={hasMore} modelName={sessionModel} providerId={sessionProviderId} initialPermissionProfile={sessionPermissionProfile} initialMode={sessionMode} /> </div> ); } 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({ <DropdownMenu> <DropdownMenuTrigger asChild> <Button - variant="ghost" + variant="outline" size="sm" - className={`gap-1 px-2 py-1 text-xs font-medium ${ + className={cn( + 'rounded-md px-2.5 h-7 text-xs font-medium border transition-all gap-1', isFullAccess - ? 'bg-status-error-muted text-status-error-foreground hover:bg-status-error-muted' - : 'bg-muted text-muted-foreground hover:bg-muted/80' - }`} + ? 'bg-status-error-muted text-status-error-foreground border-status-error-foreground/30' + : 'text-muted-foreground border-border/60 hover:text-foreground hover:border-foreground/30 hover:bg-accent/50' + )} > {isFullAccess ? ( <LockOpen size={14} className="text-status-error-foreground" /> diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 2c2be71b..9363d24c 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -6,6 +6,7 @@ import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } import { MessageList } from './MessageList'; import { MessageInput } from './MessageInput'; import { ChatComposerActionBar } from './ChatComposerActionBar'; +import { ModeIndicator } from './ModeIndicator'; import { ChatPermissionSelector } from './ChatPermissionSelector'; import { ContextUsageIndicator } from './ContextUsageIndicator'; import { ImageGenToggle } from './ImageGenToggle'; @@ -33,9 +34,10 @@ interface ChatViewProps { modelName?: string; providerId?: string; initialPermissionProfile?: 'default' | 'full_access'; + initialMode?: 'code' | 'plan'; } -export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, providerId, initialPermissionProfile }: ChatViewProps) { +export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, providerId, initialPermissionProfile, initialMode }: ChatViewProps) { const { setStreamingSessionId, workingDirectory, setPendingApprovalSessionId } = usePanel(); const { t } = useTranslation(); const router = useRouter(); @@ -47,7 +49,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal const [hasMore, setHasMore] = useState(initialHasMore); const [loadingMore, setLoadingMore] = useState(false); const loadingMoreRef = useRef(false); - const [mode, setMode] = useState('code'); // Desktop chat always uses 'code' + const [mode, setMode] = useState<string>(initialMode || 'code'); const [currentModel, setCurrentModel] = useState(() => modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet'); const [currentProviderId, setCurrentProviderId] = useState(() => providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || ''); const [selectedEffort, setSelectedEffort] = useState<string | undefined>(undefined); @@ -436,7 +438,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal sdkInitMeta={initMetaRef.current} /> <ChatComposerActionBar - left={<ImageGenToggle />} + left={<><ModeIndicator mode={mode} onModeChange={handleModeChange} disabled={isStreaming} /><ImageGenToggle /></>} center={ <ChatPermissionSelector sessionId={sessionId} diff --git a/src/components/chat/ImageGenToggle.tsx b/src/components/chat/ImageGenToggle.tsx index 1ba50bac..ebb8a32a 100644 --- a/src/components/chat/ImageGenToggle.tsx +++ b/src/components/chat/ImageGenToggle.tsx @@ -5,6 +5,7 @@ import { useImageGen } from '@/hooks/useImageGen'; import { useTranslation } from '@/hooks/useTranslation'; import type { TranslationKey } from '@/i18n'; import { Button } from '@/components/ui/button'; +import { PaintBrush } from '@/components/ui/icon'; import { Tooltip, TooltipContent, @@ -27,12 +28,13 @@ export function ImageGenToggle() { size="sm" onClick={handleToggle} className={cn( - 'rounded-full px-2.5 h-7 text-xs font-medium border transition-all', + 'rounded-md px-2.5 h-7 text-xs font-medium border transition-all', state.enabled ? 'bg-primary/15 text-primary border-primary/30' : 'text-muted-foreground border-border/60 hover:text-foreground hover:border-foreground/30 hover:bg-accent/50' )} > + <PaintBrush size={12} /> {t('composer.designAgent' as TranslationKey)} </Button> </TooltipTrigger> 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 ( + <Tabs value={mode} onValueChange={onModeChange}> + <TabsList className="!h-7 p-0.5 text-xs rounded-md"> + <TabsTrigger value="code" disabled={disabled} className="!h-5 rounded-sm px-1.5 py-0 text-xs gap-1"> + <Code size={12} /> + {t('messageInput.modeCode')} + </TabsTrigger> + <TabsTrigger value="plan" disabled={disabled} className="!h-5 rounded-sm px-1.5 py-0 text-xs gap-1"> + <NotePencil size={12} /> + {t('messageInput.modePlan')} + </TabsTrigger> + </TabsList> + </Tabs> + ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 89b2cab3..bc90a6a6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -45,8 +45,8 @@ const en = { 'messageInput.reviewDesc': 'Review code quality', 'messageInput.terminalSetupDesc': 'Configure terminal settings', 'messageInput.memoryDesc': 'Edit project memory file', - // 'messageInput.modeCode': 'Code', // Reserved: mode UI removed in latency remediation - // 'messageInput.modePlan': 'Plan', // Reserved: mode UI removed in latency remediation + 'messageInput.modeCode': 'Code', + 'messageInput.modePlan': 'Plan', 'messageInput.aiSuggested': 'AI Suggested', // ── Streaming message ─────────────────────────────────────── diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index ba62e2c7..9bfc3d80 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -42,8 +42,8 @@ const zh: Record<TranslationKey, string> = { 'messageInput.reviewDesc': '审查代码质量', 'messageInput.terminalSetupDesc': '配置终端设置', 'messageInput.memoryDesc': '编辑项目记忆文件', - // 'messageInput.modeCode': '代码', // Reserved: mode UI removed in latency remediation - // 'messageInput.modePlan': '计划', // Reserved: mode UI removed in latency remediation + 'messageInput.modeCode': '代码', + 'messageInput.modePlan': '计划', 'messageInput.aiSuggested': 'AI 推荐', // ── Streaming message ─────────────────────────────────────── diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts index ab92d24c..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,55 +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<string, MCPServerConfig> | undefined { - try { - const readJson = (p: string): Record<string, unknown> => { - 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<string, MCPServerConfig>), - ...((settings.mcpServers || {}) as Record<string, MCPServerConfig>), - ...((projectMcp.mcpServers || {}) as Record<string, MCPServerConfig>), - }; - // Apply persistent enabled overrides for project-level servers - const settingsOverrides = (settings.mcpServerOverrides || {}) as Record<string, { enabled?: boolean }>; - for (const [name, override] of Object.entries(settingsOverrides)) { - if (merged[name] && override.enabled !== undefined) { - merged[name] = { ...merged[name], enabled: override.enabled }; - } - } - // 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 || ''; - } - } - } - } - // Filter out persistently disabled servers - for (const [name, server] of Object.entries(merged)) { - if (server.enabled === false) { - delete merged[name]; - } - } - return Object.keys(merged).length > 0 ? merged : undefined; - } catch { - return undefined; - } -} - export interface PermissionRequestInfo { permissionRequestId: string; toolName: string; @@ -236,9 +191,17 @@ 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( @@ -262,7 +225,7 @@ export async function processMessage( sessionId, sdkSessionId: effectiveSdkSessionId, model: effectiveModel, - systemPrompt: session?.system_prompt || undefined, + systemPrompt: assembled.systemPrompt, workingDirectory: effectiveCwd, abortController, permissionMode, @@ -272,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 */ } }, 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<AssembledContext> { + 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 `<assistant-project-task type="onboarding"> +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. +</assistant-project-task>`; +} + +function buildCheckinInstructions(): string { + return `<assistant-project-task type="daily-checkin"> +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. +</assistant-project-task>`; +} 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<string, MCPServerConfig>; + codepilotServers: Record<string, MCPServerConfig>; // 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<string, unknown> { + 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<string, MCPServerConfig> = { + ...((userConfig.mcpServers || {}) as Record<string, MCPServerConfig>), + ...((settings.mcpServers || {}) as Record<string, MCPServerConfig>), + ...((projectMcp.mcpServers || {}) as Record<string, MCPServerConfig>), + }; + + // Apply persistent enabled overrides for project-level servers + const settingsOverrides = (settings.mcpServerOverrides || {}) as Record<string, { enabled?: boolean }>; + 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<string, MCPServerConfig> = {}; + + 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<string, MCPServerConfig> | 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<string, MCPServerConfig> | undefined { + try { + const { allServers } = loadAndMerge(); + return Object.keys(allServers).length > 0 ? allServers : undefined; + } catch { + return undefined; + } +} From 0b773cb9ab8b31a4119f58cb439584a84de149e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Tue, 24 Mar 2026 20:40:27 +0800 Subject: [PATCH 23/32] fix: plan mode bypassed by full_access, mode race condition, MCP cache staleness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: Plan mode in full_access sessions — plan mode now takes precedence over full_access permission profile. If user explicitly chose Plan, bypassPermissions is set to false so the SDK respects plan mode's no-tool-execution semantics. P2: Mode race condition — backend now reads mode from the request body (sent by frontend on each message) instead of only checking DB. This eliminates the race where user switches to Plan and immediately sends before the PATCH lands in DB. Request body mode also enables plan mode from the first message in new conversations. P3: MCP cache invalidation — PUT/POST/DELETE handlers in the MCP config API now call invalidateMcpCache() after writing, so subsequent requests pick up the new config immediately instead of serving stale data for up to 30 seconds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- src/app/api/chat/route.ts | 17 +++++++++++------ src/app/api/plugins/mcp/[name]/route.ts | 2 ++ src/app/api/plugins/mcp/route.ts | 3 +++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 91601dc8..b9a110be 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -120,11 +120,16 @@ export async function POST(request: NextRequest) { updateSessionProviderId(session_id, persistProviderId); } - // Resolve permission mode from session's mode setting. - // 'plan' → SDK plan mode (no tool execution, planning only) - // 'code' (default) → acceptEdits (auto-approve file edits) - const sessionMode = session.mode || 'code'; - const permissionMode = sessionMode === 'plan' ? 'plan' : 'acceptEdits'; + // 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'; + 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(); @@ -204,7 +209,7 @@ export async function POST(request: NextRequest) { 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, 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 419c119b..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, @@ -129,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( @@ -175,6 +177,7 @@ export async function POST( mcpServers[name] = server; writeSettings(settings); + invalidateMcpCache(); return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( From 98c4f0f9e6a88bfddfcbc0d4bd2a855824c7ec47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Tue, 24 Mar 2026 21:01:16 +0800 Subject: [PATCH 24/32] docs: add unified context layer exec plan Covers the full architecture discussion: - Problem analysis: 6 scattered connector systems - Moxt/Perplexity product inspiration - Completed phases: context assembler, MCP loader, plan mode, bridge upgrade - Planned phases: floating assistant (hotkey + clipboard + voice), code notifications - Technical debt tracking and future optimization items Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- docs/exec-plans/README.md | 1 + .../active/unified-context-layer.md | 255 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 docs/exec-plans/active/unified-context-layer.md diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md index 9644900d..43305b36 100644 --- a/docs/exec-plans/README.md +++ b/docs/exec-plans/README.md @@ -48,6 +48,7 @@ | 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/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<string> // STT + speak(text: string): Promise<void> // 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 加载器测试 | From 325e1886c6b2fed9019816229df10663c6b2f538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Tue, 24 Mar 2026 21:11:52 +0800 Subject: [PATCH 25/32] fix: enable plan mode toggle on new chat page The mode switcher was only available in existing session views. New chat page had mode hardcoded to 'code' with no setter and no ModeIndicator in the action bar, so users couldn't start a new conversation in Plan mode. - Add setMode to useState so ModeIndicator can toggle it - Render ModeIndicator in new chat action bar (same as ChatView) - Session creation and first message both carry the selected mode Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- src/app/chat/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 3dbf7184..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,7 +44,7 @@ export default function NewChatPage() { const [errorBanner, setErrorBanner] = useState<{ message: string; description?: string } | null>(null); const [recentProjects, setRecentProjects] = useState<string[]>([]); 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. @@ -773,7 +774,7 @@ export default function NewChatPage() { onEffortChange={setSelectedEffort} /> <ChatComposerActionBar - left={<ImageGenToggle />} + left={<><ModeIndicator mode={mode} onModeChange={setMode} disabled={isStreaming} /><ImageGenToggle /></>} center={ <ChatPermissionSelector permissionProfile={permissionProfile} From ae5290fc0a0e0081470eeabcd46bd79e5ca45d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Tue, 24 Mar 2026 22:51:11 +0800 Subject: [PATCH 26/32] feat: three-dot context menus for sessions and projects Session list items: - Replace delete button with three-dot DropdownMenu (outside Link to avoid navigation conflict) - Menu items: Split Screen (disabled on active session), Rename, Copy ID, Delete - Move split screen from left hover icon into the menu - Timestamp hidden when menu is visible, restored on mouse leave Project group headers: - Add three-dot menu alongside the new-chat button - Menu items: Open Folder, Copy Folder Path, Remove Project - Workspace (assistant) folders cannot be removed (option hidden) Sidebar resize: - Reduce max width from 400px to 300px to prevent content overflow i18n: add keys for deleteConversation, copySessionId, renameConversation, removeProject, openFolder, copyFolderPath Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- src/components/layout/AppShell.tsx | 2 +- src/components/layout/ChatListPanel.tsx | 43 +++++++ src/components/layout/ProjectGroupHeader.tsx | 96 +++++++++++---- src/components/layout/SessionListItem.tsx | 116 ++++++++++++------- src/components/ui/icon.tsx | 2 + src/i18n/en.ts | 6 + src/i18n/zh.ts | 6 + 7 files changed, 203 insertions(+), 68 deletions(-) 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<string>(); 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..990731aa 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -272,6 +272,47 @@ 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; + // Delete all sessions in this project + const projectSessions = sessions.filter((s) => s.working_directory === workingDirectory); + for (const session of projectSessions) { + try { + await fetch(`/api/chat/sessions/${session.id}`, { method: "DELETE" }); + if (isInSplit(session.id)) { + removeFromSplit(session.id); + } + } catch { + // Continue with remaining + } + } + setSessions((prev) => prev.filter((s) => s.working_directory !== workingDirectory)); + if (pathname?.startsWith('/chat/')) { + const currentSessionId = pathname.split('/chat/')[1]; + if (projectSessions.some((s) => s.id === currentSessionId)) { + router.push("/chat"); + } + } + }; + const handleCreateSessionInProject = async ( e: React.MouseEvent, workingDirectory: string @@ -453,6 +494,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 +519,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 45c614cf..88fe6674 100644 --- a/src/components/layout/ProjectGroupHeader.tsx +++ b/src/components/layout/ProjectGroupHeader.tsx @@ -6,7 +6,11 @@ import { CaretRight, Plus, FolderOpen, + FolderMinus, UserCircle, + DotsThree, + Copy, + ArrowSquareOut, } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; import { @@ -14,8 +18,17 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { useTranslation } from '@/hooks/useTranslation'; +import type { TranslationKey } from "@/i18n"; +import { useState } from "react"; interface ProjectGroupHeaderProps { workingDirectory: string; @@ -27,6 +40,7 @@ interface ProjectGroupHeaderProps { onMouseEnter: () => void; onMouseLeave: () => void; onCreateSession: (e: React.MouseEvent) => void; + onRemoveProject?: (workingDirectory: string) => void; } export function ProjectGroupHeader({ @@ -39,8 +53,11 @@ export function ProjectGroupHeader({ onMouseEnter, onMouseLeave, onCreateSession, + onRemoveProject, }: ProjectGroupHeaderProps) { const { t } = useTranslation(); + const [menuOpen, setMenuOpen] = useState(false); + const showActions = isFolderHovered || menuOpen; return ( <Tooltip> @@ -70,30 +87,65 @@ export function ProjectGroupHeader({ {isWorkspace && ( <UserCircle size={14} className="shrink-0 text-muted-foreground" /> )} - {/* New chat in project button (on hover) */} + {/* Action buttons (on hover) */} {workingDirectory !== "" && ( - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon-xs" - className={cn( - "h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground transition-opacity", - isFolderHovered ? "opacity-100" : "opacity-0" + <div className={cn( + "flex items-center gap-0.5 transition-opacity", + showActions ? "opacity-100" : "opacity-0" + )}> + {/* New chat button */} + <Button + variant="ghost" + size="icon-xs" + className="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground" + tabIndex={showActions ? 0 : -1} + onClick={onCreateSession} + > + <Plus size={14} /> + </Button> + {/* Three-dot menu */} + <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="icon-xs" + className="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground" + tabIndex={showActions ? 0 : -1} + onClick={(e) => { + e.stopPropagation(); + }} + > + <DotsThree size={14} weight="bold" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="min-w-[160px]" onClick={(e) => e.stopPropagation()}> + <DropdownMenuItem onClick={() => { + window.open(`file://${workingDirectory}`, '_blank'); + }}> + <ArrowSquareOut size={14} /> + <span>{t('chatList.openFolder' as TranslationKey)}</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={() => { + navigator.clipboard.writeText(workingDirectory); + }}> + <Copy size={14} /> + <span>{t('chatList.copyFolderPath' as TranslationKey)}</span> + </DropdownMenuItem> + {onRemoveProject && !isWorkspace && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + variant="destructive" + onClick={() => onRemoveProject(workingDirectory)} + > + <FolderMinus size={14} /> + <span>{t('chatList.removeProject' as TranslationKey)}</span> + </DropdownMenuItem> + </> )} - tabIndex={isFolderHovered ? 0 : -1} - onClick={onCreateSession} - > - <Plus size={14} /> - <span className="sr-only"> - {t('chatList.newConversation')} - {displayName} - </span> - </Button> - </TooltipTrigger> - <TooltipContent side="right"> - {t('chatList.newConversation')} - {displayName} - </TooltipContent> - </Tooltip> + </DropdownMenuContent> + </DropdownMenu> + </div> )} </div> </TooltipTrigger> diff --git a/src/components/layout/SessionListItem.tsx b/src/components/layout/SessionListItem.tsx index 053d654c..7ecc9131 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 ( <div className="group relative" @@ -52,48 +68,24 @@ export function SessionListItem({ <Link href={`/chat/${session.id}`} className={cn( - "flex items-center gap-1.5 rounded-md pl-2 pr-2 py-1.5 transition-all duration-150 min-w-0", + "flex items-center gap-1.5 rounded-md pl-2 pr-8 py-1.5 transition-all duration-150 min-w-0", isActive ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground hover:bg-accent/50" )} > - {/* Left icon area — always same size, swap content via opacity */} + {/* Left icon area — streaming/approval indicators */} <span className="relative flex h-3.5 w-3.5 shrink-0 items-center justify-center"> - {/* Split icon: visible on hover when splittable */} - {canSplit && ( - <Button - variant="ghost" - size="icon" - className={cn( - "absolute inset-0 flex items-center justify-center text-muted-foreground hover:text-foreground transition-opacity h-auto w-auto p-0", - isHovered ? "opacity-100" : "opacity-0 pointer-events-none" - )} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onAddToSplit(session); - }} - > - <Columns className="h-3.5 w-3.5" /> - </Button> - )} - {/* Streaming indicator: hidden when hover shows split icon */} + {/* Streaming indicator */} {isSessionStreaming && ( - <span className={cn( - "relative flex h-2 w-2 transition-opacity", - isHovered && canSplit ? "opacity-0" : "opacity-100" - )}> + <span className="relative flex h-2 w-2"> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-success opacity-75" /> <span className="relative inline-flex h-2 w-2 rounded-full bg-status-success" /> </span> )} - {/* Approval indicator: hidden when hover shows split icon */} + {/* Approval indicator */} {needsApproval && !isSessionStreaming && ( - <span className={cn( - "flex h-3.5 w-3.5 items-center justify-center rounded-full bg-status-warning-muted transition-opacity", - isHovered && canSplit ? "opacity-0" : "opacity-100" - )}> + <span className="flex h-3.5 w-3.5 items-center justify-center rounded-full bg-status-warning-muted"> <Bell size={10} className="text-status-warning-foreground" /> </span> )} @@ -103,28 +95,62 @@ export function SessionListItem({ {session.title} </span> </div> - {/* Right area — fixed width, time and delete stacked with opacity */} - <div className="relative w-[38px] h-4 shrink-0"> - <span className={cn( - "absolute inset-0 flex items-center justify-end text-[11px] text-muted-foreground/40 truncate transition-opacity", - (isHovered || isDeleting) ? "opacity-0" : "opacity-100" - )}> - {formatRelativeTime(session.updated_at, t)} - </span> + {/* Timestamp (hidden when menu is showing) */} + <span className={cn( + "absolute right-2 text-[11px] text-muted-foreground/40 truncate transition-opacity", + showActions ? "opacity-0 pointer-events-none" : "opacity-100" + )}> + {formatRelativeTime(session.updated_at, t)} + </span> + </Link> + {/* Three-dot menu — positioned outside Link with higher z-index */} + <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> + <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className={cn( - "absolute inset-0 flex items-center justify-end text-muted-foreground/60 hover:text-destructive transition-opacity h-auto w-auto p-0", - (isHovered || isDeleting) ? "opacity-100" : "opacity-0 pointer-events-none" + "absolute right-2 top-1/2 -translate-y-1/2 z-10 flex items-center justify-center text-muted-foreground/60 hover:text-foreground transition-opacity h-5 w-5 p-0", + showActions ? "opacity-100" : "opacity-0 pointer-events-none" )} - onClick={(e) => onDelete(e, session.id)} - disabled={isDeleting} + onPointerDown={(e) => e.stopPropagation()} > - <Trash size={14} /> + <DotsThree size={16} weight="bold" /> </Button> - </div> - </Link> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="min-w-[160px]"> + <DropdownMenuItem + disabled={isActive} + onClick={() => onAddToSplit(session)} + > + <Columns size={14} /> + <span>{t('chatList.splitScreen' as TranslationKey)}</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={() => { + const newTitle = prompt("Rename conversation:", session.title); + if (newTitle && newTitle !== session.title) { + onRename(session.id, newTitle); + } + }}> + <PencilSimple size={14} /> + <span>{t('chatList.renameConversation' as TranslationKey)}</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={() => { + navigator.clipboard.writeText(session.id); + }}> + <Copy size={14} /> + <span>{t('chatList.copySessionId' as TranslationKey)}</span> + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + variant="destructive" + onClick={(e) => onDelete(e as unknown as React.MouseEvent, session.id)} + > + <Trash size={14} /> + <span>{t('chatList.deleteConversation' as TranslationKey)}</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> ); } 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/i18n/en.ts b/src/i18n/en.ts index bc90a6a6..11b5d2c0 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', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 9bfc3d80..7e132b47 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -19,6 +19,12 @@ const zh: Record<TranslationKey, string> = { '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 导入', From be161c9bd8db991458e5cd321a564c4b21e84e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Tue, 24 Mar 2026 23:42:09 +0800 Subject: [PATCH 27/32] fix: sidebar context menu bugs from Codex review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 - Remove project error handling: handleRemoveProject now checks res.ok per session and only removes successfully deleted sessions from local state, preventing ghost entries that reappear on refresh. P2 - Hidden buttons still clickable: Add pointer-events-none alongside opacity-0 on project header action container, matching the session item pattern. P2 - Open Folder cross-platform: Replace window.open(file://...) with window.electronAPI.shell.openPath() (Electron) with fallback to /api/files/open (browser dev). Matches existing pattern in UnifiedTopBar.tsx. Also: - Remove Tooltip wrapper from project headers (path tooltip was redundant) - Increase sidebar min width from 180→200px to prevent time/dots clipping Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- src/components/layout/AppShell.tsx | 2 +- src/components/layout/ChatListPanel.tsx | 24 ++- src/components/layout/ProjectGroupHeader.tsx | 173 +++++++++---------- 3 files changed, 101 insertions(+), 98 deletions(-) diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index df76a2d7..503881e3 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -52,7 +52,7 @@ function loadActiveColumn(): string { } const EMPTY_SET = new Set<string>(); -const CHATLIST_MIN = 180; +const CHATLIST_MIN = 200; const CHATLIST_MAX = 300; /** Extensions that default to "rendered" view mode */ diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 990731aa..6531e3e6 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -292,23 +292,29 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { const handleRemoveProject = async (workingDirectory: string) => { if (!confirm(`Remove project "${workingDirectory.split('/').pop()}" and all its conversations?`)) return; - // Delete all sessions in this project const projectSessions = sessions.filter((s) => s.working_directory === workingDirectory); + const deletedIds = new Set<string>(); for (const session of projectSessions) { try { - await fetch(`/api/chat/sessions/${session.id}`, { method: "DELETE" }); - if (isInSplit(session.id)) { - removeFromSplit(session.id); + 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 } } - setSessions((prev) => prev.filter((s) => s.working_directory !== workingDirectory)); - if (pathname?.startsWith('/chat/')) { - const currentSessionId = pathname.split('/chat/')[1]; - if (projectSessions.some((s) => s.id === currentSessionId)) { - router.push("/chat"); + // 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"); + } } } }; diff --git a/src/components/layout/ProjectGroupHeader.tsx b/src/components/layout/ProjectGroupHeader.tsx index 88fe6674..ac127815 100644 --- a/src/components/layout/ProjectGroupHeader.tsx +++ b/src/components/layout/ProjectGroupHeader.tsx @@ -13,11 +13,6 @@ import { ArrowSquareOut, } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { DropdownMenu, DropdownMenuTrigger, @@ -60,98 +55,100 @@ export function ProjectGroupHeader({ const showActions = isFolderHovered || menuOpen; return ( - <Tooltip> - <TooltipTrigger asChild> - <div - className={cn( - "flex items-center gap-1 rounded-md px-2 py-1 cursor-pointer select-none transition-colors", - "hover:bg-accent/50" - )} - onClick={onToggle} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - > - {isCollapsed ? ( - <CaretRight size={14} className="shrink-0 text-muted-foreground" /> - ) : ( - <CaretDown size={14} className="shrink-0 text-muted-foreground" /> - )} - {isCollapsed ? ( - <Folder size={16} className="shrink-0 text-muted-foreground" /> - ) : ( - <FolderOpen size={16} className="shrink-0 text-muted-foreground" /> - )} - <span className="flex-1 truncate text-[13px] font-medium text-sidebar-foreground"> - {displayName} - </span> - {isWorkspace && ( - <UserCircle size={14} className="shrink-0 text-muted-foreground" /> - )} - {/* Action buttons (on hover) */} - {workingDirectory !== "" && ( - <div className={cn( - "flex items-center gap-0.5 transition-opacity", - showActions ? "opacity-100" : "opacity-0" - )}> - {/* New chat button */} + <div + className={cn( + "flex items-center gap-1 rounded-md px-2 py-1 cursor-pointer select-none transition-colors", + "hover:bg-accent/50" + )} + onClick={onToggle} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + > + {isCollapsed ? ( + <CaretRight size={14} className="shrink-0 text-muted-foreground" /> + ) : ( + <CaretDown size={14} className="shrink-0 text-muted-foreground" /> + )} + {isCollapsed ? ( + <Folder size={16} className="shrink-0 text-muted-foreground" /> + ) : ( + <FolderOpen size={16} className="shrink-0 text-muted-foreground" /> + )} + <span className="flex-1 truncate text-[13px] font-medium text-sidebar-foreground"> + {displayName} + </span> + {isWorkspace && ( + <UserCircle size={14} className="shrink-0 text-muted-foreground" /> + )} + {/* Action buttons (on hover) */} + {workingDirectory !== "" && ( + <div className={cn( + "flex items-center gap-0.5 transition-opacity", + showActions ? "opacity-100" : "opacity-0 pointer-events-none" + )}> + {/* New chat button */} + <Button + variant="ghost" + size="icon-xs" + className="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground" + tabIndex={showActions ? 0 : -1} + onClick={onCreateSession} + > + <Plus size={14} /> + </Button> + {/* Three-dot menu */} + <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> + <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon-xs" className="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground" tabIndex={showActions ? 0 : -1} - onClick={onCreateSession} + onClick={(e) => { + e.stopPropagation(); + }} > - <Plus size={14} /> + <DotsThree size={14} weight="bold" /> </Button> - {/* Three-dot menu */} - <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> - <DropdownMenuTrigger asChild> - <Button - variant="ghost" - size="icon-xs" - className="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground" - tabIndex={showActions ? 0 : -1} - onClick={(e) => { - e.stopPropagation(); - }} + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="min-w-[160px]" onClick={(e) => e.stopPropagation()}> + <DropdownMenuItem onClick={() => { + 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(() => {}); + } + }}> + <ArrowSquareOut size={14} /> + <span>{t('chatList.openFolder' as TranslationKey)}</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={() => { + navigator.clipboard.writeText(workingDirectory); + }}> + <Copy size={14} /> + <span>{t('chatList.copyFolderPath' as TranslationKey)}</span> + </DropdownMenuItem> + {onRemoveProject && !isWorkspace && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + variant="destructive" + onClick={() => onRemoveProject(workingDirectory)} > - <DotsThree size={14} weight="bold" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="min-w-[160px]" onClick={(e) => e.stopPropagation()}> - <DropdownMenuItem onClick={() => { - window.open(`file://${workingDirectory}`, '_blank'); - }}> - <ArrowSquareOut size={14} /> - <span>{t('chatList.openFolder' as TranslationKey)}</span> - </DropdownMenuItem> - <DropdownMenuItem onClick={() => { - navigator.clipboard.writeText(workingDirectory); - }}> - <Copy size={14} /> - <span>{t('chatList.copyFolderPath' as TranslationKey)}</span> + <FolderMinus size={14} /> + <span>{t('chatList.removeProject' as TranslationKey)}</span> </DropdownMenuItem> - {onRemoveProject && !isWorkspace && ( - <> - <DropdownMenuSeparator /> - <DropdownMenuItem - variant="destructive" - onClick={() => onRemoveProject(workingDirectory)} - > - <FolderMinus size={14} /> - <span>{t('chatList.removeProject' as TranslationKey)}</span> - </DropdownMenuItem> - </> - )} - </DropdownMenuContent> - </DropdownMenu> - </div> - )} + </> + )} + </DropdownMenuContent> + </DropdownMenu> </div> - </TooltipTrigger> - <TooltipContent side="right" className="max-w-xs"> - <p className="text-xs break-all">{workingDirectory || t('chatList.noSessions')}</p> - </TooltipContent> - </Tooltip> + )} + </div> ); } From 69cac7133e2de02edf1c3b1b81b781b3ca421168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Tue, 24 Mar 2026 23:55:11 +0800 Subject: [PATCH 28/32] fix: session list item layout uses flex instead of absolute positioning The timestamp and three-dot menu were using absolute positioning, which caused them to be clipped when the sidebar was narrower than ~268px. Now the right area uses a flex-based fixed-width slot (shrink-0 w-[38px]) inside the Link, so the title truncates naturally while the timestamp/dots always stay visible regardless of sidebar width. Restored CHATLIST_MIN to 180px since the layout now handles narrow widths correctly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- src/components/layout/AppShell.tsx | 2 +- src/components/layout/SessionListItem.tsx | 27 +++++++++++------------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 503881e3..df76a2d7 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -52,7 +52,7 @@ function loadActiveColumn(): string { } const EMPTY_SET = new Set<string>(); -const CHATLIST_MIN = 200; +const CHATLIST_MIN = 180; const CHATLIST_MAX = 300; /** Extensions that default to "rendered" view mode */ diff --git a/src/components/layout/SessionListItem.tsx b/src/components/layout/SessionListItem.tsx index 7ecc9131..7c74b320 100644 --- a/src/components/layout/SessionListItem.tsx +++ b/src/components/layout/SessionListItem.tsx @@ -68,7 +68,7 @@ export function SessionListItem({ <Link href={`/chat/${session.id}`} className={cn( - "flex items-center gap-1.5 rounded-md pl-2 pr-8 py-1.5 transition-all duration-150 min-w-0", + "flex items-center gap-1.5 rounded-md pl-2 pr-2 py-1.5 transition-all duration-150 min-w-0", isActive ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground hover:bg-accent/50" @@ -76,34 +76,33 @@ export function SessionListItem({ > {/* Left icon area — streaming/approval indicators */} <span className="relative flex h-3.5 w-3.5 shrink-0 items-center justify-center"> - {/* Streaming indicator */} {isSessionStreaming && ( <span className="relative flex h-2 w-2"> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-success opacity-75" /> <span className="relative inline-flex h-2 w-2 rounded-full bg-status-success" /> </span> )} - {/* Approval indicator */} {needsApproval && !isSessionStreaming && ( <span className="flex h-3.5 w-3.5 items-center justify-center rounded-full bg-status-warning-muted"> <Bell size={10} className="text-status-warning-foreground" /> </span> )} </span> - <div className="flex-1 min-w-0"> - <span className="line-clamp-1 text-[13px] font-medium leading-tight break-all"> - {session.title} + {/* Title — flex-1 + truncate ensures it shrinks */} + <span className="flex-1 min-w-0 line-clamp-1 text-[13px] font-medium leading-tight break-all"> + {session.title} + </span> + {/* Right area — fixed width, time or dots swap via opacity */} + <span className="shrink-0 w-[38px] flex items-center justify-end"> + <span className={cn( + "text-[11px] text-muted-foreground/40 truncate transition-opacity", + showActions ? "opacity-0" : "opacity-100" + )}> + {formatRelativeTime(session.updated_at, t)} </span> - </div> - {/* Timestamp (hidden when menu is showing) */} - <span className={cn( - "absolute right-2 text-[11px] text-muted-foreground/40 truncate transition-opacity", - showActions ? "opacity-0 pointer-events-none" : "opacity-100" - )}> - {formatRelativeTime(session.updated_at, t)} </span> </Link> - {/* Three-dot menu — positioned outside Link with higher z-index */} + {/* Three-dot menu — absolute over the right area */} <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> <DropdownMenuTrigger asChild> <Button From 564ecfde5918f0b0492540730fd92f5831b4d532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Wed, 25 Mar 2026 08:47:34 +0800 Subject: [PATCH 29/32] fix: sidebar session list no longer clips at narrow widths Root cause: Radix ScrollArea's internal viewport div uses display:table + min-width:100%, which expands to content's natural width rather than being constrained by the viewport. This caused session items to overflow and get clipped by the aside's overflow:hidden. Fix: Override the internal div to display:block via [&>[data-slot=scroll-area-viewport]>div]:!block on the ScrollArea, preventing horizontal content expansion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- src/components/layout/ChatListPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 6531e3e6..08ca1c58 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -452,7 +452,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { </div> {/* Session list grouped by project */} - <ScrollArea className="flex-1 min-h-0 px-3"> + <ScrollArea className="flex-1 min-h-0 px-3 [&>[data-slot=scroll-area-viewport]>div]:!block"> <div className="flex flex-col pb-3"> {/* Section title */} <div className="px-2 pt-1 pb-1.5"> From f8e187ac0df471469a523fc2702f6e80665ce162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Wed, 25 Mar 2026 08:50:59 +0800 Subject: [PATCH 30/32] fix: disable split screen menu item for sessions already in split The menu item only checked isActive but not canSplit, so sessions already in split view showed a clickable but no-op "Split Screen" option. Now disabled when isActive OR !canSplit (which includes isInSplit check from ChatListPanel). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- src/components/layout/SessionListItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/SessionListItem.tsx b/src/components/layout/SessionListItem.tsx index 7c74b320..07a943ff 100644 --- a/src/components/layout/SessionListItem.tsx +++ b/src/components/layout/SessionListItem.tsx @@ -119,7 +119,7 @@ export function SessionListItem({ </DropdownMenuTrigger> <DropdownMenuContent align="end" className="min-w-[160px]"> <DropdownMenuItem - disabled={isActive} + disabled={isActive || !canSplit} onClick={() => onAddToSplit(session)} > <Columns size={14} /> From f0930b34cd5ff932315cc457fb1298ab5a6e8c88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Wed, 25 Mar 2026 09:03:32 +0800 Subject: [PATCH 31/32] docs: add bridge plan-mode vs full_access tech debt item Codex identified that bridge's /mode plan can still be overridden by bypassPermissions when session has full_access profile. Desktop chat was fixed in 0b773cb but bridge conversation-engine needs the same plan-takes-precedence logic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- docs/exec-plans/tech-debt-tracker.md | 1 + 1 file changed, 1 insertion(+) 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 | ## 已解决 From 8be94c62f099aeb8fac23f327758fd05ca86974d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?= <guohao@guohaodeMacBook-Pro.local> Date: Wed, 25 Mar 2026 09:07:19 +0800 Subject: [PATCH 32/32] chore: prepare v0.39.0 release Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ea9dcc22..ed584417 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.38.5", + "version": "0.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.38.5", + "version": "0.39.0", "license": "BUSL-1.1", "workspaces": [ "apps/*", diff --git a/package.json b/package.json index 28866340..689be345 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.38.5", + "version": "0.39.0", "private": true, "license": "BUSL-1.1", "workspaces": [