Files
cc-web/lib/agent-runtime.js
cc-dan 6f381998e9 feat: v1.2.8 - Codex双Agent、图片上传、主题系统、会话加载优化
- Codex双Agent接入:共享后端内核,前台隔离会话/设置/导入
- 图片上传:Claude (stream-json) 和 Codex (--image) 均支持拖拽/粘贴/选择上传
- 主题系统:CoolVibe Light 视觉方案,主题入口移至二级页
- 会话加载优化:加载遮罩、热会话缓存、切后台内容不丢失
- 移动端增强:侧栏手势、运行状态标签、按钮比例修复
- 后端重构:agent-runtime.js / codex-rollouts.js 模块拆分
- 回归脚本:npm run regression 隔离式测试
2026-03-13 12:46:34 +00:00

391 lines
12 KiB
JavaScript

function createAgentRuntime(deps) {
const {
processEnv,
CLAUDE_PATH,
CODEX_PATH,
MODEL_MAP,
loadModelConfig,
applyCustomTemplateToSettings,
loadCodexConfig,
prepareCodexCustomRuntime,
wsSend,
truncateObj,
sanitizeToolInput,
loadSession,
saveSession,
setRuntimeSessionId,
getRuntimeSessionId,
} = deps;
function buildClaudeSpawnSpec(session, options = {}) {
const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0;
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
if (hasAttachments) args.push('--input-format', 'stream-json');
const permMode = session.permissionMode || 'yolo';
switch (permMode) {
case 'yolo':
args.push('--dangerously-skip-permissions');
break;
case 'plan':
args.push('--permission-mode', 'plan');
break;
case 'default':
break;
}
if (session.claudeSessionId) {
args.push('--resume', session.claudeSessionId);
}
if (session.model) {
const validModels = new Set(Object.values(MODEL_MAP));
if (validModels.has(session.model)) {
args.push('--model', session.model);
}
}
const env = { ...processEnv };
delete env.CLAUDECODE;
delete env.CLAUDE_CODE;
delete env.CC_WEB_PASSWORD;
for (const k of Object.keys(env)) {
if (k.startsWith('ANTHROPIC_')) delete env[k];
}
const modelCfg = loadModelConfig();
if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
const tpl = (modelCfg.templates || []).find((t) => t.name === modelCfg.activeTemplate);
if (tpl) applyCustomTemplateToSettings(tpl);
}
return {
command: CLAUDE_PATH,
args,
env,
cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(),
parser: 'claude',
mode: permMode,
resume: !!session.claudeSessionId,
};
}
function buildCodexSpawnSpec(session, options = {}) {
const codexConfig = loadCodexConfig();
const runtimeConfig = prepareCodexCustomRuntime(codexConfig);
if (runtimeConfig?.error) {
return { error: runtimeConfig.error };
}
const runtimeId = getRuntimeSessionId(session);
const args = ['exec'];
if (runtimeId) args.push('resume');
args.push('--json', '--skip-git-repo-check');
const permMode = session.permissionMode || 'yolo';
switch (permMode) {
case 'yolo':
args.push('--dangerously-bypass-approvals-and-sandbox');
break;
case 'plan':
args.push('-s', 'read-only');
break;
case 'default':
default:
args.push('--full-auto');
break;
}
const effectiveModel = session.model;
if (effectiveModel) args.push('--model', effectiveModel);
if (Array.isArray(options.attachments)) {
for (const attachment of options.attachments) {
if (attachment?.path) args.push('--image', attachment.path);
}
}
if (runtimeId) {
args.push(runtimeId, '-');
} else {
if (session.cwd) args.push('-C', session.cwd);
args.push('-');
}
const env = { ...processEnv };
delete env.CC_WEB_PASSWORD;
delete env.CLAUDECODE;
delete env.CLAUDE_CODE;
if (runtimeConfig?.mode === 'custom') {
env.CODEX_HOME = runtimeConfig.homeDir;
env.OPENAI_API_KEY = runtimeConfig.apiKey;
delete env.OPENAI_BASE_URL;
}
return {
command: CODEX_PATH,
args,
env,
cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(),
parser: 'codex',
mode: permMode,
resume: !!runtimeId,
};
}
function codexToolName(item) {
switch (item?.type) {
case 'command_execution':
return 'CommandExecution';
case 'mcp_tool_call':
return 'McpToolCall';
case 'file_change':
return 'FileChange';
case 'reasoning':
return 'Reasoning';
default:
return item?.type || 'CodexItem';
}
}
function codexToolInput(item) {
if (!item) return null;
if (item.type === 'command_execution') return { command: item.command || '' };
return truncateObj(item, 500);
}
function codexToolMeta(item) {
if (!item) return null;
switch (item.type) {
case 'command_execution':
return {
kind: 'command_execution',
title: 'Shell Command',
subtitle: item.command || '',
exitCode: typeof item.exit_code === 'number' ? item.exit_code : null,
status: item.status || null,
};
case 'mcp_tool_call':
return {
kind: 'mcp_tool_call',
title: 'MCP Tool',
subtitle: item.tool_name || item.name || item.server_name || '',
status: item.status || null,
};
case 'file_change':
return {
kind: 'file_change',
title: 'File Change',
subtitle: item.path || item.file_path || '',
status: item.status || null,
};
case 'reasoning':
return {
kind: 'reasoning',
title: 'Reasoning',
subtitle: typeof item.text === 'string' ? item.text.slice(0, 120) : '',
status: item.status || null,
};
default:
return {
kind: item.type || 'codex_item',
title: codexToolName(item),
subtitle: '',
status: item.status || null,
};
}
}
function codexToolResult(item) {
if (!item) return '';
if (typeof item.aggregated_output === 'string' && item.aggregated_output) return item.aggregated_output;
if (typeof item.text === 'string' && item.text) return item.text;
return JSON.stringify(truncateObj(item, 1200));
}
function ensureCodexToolCall(entry, item) {
let tc = entry.toolCalls.find((t) => t.id === item.id);
if (tc) {
tc.name = codexToolName(item);
tc.kind = item.type || tc.kind || null;
tc.meta = codexToolMeta(item) || tc.meta || null;
if (tc.input == null) tc.input = codexToolInput(item);
return tc;
}
tc = {
name: codexToolName(item),
id: item.id,
kind: item.type || null,
meta: codexToolMeta(item),
input: codexToolInput(item),
done: false,
};
entry.toolCalls.push(tc);
wsSend(entry.ws, {
type: 'tool_start',
name: tc.name,
toolUseId: item.id,
input: tc.input,
kind: tc.kind,
meta: tc.meta,
});
return tc;
}
function processClaudeEvent(entry, event, sessionId) {
if (!event || !event.type) return;
switch (event.type) {
case 'system':
if (event.session_id) {
const session = loadSession(sessionId);
if (session) {
session.claudeSessionId = event.session_id;
saveSession(session);
}
}
break;
case 'assistant': {
const content = event.message?.content;
if (!Array.isArray(content)) break;
for (const block of content) {
if (block.type === 'text' && block.text) {
entry.fullText += block.text;
wsSend(entry.ws, { type: 'text_delta', text: block.text });
} else if (block.type === 'tool_use') {
const toolInput = sanitizeToolInput(block.name, block.input);
const tc = { name: block.name, id: block.id, input: toolInput, done: false };
entry.toolCalls.push(tc);
wsSend(entry.ws, { type: 'tool_start', name: block.name, toolUseId: block.id, input: tc.input });
} else if (block.type === 'tool_result') {
const resultText = typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
? block.content.map((c) => c.text || '').join('\n')
: JSON.stringify(block.content);
const tc = entry.toolCalls.find((t) => t.id === block.tool_use_id);
if (tc) {
tc.done = true;
tc.result = resultText.slice(0, 2000);
}
wsSend(entry.ws, { type: 'tool_end', toolUseId: block.tool_use_id, result: resultText.slice(0, 2000) });
}
}
if (event.session_id) {
const session = loadSession(sessionId);
if (session && !session.claudeSessionId) {
session.claudeSessionId = event.session_id;
saveSession(session);
}
}
break;
}
case 'result': {
const session = loadSession(sessionId);
if (session) {
if (event.session_id) session.claudeSessionId = event.session_id;
if (event.total_cost_usd) session.totalCost = (session.totalCost || 0) + event.total_cost_usd;
saveSession(session);
}
entry.lastCost = event.total_cost_usd || null;
if (entry.ws && event.total_cost_usd !== undefined) {
wsSend(entry.ws, { type: 'cost', costUsd: session?.totalCost || 0 });
}
break;
}
}
}
function processCodexEvent(entry, event, sessionId) {
if (!event || !event.type) return;
switch (event.type) {
case 'thread.started': {
if (!event.thread_id) break;
const session = loadSession(sessionId);
if (session) {
setRuntimeSessionId(session, event.thread_id);
saveSession(session);
}
break;
}
case 'item.started': {
const item = event.item;
if (!item || !item.id || item.type === 'agent_message') break;
ensureCodexToolCall(entry, item);
break;
}
case 'item.completed': {
const item = event.item;
if (!item || !item.id) break;
if (item.type === 'agent_message') {
if (item.text) {
entry.fullText += item.text;
wsSend(entry.ws, { type: 'text_delta', text: item.text });
}
break;
}
const tc = ensureCodexToolCall(entry, item);
const resultText = codexToolResult(item).slice(0, 2000);
tc.done = true;
tc.result = resultText;
wsSend(entry.ws, {
type: 'tool_end',
toolUseId: item.id,
result: resultText,
kind: tc.kind,
meta: tc.meta,
});
break;
}
case 'turn.completed': {
const usage = event.usage || null;
entry.lastUsage = usage;
const session = loadSession(sessionId);
if (session && usage) {
session.totalUsage = {
inputTokens: (session.totalUsage?.inputTokens || 0) + (usage.input_tokens || 0),
cachedInputTokens: (session.totalUsage?.cachedInputTokens || 0) + (usage.cached_input_tokens || 0),
outputTokens: (session.totalUsage?.outputTokens || 0) + (usage.output_tokens || 0),
};
saveSession(session);
wsSend(entry.ws, { type: 'usage', totalUsage: session.totalUsage });
}
break;
}
case 'turn.failed': {
const message = event.error?.message || 'Codex 任务失败';
entry.lastError = message;
break;
}
case 'error':
if (event.message) {
if (/^Reconnecting\.\.\./.test(event.message)) {
wsSend(entry.ws, { type: 'system_message', message: event.message });
} else {
entry.lastError = event.message;
}
}
break;
}
}
function processRuntimeEvent(entry, event, sessionId) {
if (entry.agent === 'codex') processCodexEvent(entry, event, sessionId);
else processClaudeEvent(entry, event, sessionId);
}
return {
buildClaudeSpawnSpec,
buildCodexSpawnSpec,
processClaudeEvent,
processCodexEvent,
processRuntimeEvent,
};
}
module.exports = { createAgentRuntime };