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)}
+
@@ -1804,6 +1852,11 @@ item.addEventListener('click', (e) => { const target = e.target; + if (target.classList.contains('copy-id')) { + e.stopPropagation(); + copyTextToClipboard(session.id, '会话 ID 已复制'); + return; + } if (target.classList.contains('delete')) { e.stopPropagation(); const doDelete = () => { @@ -1920,6 +1973,7 @@ sendBtn.hidden = false; abortBtn.hidden = true; chatTitle.textContent = '新会话'; + updateSessionIdBadge(); updateCwdBadge(); messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent); setStatsDisplay(null); @@ -1949,6 +2003,7 @@ loadedHistorySessionId = snapshot.sessionId; setLastSessionForAgent(snapshot.agent, currentSessionId); chatTitle.textContent = snapshot.title || '新会话'; + updateSessionIdBadge(); setCurrentAgent(snapshotAgent); migratePendingNotesToSession(snapshot.sessionId, snapshotAgent); setCurrentSessionRunningState(snapshot.isRunning); @@ -2221,26 +2276,49 @@ }; // --- WebSocket --- - function connect() { - if (ws && ws.readyState <= 1) return; - ws = new WebSocket(WS_URL); + function isBrowserOnline() { + return !('onLine' in navigator) || navigator.onLine; + } - ws.onopen = () => { + function canConnectWs() { + return !isPageUnloading && isBrowserOnline(); + } + + function clearReconnectTimer() { + if (!reconnectTimer) return; + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + function connect() { + if (!canConnectWs()) return; + if (ws && ws.readyState <= 1) return; + clearReconnectTimer(); + + const socket = new WebSocket(WS_URL); + ws = socket; + + socket.onopen = () => { + if (ws !== socket) return; reconnectAttempts = 0; + clearReconnectTimer(); if (authToken) send({ type: 'auth', token: authToken }); }; - ws.onmessage = (e) => { + socket.onmessage = (e) => { + if (ws !== socket) return; let msg; try { msg = JSON.parse(e.data); } catch { return; } handleServerMessage(msg); }; - ws.onclose = () => { + socket.onclose = () => { + if (ws !== socket) return; + ws = null; clearSessionLoading(); scheduleReconnect(); }; - ws.onerror = () => {}; + socket.onerror = () => {}; } function send(data) { @@ -2248,6 +2326,7 @@ } function scheduleReconnect() { + if (!canConnectWs()) return; if (reconnectTimer) return; const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); reconnectAttempts++; @@ -2367,6 +2446,22 @@ } break; + case 'session_message': + if (msg.sessionId && msg.message) { + updateCachedSession(msg.sessionId, (snapshot) => { + snapshot.messages = Array.isArray(snapshot.messages) ? snapshot.messages : []; + snapshot.messages.push(deepClone(msg.message)); + snapshot.updated = msg.message.timestamp || new Date().toISOString(); + }); + } + if (msg.sessionId === currentSessionId && msg.message) { + const welcome = messagesDiv.querySelector('.welcome-msg'); + if (welcome) welcome.remove(); + messagesDiv.appendChild(buildMsgElement(msg.message)); + scrollToBottom(); + } + break; + case 'session_renamed': sessions = sessions.map((session) => session.id === msg.sessionId ? { ...session, title: msg.title } : session); updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; }); @@ -2755,9 +2850,10 @@ catch { return escapeHtml(text); } } - function createMsgElement(role, content, attachments = []) { + function createMsgElement(role, content, attachments = [], meta = {}) { const div = document.createElement('div'); - div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}`; + const isCrossConversation = role === 'user' && !!meta.crossConversation; + div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}`; if (role === 'system') { const bubble = document.createElement('div'); @@ -2769,7 +2865,9 @@ const avatar = document.createElement('div'); avatar.className = 'msg-avatar'; - if (role === 'user') { + if (isCrossConversation) { + avatar.textContent = '↗'; + } else if (role === 'user') { avatar.textContent = 'U'; } else if (currentAgent === 'codex') { avatar.innerHTML = `Codex`; @@ -2781,6 +2879,33 @@ bubble.className = 'msg-bubble'; if (role === 'user') { + if (isCrossConversation) { + const source = meta.crossConversation || {}; + const sourceTitle = source.sourceTitle || '未命名对话'; + const sourceId = source.sourceSessionId || ''; + const sourceMeta = document.createElement('div'); + sourceMeta.className = 'cross-conversation-meta'; + + const label = document.createElement('span'); + label.className = 'cross-conversation-label'; + label.textContent = `来自「${sourceTitle}」的对话`; + sourceMeta.appendChild(label); + + if (sourceId) { + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'cross-conversation-id-btn'; + copyBtn.textContent = `ID ${shortSessionId(sourceId)}`; + copyBtn.title = `复制来源会话 ID\n${sourceId}`; + copyBtn.addEventListener('click', (event) => { + event.stopPropagation(); + copyTextToClipboard(sourceId, '来源会话 ID 已复制'); + }); + sourceMeta.appendChild(copyBtn); + } + + bubble.appendChild(sourceMeta); + } if (content) { const textNode = document.createElement('div'); textNode.className = 'msg-text'; @@ -3052,7 +3177,7 @@ } function buildMsgElement(m) { - const el = createMsgElement(m.role, m.content, m.attachments || []); + const el = createMsgElement(m.role, m.content, m.attachments || [], m); if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { const bubble = el.querySelector('.msg-bubble'); const FOLD_AT = 3; @@ -3822,6 +3947,12 @@ }); } + if (chatSessionIdBtn) { + chatSessionIdBtn.addEventListener('click', () => { + copyTextToClipboard(currentSessionId, '当前会话 ID 已复制'); + }); + } + // --- Header title editing (contenteditable) --- chatTitle.addEventListener('click', () => { if (!currentSessionId || chatTitle.contentEditable === 'true') return; @@ -5932,6 +6063,25 @@ renderSessionList(); connect(); window.addEventListener('resize', updateCwdBadge); + window.addEventListener('online', () => { + reconnectAttempts = 0; + connect(); + }); + window.addEventListener('offline', clearReconnectTimer); + window.addEventListener('pagehide', () => { + isPageUnloading = true; + clearReconnectTimer(); + if (ws && ws.readyState <= 1) { + ws.close(1001, 'pagehide'); + } + }); + window.addEventListener('pageshow', () => { + isPageUnloading = false; + if (!ws || ws.readyState > 1) { + reconnectAttempts = 0; + connect(); + } + }); // Register Service Worker for mobile push notifications if ('serviceWorker' in navigator) { @@ -5950,6 +6100,7 @@ if (document.visibilityState !== 'visible') return; if (!ws || ws.readyState > 1) { // WS is dead, force reconnect + reconnectAttempts = 0; connect(); } else if (ws.readyState === 1 && currentSessionId) { // Preserve active streaming UI when returning to foreground. diff --git a/public/index.html b/public/index.html index 77ef849..1f93970 100644 --- a/public/index.html +++ b/public/index.html @@ -60,6 +60,7 @@
新会话 +