feat: add cross conversation messaging
This commit is contained in:
@@ -9,6 +9,10 @@ function createAgentRuntime(deps) {
|
|||||||
getDefaultCodexModel,
|
getDefaultCodexModel,
|
||||||
loadCodexConfig,
|
loadCodexConfig,
|
||||||
prepareCodexCustomRuntime,
|
prepareCodexCustomRuntime,
|
||||||
|
ccwebMcpServerPath,
|
||||||
|
internalMcpUrl,
|
||||||
|
internalMcpToken,
|
||||||
|
nodePath,
|
||||||
wsSend,
|
wsSend,
|
||||||
truncateObj,
|
truncateObj,
|
||||||
sanitizeToolInput,
|
sanitizeToolInput,
|
||||||
@@ -18,6 +22,39 @@ function createAgentRuntime(deps) {
|
|||||||
getRuntimeSessionId,
|
getRuntimeSessionId,
|
||||||
} = deps;
|
} = 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 = {}) {
|
function buildClaudeSpawnSpec(session, options = {}) {
|
||||||
const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0;
|
const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0;
|
||||||
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
||||||
@@ -75,6 +112,8 @@ function createAgentRuntime(deps) {
|
|||||||
const runtimeId = getRuntimeSessionId(session);
|
const runtimeId = getRuntimeSessionId(session);
|
||||||
const args = ['exec'];
|
const args = ['exec'];
|
||||||
args.push('--json', '--skip-git-repo-check');
|
args.push('--json', '--skip-git-repo-check');
|
||||||
|
const ccwebMcpEnv = createCcwebMcpEnv(session, options);
|
||||||
|
appendCcwebMcpConfig(args, ccwebMcpEnv);
|
||||||
|
|
||||||
const permMode = session.permissionMode || 'yolo';
|
const permMode = session.permissionMode || 'yolo';
|
||||||
// `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`.
|
// `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`.
|
||||||
@@ -126,7 +165,7 @@ function createAgentRuntime(deps) {
|
|||||||
args.push('-');
|
args.push('-');
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = { ...processEnv };
|
const env = { ...processEnv, ...(ccwebMcpEnv || {}) };
|
||||||
delete env.CC_WEB_PASSWORD;
|
delete env.CC_WEB_PASSWORD;
|
||||||
delete env.CLAUDECODE;
|
delete env.CLAUDECODE;
|
||||||
delete env.CLAUDE_CODE;
|
delete env.CLAUDE_CODE;
|
||||||
|
|||||||
251
lib/ccweb-mcp-server.js
Normal file
251
lib/ccweb-mcp-server.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
173
public/app.js
173
public/app.js
@@ -81,6 +81,7 @@
|
|||||||
let isGenerating = false;
|
let isGenerating = false;
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
|
let isPageUnloading = false;
|
||||||
let pendingText = '';
|
let pendingText = '';
|
||||||
let renderTimer = null;
|
let renderTimer = null;
|
||||||
let generatingSessionId = null;
|
let generatingSessionId = null;
|
||||||
@@ -135,6 +136,7 @@
|
|||||||
const importSessionBtn = $('#import-session-btn');
|
const importSessionBtn = $('#import-session-btn');
|
||||||
const sessionList = $('#session-list');
|
const sessionList = $('#session-list');
|
||||||
const chatTitle = $('#chat-title');
|
const chatTitle = $('#chat-title');
|
||||||
|
const chatSessionIdBtn = $('#chat-session-id-btn');
|
||||||
const chatAgentBtn = $('#chat-agent-btn');
|
const chatAgentBtn = $('#chat-agent-btn');
|
||||||
const chatAgentMenu = $('#chat-agent-menu');
|
const chatAgentMenu = $('#chat-agent-menu');
|
||||||
const chatRuntimeState = $('#chat-runtime-state');
|
const chatRuntimeState = $('#chat-runtime-state');
|
||||||
@@ -648,6 +650,51 @@
|
|||||||
return sessions.find((s) => s.id === sessionId) || null;
|
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) {
|
function deepClone(value) {
|
||||||
if (value === null || value === undefined) return value;
|
if (value === null || value === undefined) return value;
|
||||||
return JSON.parse(JSON.stringify(value));
|
return JSON.parse(JSON.stringify(value));
|
||||||
@@ -1797,6 +1844,7 @@
|
|||||||
${session.hasUnread ? '<span class="session-unread-dot"></span>' : ''}
|
${session.hasUnread ? '<span class="session-unread-dot"></span>' : ''}
|
||||||
<span class="session-item-time">${timeAgo(session.updated)}</span>
|
<span class="session-item-time">${timeAgo(session.updated)}</span>
|
||||||
<div class="session-item-actions">
|
<div class="session-item-actions">
|
||||||
|
<button class="session-item-btn copy-id" title="复制 ID">ID</button>
|
||||||
<button class="session-item-btn edit" title="重命名">✎</button>
|
<button class="session-item-btn edit" title="重命名">✎</button>
|
||||||
<button class="session-item-btn delete" title="删除">×</button>
|
<button class="session-item-btn delete" title="删除">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1804,6 +1852,11 @@
|
|||||||
|
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
|
if (target.classList.contains('copy-id')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyTextToClipboard(session.id, '会话 ID 已复制');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (target.classList.contains('delete')) {
|
if (target.classList.contains('delete')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const doDelete = () => {
|
const doDelete = () => {
|
||||||
@@ -1920,6 +1973,7 @@
|
|||||||
sendBtn.hidden = false;
|
sendBtn.hidden = false;
|
||||||
abortBtn.hidden = true;
|
abortBtn.hidden = true;
|
||||||
chatTitle.textContent = '新会话';
|
chatTitle.textContent = '新会话';
|
||||||
|
updateSessionIdBadge();
|
||||||
updateCwdBadge();
|
updateCwdBadge();
|
||||||
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
|
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
|
||||||
setStatsDisplay(null);
|
setStatsDisplay(null);
|
||||||
@@ -1949,6 +2003,7 @@
|
|||||||
loadedHistorySessionId = snapshot.sessionId;
|
loadedHistorySessionId = snapshot.sessionId;
|
||||||
setLastSessionForAgent(snapshot.agent, currentSessionId);
|
setLastSessionForAgent(snapshot.agent, currentSessionId);
|
||||||
chatTitle.textContent = snapshot.title || '新会话';
|
chatTitle.textContent = snapshot.title || '新会话';
|
||||||
|
updateSessionIdBadge();
|
||||||
setCurrentAgent(snapshotAgent);
|
setCurrentAgent(snapshotAgent);
|
||||||
migratePendingNotesToSession(snapshot.sessionId, snapshotAgent);
|
migratePendingNotesToSession(snapshot.sessionId, snapshotAgent);
|
||||||
setCurrentSessionRunningState(snapshot.isRunning);
|
setCurrentSessionRunningState(snapshot.isRunning);
|
||||||
@@ -2221,26 +2276,49 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- WebSocket ---
|
// --- WebSocket ---
|
||||||
function connect() {
|
function isBrowserOnline() {
|
||||||
if (ws && ws.readyState <= 1) return;
|
return !('onLine' in navigator) || navigator.onLine;
|
||||||
ws = new WebSocket(WS_URL);
|
}
|
||||||
|
|
||||||
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;
|
reconnectAttempts = 0;
|
||||||
|
clearReconnectTimer();
|
||||||
if (authToken) send({ type: 'auth', token: authToken });
|
if (authToken) send({ type: 'auth', token: authToken });
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
socket.onmessage = (e) => {
|
||||||
|
if (ws !== socket) return;
|
||||||
let msg;
|
let msg;
|
||||||
try { msg = JSON.parse(e.data); } catch { return; }
|
try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
handleServerMessage(msg);
|
handleServerMessage(msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
if (ws !== socket) return;
|
||||||
|
ws = null;
|
||||||
clearSessionLoading();
|
clearSessionLoading();
|
||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
};
|
};
|
||||||
ws.onerror = () => {};
|
socket.onerror = () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function send(data) {
|
function send(data) {
|
||||||
@@ -2248,6 +2326,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleReconnect() {
|
function scheduleReconnect() {
|
||||||
|
if (!canConnectWs()) return;
|
||||||
if (reconnectTimer) return;
|
if (reconnectTimer) return;
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
@@ -2367,6 +2446,22 @@
|
|||||||
}
|
}
|
||||||
break;
|
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':
|
case 'session_renamed':
|
||||||
sessions = sessions.map((session) => session.id === msg.sessionId ? { ...session, title: msg.title } : session);
|
sessions = sessions.map((session) => session.id === msg.sessionId ? { ...session, title: msg.title } : session);
|
||||||
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; });
|
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; });
|
||||||
@@ -2755,9 +2850,10 @@
|
|||||||
catch { return escapeHtml(text); }
|
catch { return escapeHtml(text); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMsgElement(role, content, attachments = []) {
|
function createMsgElement(role, content, attachments = [], meta = {}) {
|
||||||
const div = document.createElement('div');
|
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') {
|
if (role === 'system') {
|
||||||
const bubble = document.createElement('div');
|
const bubble = document.createElement('div');
|
||||||
@@ -2769,7 +2865,9 @@
|
|||||||
|
|
||||||
const avatar = document.createElement('div');
|
const avatar = document.createElement('div');
|
||||||
avatar.className = 'msg-avatar';
|
avatar.className = 'msg-avatar';
|
||||||
if (role === 'user') {
|
if (isCrossConversation) {
|
||||||
|
avatar.textContent = '↗';
|
||||||
|
} else if (role === 'user') {
|
||||||
avatar.textContent = 'U';
|
avatar.textContent = 'U';
|
||||||
} else if (currentAgent === 'codex') {
|
} else if (currentAgent === 'codex') {
|
||||||
avatar.innerHTML = `<img src="/codex.png" width="24" height="24" style="display:block;" alt="Codex">`;
|
avatar.innerHTML = `<img src="/codex.png" width="24" height="24" style="display:block;" alt="Codex">`;
|
||||||
@@ -2781,6 +2879,33 @@
|
|||||||
bubble.className = 'msg-bubble';
|
bubble.className = 'msg-bubble';
|
||||||
|
|
||||||
if (role === 'user') {
|
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) {
|
if (content) {
|
||||||
const textNode = document.createElement('div');
|
const textNode = document.createElement('div');
|
||||||
textNode.className = 'msg-text';
|
textNode.className = 'msg-text';
|
||||||
@@ -3052,7 +3177,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildMsgElement(m) {
|
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) {
|
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
|
||||||
const bubble = el.querySelector('.msg-bubble');
|
const bubble = el.querySelector('.msg-bubble');
|
||||||
const FOLD_AT = 3;
|
const FOLD_AT = 3;
|
||||||
@@ -3822,6 +3947,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chatSessionIdBtn) {
|
||||||
|
chatSessionIdBtn.addEventListener('click', () => {
|
||||||
|
copyTextToClipboard(currentSessionId, '当前会话 ID 已复制');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Header title editing (contenteditable) ---
|
// --- Header title editing (contenteditable) ---
|
||||||
chatTitle.addEventListener('click', () => {
|
chatTitle.addEventListener('click', () => {
|
||||||
if (!currentSessionId || chatTitle.contentEditable === 'true') return;
|
if (!currentSessionId || chatTitle.contentEditable === 'true') return;
|
||||||
@@ -5932,6 +6063,25 @@
|
|||||||
renderSessionList();
|
renderSessionList();
|
||||||
connect();
|
connect();
|
||||||
window.addEventListener('resize', updateCwdBadge);
|
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
|
// Register Service Worker for mobile push notifications
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
@@ -5950,6 +6100,7 @@
|
|||||||
if (document.visibilityState !== 'visible') return;
|
if (document.visibilityState !== 'visible') return;
|
||||||
if (!ws || ws.readyState > 1) {
|
if (!ws || ws.readyState > 1) {
|
||||||
// WS is dead, force reconnect
|
// WS is dead, force reconnect
|
||||||
|
reconnectAttempts = 0;
|
||||||
connect();
|
connect();
|
||||||
} else if (ws.readyState === 1 && currentSessionId) {
|
} else if (ws.readyState === 1 && currentSessionId) {
|
||||||
// Preserve active streaming UI when returning to foreground.
|
// Preserve active streaming UI when returning to foreground.
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<button id="menu-btn" class="menu-btn" title="菜单">☰</button>
|
<button id="menu-btn" class="menu-btn" title="菜单">☰</button>
|
||||||
<span id="chat-title" class="chat-title">新会话</span>
|
<span id="chat-title" class="chat-title">新会话</span>
|
||||||
|
<button id="chat-session-id-btn" class="chat-session-id-btn" type="button" title="复制当前会话 ID" hidden>ID</button>
|
||||||
<button id="chat-agent-btn" class="chat-agent-btn" type="button" aria-haspopup="menu" aria-expanded="false">Claude</button>
|
<button id="chat-agent-btn" class="chat-agent-btn" type="button" aria-haspopup="menu" aria-expanded="false">Claude</button>
|
||||||
<div id="chat-agent-menu" class="chat-agent-menu" hidden>
|
<div id="chat-agent-menu" class="chat-agent-menu" hidden>
|
||||||
<button type="button" class="chat-agent-option active" data-agent="claude">Claude</button>
|
<button type="button" class="chat-agent-option active" data-agent="claude">Claude</button>
|
||||||
|
|||||||
@@ -804,6 +804,12 @@ body.session-loading-active {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.session-item-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
.session-item-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||||
|
.session-item-btn.copy-id {
|
||||||
|
min-width: 24px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
.session-item-btn.delete:hover { color: var(--danger); background: var(--accent-light); }
|
.session-item-btn.delete:hover { color: var(--danger); background: var(--accent-light); }
|
||||||
/* Inline edit in sidebar */
|
/* Inline edit in sidebar */
|
||||||
.session-item-edit-input {
|
.session-item-edit-input {
|
||||||
@@ -872,6 +878,26 @@ body.session-loading-active {
|
|||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.chat-title:hover { background: var(--bg-tertiary); }
|
.chat-title:hover { background: var(--bg-tertiary); }
|
||||||
|
.chat-session-id-btn {
|
||||||
|
appearance: none;
|
||||||
|
max-width: 112px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid rgba(91, 126, 161, 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(91, 126, 161, 0.1);
|
||||||
|
color: var(--info);
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-session-id-btn:hover {
|
||||||
|
background: rgba(91, 126, 161, 0.16);
|
||||||
|
border-color: rgba(91, 126, 161, 0.34);
|
||||||
|
}
|
||||||
.chat-agent-btn {
|
.chat-agent-btn {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1268,6 +1294,46 @@ body.session-loading-active {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
border-bottom-right-radius: 4px;
|
border-bottom-right-radius: 4px;
|
||||||
}
|
}
|
||||||
|
.msg.user.cross-conversation .msg-avatar {
|
||||||
|
background: var(--info);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.msg.user.cross-conversation .msg-bubble {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.76), transparent),
|
||||||
|
rgba(91, 126, 161, 0.1);
|
||||||
|
border: 1px solid rgba(91, 126, 161, 0.24);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 10px 22px rgba(45, 31, 20, 0.05);
|
||||||
|
}
|
||||||
|
.cross-conversation-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
color: var(--info);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.cross-conversation-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.cross-conversation-id-btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(91, 126, 161, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
color: var(--info);
|
||||||
|
padding: 2px 7px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cross-conversation-id-btn:hover {
|
||||||
|
background: rgba(91, 126, 161, 0.14);
|
||||||
|
}
|
||||||
.msg.assistant .msg-bubble {
|
.msg.assistant .msg-bubble {
|
||||||
background: var(--bg-bubble-assistant);
|
background: var(--bg-bubble-assistant);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -2149,6 +2215,12 @@ body.session-loading-active {
|
|||||||
.chat-title {
|
.chat-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.chat-session-id-btn {
|
||||||
|
max-width: 82px;
|
||||||
|
padding-left: 7px;
|
||||||
|
padding-right: 7px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
.theme-grid {
|
.theme-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const REPO_DIR = path.resolve(__dirname, '..');
|
|||||||
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
|
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
|
||||||
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
|
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
|
||||||
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
|
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
|
||||||
|
const HAS_SQLITE3 = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' }).status === 0;
|
||||||
|
|
||||||
function mkdirp(dir) {
|
function mkdirp(dir) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
@@ -39,6 +40,7 @@ function assert(condition, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sql(dbPath, statement) {
|
function sql(dbPath, statement) {
|
||||||
|
if (!HAS_SQLITE3) throw new Error('sqlite3 is not available');
|
||||||
const result = spawnSync('sqlite3', [dbPath, statement], { encoding: 'utf8' });
|
const result = spawnSync('sqlite3', [dbPath, statement], { encoding: 'utf8' });
|
||||||
if (result.status !== 0) throw new Error(result.stderr || `sqlite3 failed: ${statement}`);
|
if (result.status !== 0) throw new Error(result.stderr || `sqlite3 failed: ${statement}`);
|
||||||
return result.stdout.trim();
|
return result.stdout.trim();
|
||||||
@@ -102,6 +104,43 @@ function connectWs(port, password) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertWsUpgradeRejected(port, pathname) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${port}${pathname}`);
|
||||||
|
const timer = setTimeout(() => finish(reject, new Error(`WebSocket upgrade was not rejected for ${pathname}`)), 5000);
|
||||||
|
|
||||||
|
function finish(done, value) {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
try { ws.terminate(); } catch {}
|
||||||
|
}
|
||||||
|
done(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
finish(reject, new Error(`Unexpected WebSocket connection opened for ${pathname}`));
|
||||||
|
});
|
||||||
|
ws.on('unexpected-response', (req, res) => {
|
||||||
|
res.resume();
|
||||||
|
if (res.statusCode === 404) {
|
||||||
|
finish(resolve);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish(reject, new Error(`Expected 404 for ${pathname}, got ${res.statusCode}`));
|
||||||
|
});
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
if (/Unexpected server response: 404/.test(err.message || '')) {
|
||||||
|
finish(resolve);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish(reject, err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadAttachment(port, token, { filename, mime, data }) {
|
async function uploadAttachment(port, token, { filename, mime, data }) {
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/api/attachments`, {
|
const response = await fetch(`http://127.0.0.1:${port}/api/attachments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -128,6 +167,22 @@ async function fetchAuthedJson(port, token, pathname) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function callInternalMcp(port, token, payload) {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CC-Web-MCP-Token': token,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {}
|
||||||
|
return { status: response.status, body };
|
||||||
|
}
|
||||||
|
|
||||||
function nextMessage(messages, ws, predicate, timeoutMs = 15000) {
|
function nextMessage(messages, ws, predicate, timeoutMs = 15000) {
|
||||||
const callSite = (() => {
|
const callSite = (() => {
|
||||||
const stack = String(new Error().stack || '').split('\n');
|
const stack = String(new Error().stack || '').split('\n');
|
||||||
@@ -220,7 +275,10 @@ function createFakeCodexHistory(homeDir) {
|
|||||||
];
|
];
|
||||||
fs.writeFileSync(rolloutPath, `${rolloutLines.join('\n')}\n`);
|
fs.writeFileSync(rolloutPath, `${rolloutLines.join('\n')}\n`);
|
||||||
|
|
||||||
const stateDb = path.join(homeDir, '.codex', 'state_5.sqlite');
|
let stateDb = null;
|
||||||
|
let logsDb = null;
|
||||||
|
if (HAS_SQLITE3) {
|
||||||
|
stateDb = path.join(homeDir, '.codex', 'state_5.sqlite');
|
||||||
mkdirp(path.dirname(stateDb));
|
mkdirp(path.dirname(stateDb));
|
||||||
sql(stateDb, `
|
sql(stateDb, `
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
@@ -282,7 +340,7 @@ function createFakeCodexHistory(homeDir) {
|
|||||||
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
|
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite');
|
logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite');
|
||||||
sql(logsDb, `
|
sql(logsDb, `
|
||||||
CREATE TABLE IF NOT EXISTS logs (
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -300,6 +358,7 @@ function createFakeCodexHistory(homeDir) {
|
|||||||
);
|
);
|
||||||
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
|
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
|
||||||
`);
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
return { threadId, rolloutPath, stateDb, logsDb };
|
return { threadId, rolloutPath, stateDb, logsDb };
|
||||||
}
|
}
|
||||||
@@ -344,10 +403,12 @@ async function main() {
|
|||||||
|
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const password = 'Regression!234';
|
const password = 'Regression!234';
|
||||||
|
const internalMcpToken = 'RegressionMcp!234';
|
||||||
|
|
||||||
await withServer({
|
await withServer({
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
CC_WEB_PASSWORD: password,
|
CC_WEB_PASSWORD: password,
|
||||||
|
CC_WEB_INTERNAL_MCP_TOKEN: internalMcpToken,
|
||||||
CC_WEB_CONFIG_DIR: configDir,
|
CC_WEB_CONFIG_DIR: configDir,
|
||||||
CC_WEB_SESSIONS_DIR: sessionsDir,
|
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||||||
CC_WEB_LOGS_DIR: logsDir,
|
CC_WEB_LOGS_DIR: logsDir,
|
||||||
@@ -355,6 +416,8 @@ async function main() {
|
|||||||
CLAUDE_PATH: MOCK_CLAUDE,
|
CLAUDE_PATH: MOCK_CLAUDE,
|
||||||
CODEX_PATH: MOCK_CODEX,
|
CODEX_PATH: MOCK_CODEX,
|
||||||
}, async () => {
|
}, async () => {
|
||||||
|
await assertWsUpgradeRejected(port, '/not-ws');
|
||||||
|
|
||||||
const { ws, messages, token } = await connectWs(port, password);
|
const { ws, messages, token } = await connectWs(port, password);
|
||||||
|
|
||||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||||||
@@ -409,6 +472,62 @@ async function main() {
|
|||||||
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
|
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
|
||||||
assert(codexSession.model === 'gpt-5.5(xhigh)', 'Codex new_session should read default model from ~/.codex/config.toml');
|
assert(codexSession.model === 'gpt-5.5(xhigh)', 'Codex new_session should read default model from ~/.codex/config.toml');
|
||||||
|
|
||||||
|
const mcpList = await callInternalMcp(port, internalMcpToken, {
|
||||||
|
tool: 'ccweb_list_conversations',
|
||||||
|
sourceSessionId: codexSession.sessionId,
|
||||||
|
args: { agent: 'codex', limit: 20 },
|
||||||
|
});
|
||||||
|
assert(mcpList.status === 200 && mcpList.body?.ok, 'MCP conversation list should succeed');
|
||||||
|
assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id');
|
||||||
|
assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary');
|
||||||
|
|
||||||
|
const crossTargetCwd = path.join(tempRoot, 'codex-mcp-cross-target');
|
||||||
|
mkdirp(crossTargetCwd);
|
||||||
|
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTargetCwd, mode: 'yolo' }));
|
||||||
|
const crossTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossTargetCwd);
|
||||||
|
const crossSend = await callInternalMcp(port, internalMcpToken, {
|
||||||
|
tool: 'ccweb_send_message',
|
||||||
|
sourceSessionId: codexSession.sessionId,
|
||||||
|
sourceHopCount: 0,
|
||||||
|
args: {
|
||||||
|
targetConversationId: crossTargetSession.sessionId,
|
||||||
|
content: 'cross hello from mcp',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(crossSend.status === 200 && crossSend.body?.ok, `MCP cross send should succeed: ${JSON.stringify(crossSend.body)}`);
|
||||||
|
const crossUserBubble = await nextMessage(messages, ws, (msg) => (
|
||||||
|
msg.type === 'session_message' &&
|
||||||
|
msg.sessionId === crossTargetSession.sessionId &&
|
||||||
|
msg.message?.crossConversation?.sourceSessionId === codexSession.sessionId &&
|
||||||
|
msg.message?.content === 'cross hello from mcp'
|
||||||
|
));
|
||||||
|
assert(crossUserBubble.message.crossConversation.hopCount === 1, 'Cross message should persist hop count');
|
||||||
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
|
||||||
|
const storedCrossTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossTargetSession.sessionId}.json`), 'utf8'));
|
||||||
|
const storedCrossMessage = storedCrossTarget.messages.find((message) => message.crossConversation?.messageId === crossSend.body.messageId);
|
||||||
|
assert(storedCrossMessage?.content === 'cross hello from mcp', 'Cross message should be persisted in target session');
|
||||||
|
assert(storedCrossMessage.crossConversation.sourceTitle === codexSession.title, 'Cross message should persist source title');
|
||||||
|
assert(storedCrossTarget.messages.some((message) => message.role === 'assistant' && /来自/.test(String(message.content || ''))), 'Cross message runtime prompt should include source context for the target agent');
|
||||||
|
|
||||||
|
const hopLimit = await callInternalMcp(port, internalMcpToken, {
|
||||||
|
tool: 'ccweb_send_message',
|
||||||
|
sourceSessionId: crossTargetSession.sessionId,
|
||||||
|
sourceHopCount: 1,
|
||||||
|
args: {
|
||||||
|
targetConversationId: codexSession.sessionId,
|
||||||
|
content: 'this should be blocked by hop limit',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(hopLimit.status === 400 && hopLimit.body?.code === 'hop_limit_exceeded', 'MCP cross send should enforce hop limit');
|
||||||
|
|
||||||
|
const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
|
||||||
|
const mcpSpawnLine = processLogAfterMcp
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(crossTargetSession.sessionId.slice(0, 8)));
|
||||||
|
assert(mcpSpawnLine && mcpSpawnLine.includes('mcp_servers.ccweb.command') && mcpSpawnLine.includes('mcp_servers.ccweb.env_vars'), 'Codex spawn should inject ccweb MCP config');
|
||||||
|
assert(!mcpSpawnLine.includes(internalMcpToken), 'Codex spawn log should not expose internal MCP token');
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'list_cwd_suggestions' }));
|
ws.send(JSON.stringify({ type: 'list_cwd_suggestions' }));
|
||||||
const cwdSuggestions = await nextMessage(messages, ws, (msg) => msg.type === 'cwd_suggestions');
|
const cwdSuggestions = await nextMessage(messages, ws, (msg) => msg.type === 'cwd_suggestions');
|
||||||
assert(cwdSuggestions.defaultPath === homeDir, 'CWD suggestions should expose HOME as default path');
|
assert(cwdSuggestions.defaultPath === homeDir, 'CWD suggestions should expose HOME as default path');
|
||||||
@@ -573,7 +692,9 @@ async function main() {
|
|||||||
|
|
||||||
assert(!fs.existsSync(path.join(sessionsDir, `${importedSessionId}.json`)), 'Deleting Codex session did not remove session JSON');
|
assert(!fs.existsSync(path.join(sessionsDir, `${importedSessionId}.json`)), 'Deleting Codex session did not remove session JSON');
|
||||||
assert(!fs.existsSync(codexFixture.rolloutPath), 'Deleting Codex session did not remove rollout file');
|
assert(!fs.existsSync(codexFixture.rolloutPath), 'Deleting Codex session did not remove rollout file');
|
||||||
|
if (codexFixture.stateDb) {
|
||||||
assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row');
|
assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row');
|
||||||
|
}
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
console.log('Regression checks passed.');
|
console.log('Regression checks passed.');
|
||||||
|
|||||||
319
server.js
319
server.js
@@ -19,14 +19,17 @@ if (fs.existsSync(envPath)) {
|
|||||||
const PORT = parseInt(process.env.PORT) || 8002;
|
const PORT = parseInt(process.env.PORT) || 8002;
|
||||||
const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude';
|
const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude';
|
||||||
const CODEX_PATH = process.env.CODEX_PATH || 'codex';
|
const CODEX_PATH = process.env.CODEX_PATH || 'codex';
|
||||||
|
const INTERNAL_MCP_TOKEN = process.env.CC_WEB_INTERNAL_MCP_TOKEN || crypto.randomBytes(32).toString('hex');
|
||||||
const CONFIG_DIR = process.env.CC_WEB_CONFIG_DIR || path.join(__dirname, 'config');
|
const CONFIG_DIR = process.env.CC_WEB_CONFIG_DIR || path.join(__dirname, 'config');
|
||||||
const SESSIONS_DIR = process.env.CC_WEB_SESSIONS_DIR || path.join(__dirname, 'sessions');
|
const SESSIONS_DIR = process.env.CC_WEB_SESSIONS_DIR || path.join(__dirname, 'sessions');
|
||||||
const PUBLIC_DIR = process.env.CC_WEB_PUBLIC_DIR || path.join(__dirname, 'public');
|
const PUBLIC_DIR = process.env.CC_WEB_PUBLIC_DIR || path.join(__dirname, 'public');
|
||||||
const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(__dirname, 'logs');
|
const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(__dirname, 'logs');
|
||||||
|
const CCWEB_MCP_SERVER_PATH = path.join(__dirname, 'lib', 'ccweb-mcp-server.js');
|
||||||
const ATTACHMENTS_DIR = path.join(SESSIONS_DIR, '_attachments');
|
const ATTACHMENTS_DIR = path.join(SESSIONS_DIR, '_attachments');
|
||||||
const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
||||||
const MAX_MESSAGE_ATTACHMENTS = 4;
|
const MAX_MESSAGE_ATTACHMENTS = 4;
|
||||||
|
const CROSS_CONVERSATION_MAX_HOPS = 1;
|
||||||
const FILE_BROWSER_MAX_LIST_ENTRIES = 400;
|
const FILE_BROWSER_MAX_LIST_ENTRIES = 400;
|
||||||
const FILE_BROWSER_MAX_PREVIEW_BYTES = 200 * 1024;
|
const FILE_BROWSER_MAX_PREVIEW_BYTES = 200 * 1024;
|
||||||
const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
||||||
@@ -1007,6 +1010,43 @@ function jsonResponse(res, statusCode, payload) {
|
|||||||
res.end(JSON.stringify(payload));
|
res.end(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readJsonBody(req, maxBytes = 1024 * 1024) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
let total = 0;
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
if (finished) return;
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
finished = true;
|
||||||
|
reject(new Error('请求体过大'));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('end', () => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
try {
|
||||||
|
const text = Buffer.concat(chunks).toString('utf8').trim();
|
||||||
|
resolve(text ? JSON.parse(text) : {});
|
||||||
|
} catch {
|
||||||
|
reject(new Error('请求体不是有效 JSON'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRelativeBrowserPath(input) {
|
function normalizeRelativeBrowserPath(input) {
|
||||||
return String(input || '')
|
return String(input || '')
|
||||||
.replace(/\\/g, '/')
|
.replace(/\\/g, '/')
|
||||||
@@ -1585,6 +1625,196 @@ function sendSessionList(ws) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function broadcastSessionList() {
|
||||||
|
if (!wss) return;
|
||||||
|
for (const client of wss.clients) {
|
||||||
|
if (client.readyState === 1) sendSessionList(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findViewingSessionWs(sessionId) {
|
||||||
|
const normalizedId = sanitizeId(sessionId || '');
|
||||||
|
if (!normalizedId) return null;
|
||||||
|
for (const [client, viewedSessionId] of wsSessionMap.entries()) {
|
||||||
|
if (viewedSessionId === normalizedId && client?.readyState === 1) return client;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInternalMcpRequestToken(req) {
|
||||||
|
return String(req.headers['x-cc-web-mcp-token'] || '').trim() || extractBearerToken(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mcpToolError(code, message, extra = {}) {
|
||||||
|
return { ok: false, code, message, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampMcpLimit(value) {
|
||||||
|
const parsed = Number.parseInt(String(value || ''), 10);
|
||||||
|
if (!Number.isFinite(parsed)) return 50;
|
||||||
|
return Math.max(1, Math.min(100, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listConversationSummaries(args = {}, sourceSessionId = '') {
|
||||||
|
const agentFilter = VALID_AGENTS.has(args.agent) ? args.agent : '';
|
||||||
|
const statusFilter = ['running', 'idle'].includes(args.status) ? args.status : 'all';
|
||||||
|
const limit = clampMcpLimit(args.limit);
|
||||||
|
const conversations = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(SESSIONS_DIR).filter((name) => name.endsWith('.json'));
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, file), 'utf8')));
|
||||||
|
const agent = getSessionAgent(session);
|
||||||
|
const running = activeProcesses.has(session.id);
|
||||||
|
const status = running ? 'running' : 'idle';
|
||||||
|
if (agentFilter && agent !== agentFilter) continue;
|
||||||
|
if (statusFilter !== 'all' && status !== statusFilter) continue;
|
||||||
|
const cwd = session.cwd || '';
|
||||||
|
conversations.push({
|
||||||
|
id: session.id,
|
||||||
|
title: session.title || 'Untitled',
|
||||||
|
agent,
|
||||||
|
status,
|
||||||
|
updatedAt: session.updated || session.created || null,
|
||||||
|
cwd,
|
||||||
|
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
|
||||||
|
isCurrent: session.id === sourceSessionId,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
conversations.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
currentConversationId: sourceSessionId || null,
|
||||||
|
conversations: conversations.slice(0, limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCrossConversationRuntimeText(sourceSession, content) {
|
||||||
|
const sourceTitle = sourceSession?.title || 'Untitled';
|
||||||
|
const sourceId = sourceSession?.id || '';
|
||||||
|
return `来自「${sourceTitle}」对话(ID: ${sourceId})的消息:\n\n${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHopCount = 0) {
|
||||||
|
const sourceId = sanitizeId(sourceSessionId || '');
|
||||||
|
const targetId = sanitizeId(args.targetConversationId || args.targetSessionId || args.conversationId || '');
|
||||||
|
const content = typeof args.content === 'string' ? args.content.trim() : '';
|
||||||
|
|
||||||
|
if (!sourceId) {
|
||||||
|
return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
|
||||||
|
}
|
||||||
|
if (!targetId) {
|
||||||
|
return mcpToolError('missing_target_conversation', '缺少目标对话 ID。');
|
||||||
|
}
|
||||||
|
if (!content) {
|
||||||
|
return mcpToolError('empty_content', '消息内容不能为空。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceSession = loadSession(sourceId);
|
||||||
|
if (!sourceSession) {
|
||||||
|
return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSession = loadSession(targetId);
|
||||||
|
if (!targetSession) {
|
||||||
|
return mcpToolError('target_not_found', '目标对话不存在。', { targetConversationId: targetId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceId === targetId) {
|
||||||
|
return mcpToolError('same_conversation', '不能把消息发送给当前同一个对话。', { targetConversationId: targetId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHopCount = Math.max(0, Number.parseInt(String(sourceHopCount || 0), 10) || 0);
|
||||||
|
if (normalizedHopCount >= CROSS_CONVERSATION_MAX_HOPS) {
|
||||||
|
return mcpToolError('hop_limit_exceeded', '跨对话消息已达到转发跳数上限,已拒绝继续转发。', {
|
||||||
|
sourceConversationId: sourceId,
|
||||||
|
targetConversationId: targetId,
|
||||||
|
maxHops: CROSS_CONVERSATION_MAX_HOPS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeProcesses.has(targetId)) {
|
||||||
|
return mcpToolError('target_running', '目标对话正在处理中,请稍后再发送。', { targetConversationId: targetId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const messageId = crypto.randomUUID();
|
||||||
|
const crossConversation = {
|
||||||
|
messageId,
|
||||||
|
sourceSessionId: sourceSession.id,
|
||||||
|
sourceTitle: sourceSession.title || 'Untitled',
|
||||||
|
sentAt: now,
|
||||||
|
hopCount: normalizedHopCount + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetWs = findViewingSessionWs(targetId);
|
||||||
|
const result = handleMessage(targetWs, {
|
||||||
|
text: content,
|
||||||
|
sessionId: targetSession.id,
|
||||||
|
mode: targetSession.permissionMode || 'yolo',
|
||||||
|
agent: getSessionAgent(targetSession),
|
||||||
|
}, {
|
||||||
|
crossConversation,
|
||||||
|
emitUserMessage: true,
|
||||||
|
runtimeText: buildCrossConversationRuntimeText(sourceSession, content),
|
||||||
|
mcpContext: { hopCount: crossConversation.hopCount },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.ok) {
|
||||||
|
return mcpToolError(result?.code || 'send_failed', result?.message || '跨对话消息发送失败。', {
|
||||||
|
sourceConversationId: sourceId,
|
||||||
|
targetConversationId: targetId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastSessionList();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
messageId,
|
||||||
|
deliveryStatus: 'delivered',
|
||||||
|
sourceConversationId: sourceId,
|
||||||
|
targetConversationId: targetId,
|
||||||
|
targetTitle: targetSession.title || 'Untitled',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
|
||||||
|
switch (tool) {
|
||||||
|
case 'ccweb_list_conversations':
|
||||||
|
return listConversationSummaries(args, sanitizeId(sourceSessionId || ''));
|
||||||
|
case 'ccweb_send_message':
|
||||||
|
return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount);
|
||||||
|
default:
|
||||||
|
return mcpToolError('unknown_tool', `未知 MCP 工具: ${tool}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInternalMcpApi(req, res) {
|
||||||
|
const token = getInternalMcpRequestToken(req);
|
||||||
|
if (!token || token !== INTERNAL_MCP_TOKEN) {
|
||||||
|
return jsonResponse(res, 401, mcpToolError('unauthorized', 'MCP 内部接口未授权。'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = await readJsonBody(req);
|
||||||
|
} catch (err) {
|
||||||
|
return jsonResponse(res, 400, mcpToolError('bad_request', err.message || '请求体无效。'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tool = String(payload.tool || '');
|
||||||
|
const args = payload.args && typeof payload.args === 'object' ? payload.args : {};
|
||||||
|
const sourceSessionId = sanitizeId(payload.sourceSessionId || '');
|
||||||
|
const sourceHopCount = Number.parseInt(String(payload.sourceHopCount || 0), 10) || 0;
|
||||||
|
const result = callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount);
|
||||||
|
return jsonResponse(res, result.ok ? 200 : 400, result);
|
||||||
|
}
|
||||||
|
|
||||||
// === File Tailer ===
|
// === File Tailer ===
|
||||||
// Tails a file and calls onLine for each new complete line.
|
// Tails a file and calls onLine for each new complete line.
|
||||||
class FileTailer {
|
class FileTailer {
|
||||||
@@ -2016,6 +2246,10 @@ function recoverProcesses() {
|
|||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
|
||||||
|
if (req.method === 'POST' && url.pathname === '/api/internal/mcp') {
|
||||||
|
return handleInternalMcpApi(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/attachments') {
|
if (req.method === 'POST' && url.pathname === '/api/attachments') {
|
||||||
const token = extractBearerToken(req);
|
const token = extractBearerToken(req);
|
||||||
if (!token || !activeTokens.has(token)) {
|
if (!token || !activeTokens.has(token)) {
|
||||||
@@ -2160,7 +2394,26 @@ const server = http.createServer((req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// === WebSocket Server ===
|
// === WebSocket Server ===
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
server.on('upgrade', (req, socket, head) => {
|
||||||
|
let pathname = '';
|
||||||
|
try {
|
||||||
|
pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
} catch {
|
||||||
|
pathname = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname !== '/ws') {
|
||||||
|
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
wss.emit('connection', ws, req);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
wss.on('connection', (ws, req) => {
|
wss.on('connection', (ws, req) => {
|
||||||
ws._req = req;
|
ws._req = req;
|
||||||
@@ -3042,14 +3295,22 @@ function handleAbort(ws) {
|
|||||||
function handleMessage(ws, msg, options = {}) {
|
function handleMessage(ws, msg, options = {}) {
|
||||||
const { text, sessionId, mode } = msg;
|
const { text, sessionId, mode } = msg;
|
||||||
const { hideInHistory = false } = options;
|
const { hideInHistory = false } = options;
|
||||||
|
const fail = (code, message) => {
|
||||||
|
wsSend(ws, { type: 'error', code, message });
|
||||||
|
return { ok: false, code, message };
|
||||||
|
};
|
||||||
const textValue = typeof text === 'string' ? text : '';
|
const textValue = typeof text === 'string' ? text : '';
|
||||||
|
const runtimeTextValue = typeof options.runtimeText === 'string' ? options.runtimeText : textValue;
|
||||||
const attachments = Array.isArray(msg.attachments) ? msg.attachments.slice(0, MAX_MESSAGE_ATTACHMENTS) : [];
|
const attachments = Array.isArray(msg.attachments) ? msg.attachments.slice(0, MAX_MESSAGE_ATTACHMENTS) : [];
|
||||||
const normalizedText = textValue.trim();
|
const normalizedText = textValue.trim();
|
||||||
|
const normalizedRuntimeText = runtimeTextValue.trim();
|
||||||
const resolvedAttachments = resolveMessageAttachments(attachments);
|
const resolvedAttachments = resolveMessageAttachments(attachments);
|
||||||
if (attachments.length > 0 && resolvedAttachments.length === 0) {
|
if (attachments.length > 0 && resolvedAttachments.length === 0) {
|
||||||
return wsSend(ws, { type: 'error', message: '图片附件已过期或不可用,请重新上传后再发送。' });
|
return fail('attachment_unavailable', '图片附件已过期或不可用,请重新上传后再发送。');
|
||||||
|
}
|
||||||
|
if (!normalizedText && resolvedAttachments.length === 0) {
|
||||||
|
return fail('empty_message', '消息内容不能为空。');
|
||||||
}
|
}
|
||||||
if (!normalizedText && resolvedAttachments.length === 0) return;
|
|
||||||
|
|
||||||
const savedAttachments = resolvedAttachments.map((attachment) => ({
|
const savedAttachments = resolvedAttachments.map((attachment) => ({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
@@ -3063,7 +3324,7 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (sessionId && activeProcesses.has(sessionId)) {
|
if (sessionId && activeProcesses.has(sessionId)) {
|
||||||
return wsSend(ws, { type: 'error', message: '正在处理中,请先点击停止按钮。' });
|
return fail('session_running', '正在处理中,请先点击停止按钮。');
|
||||||
}
|
}
|
||||||
|
|
||||||
const derivedTitle = normalizedText
|
const derivedTitle = normalizedText
|
||||||
@@ -3095,38 +3356,45 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
normalizeSession(session);
|
normalizeSession(session);
|
||||||
|
|
||||||
if (normalizedText.startsWith('/') && resolvedAttachments.length > 0) {
|
if (normalizedText.startsWith('/') && resolvedAttachments.length > 0) {
|
||||||
return wsSend(ws, { type: 'error', message: '命令消息暂不支持同时附带图片。请先发送图片说明,再单独使用 /model 或 /mode。' });
|
return fail('command_attachment_unsupported', '命令消息暂不支持同时附带图片。请先发送图片说明,再单独使用 /model 或 /mode。');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode && ['default', 'plan', 'yolo'].includes(mode)) {
|
if (mode && ['default', 'plan', 'yolo'].includes(mode)) {
|
||||||
session.permissionMode = mode;
|
session.permissionMode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hideInHistory && normalizedText !== '/compact' && getRuntimeSessionId(session)) {
|
if (!hideInHistory && normalizedRuntimeText !== '/compact' && getRuntimeSessionId(session)) {
|
||||||
pendingCompactRetries.set(session.id, { text: normalizedText, mode: session.permissionMode || 'yolo', reason: 'normal' });
|
pendingCompactRetries.set(session.id, { text: normalizedRuntimeText, mode: session.permissionMode || 'yolo', reason: 'normal' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.title === 'New Chat' || session.title === 'Untitled') {
|
if (session.title === 'New Chat' || session.title === 'Untitled') {
|
||||||
session.title = derivedTitle;
|
session.title = derivedTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let persistedUserMessage = null;
|
||||||
if (!hideInHistory) {
|
if (!hideInHistory) {
|
||||||
session.messages.push({
|
persistedUserMessage = {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: textValue,
|
content: textValue,
|
||||||
attachments: savedAttachments,
|
attachments: savedAttachments,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
};
|
||||||
|
if (options.crossConversation) {
|
||||||
|
persistedUserMessage.crossConversation = options.crossConversation;
|
||||||
|
}
|
||||||
|
session.messages.push(persistedUserMessage);
|
||||||
}
|
}
|
||||||
session.updated = new Date().toISOString();
|
session.updated = new Date().toISOString();
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
|
|
||||||
const currentSessionId = session.id;
|
const currentSessionId = session.id;
|
||||||
|
|
||||||
|
if (ws) {
|
||||||
for (const [, entry] of activeProcesses) {
|
for (const [, entry] of activeProcesses) {
|
||||||
if (entry.ws === ws) entry.ws = null;
|
if (entry.ws === ws) entry.ws = null;
|
||||||
}
|
}
|
||||||
wsSessionMap.set(ws, currentSessionId);
|
wsSessionMap.set(ws, currentSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
wsSend(ws, {
|
wsSend(ws, {
|
||||||
@@ -3146,13 +3414,20 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
isRunning: false,
|
isRunning: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (ws && options.emitUserMessage && persistedUserMessage) {
|
||||||
|
wsSend(ws, { type: 'session_message', sessionId: currentSessionId, message: persistedUserMessage });
|
||||||
|
}
|
||||||
sendSessionList(ws);
|
sendSessionList(ws);
|
||||||
|
|
||||||
|
const runtimeOptions = {
|
||||||
|
attachments: resolvedAttachments,
|
||||||
|
mcpContext: options.mcpContext || {},
|
||||||
|
};
|
||||||
const spawnSpec = isClaudeSession(session)
|
const spawnSpec = isClaudeSession(session)
|
||||||
? buildClaudeSpawnSpec(session, { attachments: resolvedAttachments })
|
? buildClaudeSpawnSpec(session, runtimeOptions)
|
||||||
: buildCodexSpawnSpec(session, { attachments: resolvedAttachments });
|
: buildCodexSpawnSpec(session, runtimeOptions);
|
||||||
if (spawnSpec?.error) {
|
if (spawnSpec?.error) {
|
||||||
return wsSend(ws, { type: 'error', message: spawnSpec.error });
|
return fail('runtime_config_error', spawnSpec.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Detached process with file-based I/O ===
|
// === Detached process with file-based I/O ===
|
||||||
@@ -3165,7 +3440,7 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
|
|
||||||
if (isClaudeSession(session) && resolvedAttachments.length > 0) {
|
if (isClaudeSession(session) && resolvedAttachments.length > 0) {
|
||||||
const content = [];
|
const content = [];
|
||||||
if (textValue) content.push({ type: 'text', text: textValue });
|
if (runtimeTextValue) content.push({ type: 'text', text: runtimeTextValue });
|
||||||
for (const attachment of resolvedAttachments) {
|
for (const attachment of resolvedAttachments) {
|
||||||
const data = fs.readFileSync(attachment.path).toString('base64');
|
const data = fs.readFileSync(attachment.path).toString('base64');
|
||||||
content.push({
|
content.push({
|
||||||
@@ -3185,7 +3460,7 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
},
|
},
|
||||||
})}\n`);
|
})}\n`);
|
||||||
} else {
|
} else {
|
||||||
fs.writeFileSync(inputPath, textValue);
|
fs.writeFileSync(inputPath, runtimeTextValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputFd = fs.openSync(inputPath, 'r');
|
const inputFd = fs.openSync(inputPath, 'r');
|
||||||
@@ -3208,7 +3483,7 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
cleanRunDir(currentSessionId);
|
cleanRunDir(currentSessionId);
|
||||||
plog('ERROR', 'process_spawn_fail', { sessionId: currentSessionId.slice(0, 8), error: err.message });
|
plog('ERROR', 'process_spawn_fail', { sessionId: currentSessionId.slice(0, 8), error: err.message });
|
||||||
const agent = getSessionAgent(session);
|
const agent = getSessionAgent(session);
|
||||||
return wsSend(ws, { type: 'error', message: formatRuntimeError(agent, err.message, { exitCode: null, signal: null }) });
|
return fail('process_spawn_failed', formatRuntimeError(agent, err.message, { exitCode: null, signal: null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.closeSync(inputFd);
|
fs.closeSync(inputFd);
|
||||||
@@ -3225,7 +3500,7 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
mode: spawnSpec.mode,
|
mode: spawnSpec.mode,
|
||||||
model: session.model || 'default',
|
model: session.model || 'default',
|
||||||
resume: spawnSpec.resume,
|
resume: spawnSpec.resume,
|
||||||
args: spawnSpec.args.join(' '),
|
args: redactSpawnArgs(spawnSpec.args.join(' ')),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fast exit detection (while Node.js is running)
|
// Fast exit detection (while Node.js is running)
|
||||||
@@ -3265,6 +3540,8 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
entry.tailer.start();
|
entry.tailer.start();
|
||||||
|
|
||||||
|
return { ok: true, sessionId: currentSessionId, pid: proc.pid };
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateObj(obj, maxLen) {
|
function truncateObj(obj, maxLen) {
|
||||||
@@ -3296,6 +3573,12 @@ function sanitizeToolInput(toolName, input) {
|
|||||||
return truncateObj(parsed, 500);
|
return truncateObj(parsed, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redactSpawnArgs(argsText) {
|
||||||
|
return String(argsText || '')
|
||||||
|
.replace(/CC_WEB_MCP_TOKEN[^\s,\]}]*/g, 'CC_WEB_MCP_TOKEN=****')
|
||||||
|
.replace(/mcp_servers\.ccweb\.env=\{[^}]*\}/g, 'mcp_servers.ccweb.env={****}');
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
buildClaudeSpawnSpec,
|
buildClaudeSpawnSpec,
|
||||||
buildCodexSpawnSpec,
|
buildCodexSpawnSpec,
|
||||||
@@ -3312,6 +3595,10 @@ const {
|
|||||||
getDefaultCodexModel,
|
getDefaultCodexModel,
|
||||||
loadCodexConfig,
|
loadCodexConfig,
|
||||||
prepareCodexCustomRuntime,
|
prepareCodexCustomRuntime,
|
||||||
|
ccwebMcpServerPath: CCWEB_MCP_SERVER_PATH,
|
||||||
|
internalMcpUrl: `http://127.0.0.1:${PORT}/api/internal/mcp`,
|
||||||
|
internalMcpToken: INTERNAL_MCP_TOKEN,
|
||||||
|
nodePath: process.execPath,
|
||||||
wsSend,
|
wsSend,
|
||||||
truncateObj,
|
truncateObj,
|
||||||
sanitizeToolInput,
|
sanitizeToolInput,
|
||||||
|
|||||||
Reference in New Issue
Block a user