Files
cc-web/lib/codex-rollouts.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

206 lines
7.2 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function createCodexRolloutStore(deps) {
const { codexSessionsDir, sessionsDir, normalizeSession, sanitizeToolInput } = deps;
function extractCodexMessageText(content) {
if (!Array.isArray(content)) return '';
return content
.filter((item) => item && (item.type === 'input_text' || item.type === 'output_text'))
.map((item) => item.text || '')
.join('');
}
function appendAssistantContent(turn, text) {
if (!turn || !text || !text.trim()) return;
turn.content = turn.content ? `${turn.content}\n\n${text}` : text;
}
function parseCodexRolloutLines(lines) {
const messages = [];
const pendingToolCalls = new Map();
const meta = { threadId: null, cwd: null, title: '', updatedAt: null, cliVersion: null, source: null };
const totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
let currentAssistant = null;
let sawRealUserMessage = false;
const fallbackUserMessages = [];
function ensureAssistant(ts) {
if (!currentAssistant) {
currentAssistant = { role: 'assistant', content: '', toolCalls: [], timestamp: ts || null };
} else if (!currentAssistant.timestamp && ts) {
currentAssistant.timestamp = ts;
}
return currentAssistant;
}
function flushAssistant() {
if (!currentAssistant) return;
if ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0) {
messages.push(currentAssistant);
}
currentAssistant = null;
pendingToolCalls.clear();
}
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let entry;
try { entry = JSON.parse(trimmed); } catch { continue; }
const ts = entry.timestamp || null;
if (ts) meta.updatedAt = ts;
if (entry.type === 'session_meta') {
meta.threadId = entry.payload?.id || meta.threadId;
meta.cwd = entry.payload?.cwd || meta.cwd;
meta.cliVersion = entry.payload?.cli_version || meta.cliVersion;
meta.source = entry.payload?.source || meta.source;
continue;
}
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') {
const total = entry.payload?.info?.total_token_usage || null;
const usage = entry.payload?.info?.last_token_usage || null;
if (total) {
totalUsage.inputTokens = Math.max(totalUsage.inputTokens, total.input_tokens || 0);
totalUsage.cachedInputTokens = Math.max(totalUsage.cachedInputTokens, total.cached_input_tokens || 0);
totalUsage.outputTokens = Math.max(totalUsage.outputTokens, total.output_tokens || 0);
} else if (usage) {
totalUsage.inputTokens += usage.input_tokens || 0;
totalUsage.cachedInputTokens += usage.cached_input_tokens || 0;
totalUsage.outputTokens += usage.output_tokens || 0;
}
continue;
}
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
const text = String(entry.payload?.message || '').trim();
if (text) {
sawRealUserMessage = true;
flushAssistant();
if (!meta.title) meta.title = text.slice(0, 80).replace(/\n/g, ' ');
messages.push({ role: 'user', content: text, timestamp: ts });
}
continue;
}
if (entry.type !== 'response_item') continue;
const payload = entry.payload || {};
switch (payload.type) {
case 'message': {
if (payload.role === 'assistant') {
const text = extractCodexMessageText(payload.content);
if (text.trim()) {
if (currentAssistant && ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0)) {
flushAssistant();
}
appendAssistantContent(ensureAssistant(ts), text);
}
} else if (payload.role === 'user' && !sawRealUserMessage) {
const text = extractCodexMessageText(payload.content);
if (text.trim()) {
fallbackUserMessages.push({ role: 'user', content: text, timestamp: ts });
}
}
break;
}
case 'function_call': {
const assistant = ensureAssistant(ts);
const toolUseId = payload.call_id || payload.id || crypto.randomUUID();
const tc = {
name: payload.name || 'FunctionCall',
id: toolUseId,
input: sanitizeToolInput(payload.name || 'FunctionCall', payload.arguments || ''),
done: false,
};
assistant.toolCalls.push(tc);
pendingToolCalls.set(toolUseId, tc);
break;
}
case 'function_call_output': {
const assistant = ensureAssistant(ts);
const toolUseId = payload.call_id || crypto.randomUUID();
let tc = pendingToolCalls.get(toolUseId);
if (!tc) {
tc = { name: 'FunctionCall', id: toolUseId, input: null, done: false };
assistant.toolCalls.push(tc);
pendingToolCalls.set(toolUseId, tc);
}
tc.done = true;
tc.result = (typeof payload.output === 'string'
? payload.output
: JSON.stringify(payload.output || '')).slice(0, 2000);
break;
}
default:
break;
}
}
flushAssistant();
if (!sawRealUserMessage && fallbackUserMessages.length > 0) {
const fallback = fallbackUserMessages[0];
if (!meta.title) meta.title = fallback.content.trim().slice(0, 80).replace(/\n/g, ' ');
return { meta, messages: fallbackUserMessages.concat(messages), totalUsage };
}
return { meta, messages, totalUsage };
}
function walkFiles(dir, files = []) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return files;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) walkFiles(fullPath, files);
else if (entry.isFile()) files.push(fullPath);
}
return files;
}
function getCodexRolloutFiles() {
if (!fs.existsSync(codexSessionsDir)) return [];
return walkFiles(codexSessionsDir, []).filter((filePath) => filePath.endsWith('.jsonl')).sort().reverse();
}
function getImportedCodexThreadIds() {
const imported = new Set();
try {
for (const f of fs.readdirSync(sessionsDir).filter((name) => name.endsWith('.json'))) {
try {
const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf8')));
if (session.codexThreadId) imported.add(session.codexThreadId);
} catch {}
}
} catch {}
return imported;
}
function parseCodexRolloutFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const parsed = parseCodexRolloutLines(content.split('\n'));
parsed.filePath = filePath;
return parsed;
} catch {
return null;
}
}
return {
parseCodexRolloutLines,
getCodexRolloutFiles,
getImportedCodexThreadIds,
parseCodexRolloutFile,
};
}
module.exports = { createCodexRolloutStore };