diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index 69154b9..e70e81e 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -9,6 +9,10 @@ function createAgentRuntime(deps) { getDefaultCodexModel, loadCodexConfig, prepareCodexCustomRuntime, + ccwebMcpServerPath, + internalMcpUrl, + internalMcpToken, + nodePath, wsSend, truncateObj, sanitizeToolInput, @@ -18,6 +22,39 @@ function createAgentRuntime(deps) { getRuntimeSessionId, } = deps; + function tomlString(value) { + return JSON.stringify(String(value || '')); + } + + function tomlStringArray(values) { + return `[${values.map((value) => tomlString(value)).join(',')}]`; + } + + function createCcwebMcpEnv(session, options = {}) { + if (!ccwebMcpServerPath || !internalMcpUrl || !internalMcpToken || !session?.id) return null; + const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10); + const hopCount = Number.isFinite(rawHopCount) ? Math.max(0, rawHopCount) : 0; + return { + CC_WEB_MCP_URL: internalMcpUrl, + CC_WEB_MCP_TOKEN: internalMcpToken, + CC_WEB_SOURCE_SESSION_ID: session.id, + CC_WEB_CROSS_HOP_COUNT: String(hopCount), + }; + } + + function appendCcwebMcpConfig(args, mcpEnv) { + if (!mcpEnv) return; + const envVars = Object.keys(mcpEnv); + args.push( + '-c', 'mcp_servers.ccweb.type="stdio"', + '-c', `mcp_servers.ccweb.command=${tomlString(nodePath || 'node')}`, + '-c', `mcp_servers.ccweb.args=${tomlStringArray([ccwebMcpServerPath])}`, + '-c', `mcp_servers.ccweb.env_vars=${tomlStringArray(envVars)}`, + '-c', 'mcp_servers.ccweb.startup_timeout_sec=10', + '-c', 'mcp_servers.ccweb.tool_timeout_sec=60' + ); + } + function buildClaudeSpawnSpec(session, options = {}) { const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0; const args = ['-p', '--output-format', 'stream-json', '--verbose']; @@ -75,6 +112,8 @@ function createAgentRuntime(deps) { const runtimeId = getRuntimeSessionId(session); const args = ['exec']; args.push('--json', '--skip-git-repo-check'); + const ccwebMcpEnv = createCcwebMcpEnv(session, options); + appendCcwebMcpConfig(args, ccwebMcpEnv); const permMode = session.permissionMode || 'yolo'; // `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`. @@ -126,7 +165,7 @@ function createAgentRuntime(deps) { args.push('-'); } - const env = { ...processEnv }; + const env = { ...processEnv, ...(ccwebMcpEnv || {}) }; delete env.CC_WEB_PASSWORD; delete env.CLAUDECODE; delete env.CLAUDE_CODE; diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js new file mode 100644 index 0000000..8946805 --- /dev/null +++ b/lib/ccweb-mcp-server.js @@ -0,0 +1,251 @@ +#!/usr/bin/env node +'use strict'; + +const http = require('http'); +const https = require('https'); + +const SERVER_INFO = { + name: 'ccweb', + version: '1.0.0', +}; + +const TOOLS = [ + { + name: 'ccweb_list_conversations', + description: '列出当前 ccweb 中可投递消息的对话。只返回 ID、标题、Agent、运行状态和更新时间,不返回对话正文。', + inputSchema: { + type: 'object', + properties: { + agent: { + type: 'string', + enum: ['claude', 'codex'], + description: '可选。只返回指定 Agent 的对话。', + }, + status: { + type: 'string', + enum: ['all', 'running', 'idle'], + description: '可选。按运行状态过滤,默认 all。', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + description: '可选。最多返回多少条,默认 50。', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'ccweb_send_message', + description: '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。', + inputSchema: { + type: 'object', + properties: { + targetConversationId: { + type: 'string', + description: '目标对话 ID。', + }, + content: { + type: 'string', + description: '要发送到目标对话的纯文本消息。', + }, + }, + required: ['targetConversationId', 'content'], + additionalProperties: false, + }, + }, +]; + +function writeMessage(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function jsonRpcResult(id, result) { + writeMessage({ jsonrpc: '2.0', id, result }); +} + +function jsonRpcError(id, code, message, data) { + const error = { code, message }; + if (data !== undefined) error.data = data; + writeMessage({ jsonrpc: '2.0', id, error }); +} + +function parseInteger(value, fallback = 0) { + const parsed = Number.parseInt(String(value || ''), 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function callCcweb(tool, args) { + return new Promise((resolve) => { + const urlText = String(process.env.CC_WEB_MCP_URL || '').trim(); + const token = String(process.env.CC_WEB_MCP_TOKEN || '').trim(); + if (!urlText || !token) { + resolve({ + ok: false, + code: 'mcp_not_configured', + message: 'ccweb MCP 环境变量未配置完整。', + }); + return; + } + + let url; + try { + url = new URL(urlText); + } catch { + resolve({ + ok: false, + code: 'mcp_bad_url', + message: 'ccweb MCP 内部地址无效。', + }); + return; + } + + const body = JSON.stringify({ + tool, + args: args && typeof args === 'object' ? args : {}, + sourceSessionId: process.env.CC_WEB_SOURCE_SESSION_ID || '', + sourceHopCount: parseInteger(process.env.CC_WEB_CROSS_HOP_COUNT, 0), + }); + + const transport = url.protocol === 'https:' ? https : http; + const req = transport.request(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'X-CC-Web-MCP-Token': token, + }, + timeout: 60000, + }, (res) => { + let data = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + const payload = JSON.parse(data || '{}'); + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(payload); + } else { + resolve({ + ok: false, + code: payload.code || 'ccweb_http_error', + message: payload.message || `ccweb 内部接口返回 HTTP ${res.statusCode}`, + statusCode: res.statusCode, + }); + } + } catch { + resolve({ + ok: false, + code: 'ccweb_bad_response', + message: 'ccweb 内部接口返回了无法解析的响应。', + statusCode: res.statusCode, + }); + } + }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ + ok: false, + code: 'ccweb_timeout', + message: 'ccweb 内部接口调用超时。', + }); + }); + req.on('error', (err) => { + resolve({ + ok: false, + code: 'ccweb_request_failed', + message: err.message || 'ccweb 内部接口调用失败。', + }); + }); + req.write(body); + req.end(); + }); +} + +function toolResponse(payload) { + const text = JSON.stringify(payload, null, 2); + return { + content: [{ type: 'text', text }], + structuredContent: payload, + isError: !payload?.ok, + }; +} + +async function handleRequest(message) { + const { id, method } = message; + const hasId = Object.prototype.hasOwnProperty.call(message, 'id'); + + if (!hasId) return; + + try { + switch (method) { + case 'initialize': { + jsonRpcResult(id, { + protocolVersion: message.params?.protocolVersion || '2024-11-05', + capabilities: { tools: {} }, + serverInfo: SERVER_INFO, + }); + break; + } + case 'ping': + jsonRpcResult(id, {}); + break; + case 'tools/list': + jsonRpcResult(id, { tools: TOOLS }); + break; + case 'tools/call': { + const name = String(message.params?.name || ''); + const args = message.params?.arguments || {}; + if (!TOOLS.some((tool) => tool.name === name)) { + jsonRpcResult(id, toolResponse({ + ok: false, + code: 'unknown_tool', + message: `未知工具: ${name}`, + })); + break; + } + const payload = await callCcweb(name, args); + jsonRpcResult(id, toolResponse(payload)); + break; + } + case 'resources/list': + jsonRpcResult(id, { resources: [] }); + break; + case 'prompts/list': + jsonRpcResult(id, { prompts: [] }); + break; + default: + jsonRpcError(id, -32601, `Method not found: ${method}`); + } + } catch (err) { + jsonRpcError(id, -32603, err.message || 'Internal error'); + } +} + +let lineBuffer = ''; + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + lineBuffer += chunk; + let index; + while ((index = lineBuffer.indexOf('\n')) >= 0) { + const line = lineBuffer.slice(0, index).trim(); + lineBuffer = lineBuffer.slice(index + 1); + if (!line) continue; + let message; + try { + message = JSON.parse(line); + } catch (err) { + jsonRpcError(null, -32700, 'Parse error', err.message); + continue; + } + handleRequest(message); + } +}); + +process.stdin.on('end', () => { + process.exit(0); +}); diff --git a/public/app.js b/public/app.js index f57347d..36f47fc 100644 --- a/public/app.js +++ b/public/app.js @@ -81,6 +81,7 @@ let isGenerating = false; let reconnectAttempts = 0; let reconnectTimer = null; + let isPageUnloading = false; let pendingText = ''; let renderTimer = null; let generatingSessionId = null; @@ -135,6 +136,7 @@ const importSessionBtn = $('#import-session-btn'); const sessionList = $('#session-list'); const chatTitle = $('#chat-title'); + const chatSessionIdBtn = $('#chat-session-id-btn'); const chatAgentBtn = $('#chat-agent-btn'); const chatAgentMenu = $('#chat-agent-menu'); const chatRuntimeState = $('#chat-runtime-state'); @@ -648,6 +650,51 @@ return sessions.find((s) => s.id === sessionId) || null; } + function shortSessionId(sessionId) { + const value = String(sessionId || ''); + return value ? value.slice(0, 8) : ''; + } + + function updateSessionIdBadge() { + if (!chatSessionIdBtn) return; + if (!currentSessionId) { + chatSessionIdBtn.hidden = true; + chatSessionIdBtn.textContent = 'ID'; + chatSessionIdBtn.title = '复制当前会话 ID'; + return; + } + chatSessionIdBtn.hidden = false; + chatSessionIdBtn.textContent = `ID ${shortSessionId(currentSessionId)}`; + chatSessionIdBtn.title = `复制当前会话 ID\n${currentSessionId}`; + chatSessionIdBtn.setAttribute('aria-label', `复制当前会话 ID ${currentSessionId}`); + } + + async function copyTextToClipboard(text, successText = '已复制') { + const value = String(text || ''); + if (!value) return false; + try { + if (navigator.clipboard?.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(value); + } else { + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + textarea.remove(); + } + showToast(successText); + return true; + } catch { + showToast('复制失败'); + return false; + } + } + function deepClone(value) { if (value === null || value === undefined) return value; return JSON.parse(JSON.stringify(value)); @@ -1797,6 +1844,7 @@ ${session.hasUnread ? '' : ''} ${timeAgo(session.updated)}