import { randomUUID } from 'node:crypto'; import type { AgentEvent, AgentEventKind } from './types'; /** * 移植自 Noobi.ai src/main/eventMapper.ts。 * 删掉了整块 stage 推断(那是游戏流水线专用)与 planner/implementer/reviewer 角色标签, * 事件维度改成 threadId;也删掉了 imageGeneration / dynamicToolCall 这类 Noobi 自有素材工具。 */ const UNTHREADED = 'unthreaded'; export function threadIdOf(notification: { params?: unknown }): string { const params = asRecord(notification.params); const turn = asRecord(params?.turn); const item = asRecord(params?.item); return ( readString(params?.threadId) ?? readString(turn?.threadId) ?? readString(item?.threadId) ?? UNTHREADED ); } export function notificationToEvent(notification: { method: string; params?: unknown; }): AgentEvent | null { const params = asRecord(notification.params) ?? {}; const item = asRecord(params.item); const turn = asRecord(params.turn); const threadId = threadIdOf(notification); const turnId = readString(params.turnId) ?? readString(turn?.id) ?? 'turn'; let itemId = readString(params.itemId) ?? readString(item?.id) ?? undefined; const method = notification.method; let kind: AgentEventKind = 'lifecycle'; let title = 'Codex'; let message = ''; let isDelta = false; switch (method) { case 'item/agentMessage/delta': kind = 'assistant'; title = '回复'; message = readString(params.delta) ?? ''; isDelta = true; break; case 'item/reasoning/summaryTextDelta': kind = 'thought'; title = '思考摘要'; message = readString(params.delta) ?? ''; isDelta = true; break; case 'item/commandExecution/outputDelta': kind = 'tool'; title = '命令输出'; message = readString(params.delta) ?? ''; isDelta = true; break; case 'turn/plan/updated': kind = 'plan'; title = '计划更新'; message = describe(params.plan ?? params); break; case 'item/fileChange/patchUpdated': kind = 'file'; title = '文件变更'; message = readString(params.patch) ?? readString(params.diff) ?? '正在生成补丁'; isDelta = true; break; case 'item/started': case 'item/completed': { const type = readString(item?.type) ?? 'item'; const presentation = describeItem(item, type); kind = presentation.kind; title = presentation.title; message = presentation.message || (method === 'item/completed' ? '已完成' : '已开始'); break; } case 'turn/started': title = '回合开始'; // 自部署端点在首字之前整段静默是正常现象,先把预期讲清楚,免得被当成卡死 message = 'Codex 已开始处理当前任务;端点要把完整上下文重新 prefill,首字之前不会有任何输出'; break; case 'thread/tokenUsage/updated': { // 形状取自 rust-v0.155.1 ServerNotification.json: // params.tokenUsage = { last, total, modelContextWindow:int64 },last/total 都是 TokenUsageBreakdown const usage = asRecord(params.tokenUsage); const last = asRecord(usage?.last) ?? asRecord(usage?.total); const input = readNumber(last?.inputTokens); const cached = readNumber(last?.cachedInputTokens); const output = readNumber(last?.outputTokens); const window = readNumber(usage?.modelContextWindow); kind = 'lifecycle'; title = '上下文用量'; itemId = 'tokenUsage'; message = `本轮请求:输入 ${formatTokens(input)} tokens` + (cached ? `(命中缓存 ${formatTokens(cached)})` : '') + `|输出 ${formatTokens(output)} tokens` + (window ? `|上下文窗口 ${formatTokens(window)} tokens` : ''); break; } case 'turn/completed': title = '回合结束'; message = `状态:${readString(turn?.status) ?? 'completed'}`; break; case 'error': kind = 'error'; title = '运行错误'; message = readString(params.message) ?? readString(asRecord(params.error)?.message) ?? describe(params); break; case 'warning': case 'configWarning': kind = 'error'; title = '警告'; message = readString(params.message) ?? describe(params); break; default: return null; } if (!message) return null; return { id: itemId ? `${threadId}:${turnId}:${itemId}:${kind}` : randomUUID(), threadId, turnId, kind, title, message: clip(message), timestamp: new Date().toISOString(), method, ...(itemId ? { itemId } : {}), ...(isDelta ? { isDelta: true } : {}), }; } function describeItem( item: Record | null, type: string, ): { kind: AgentEventKind; title: string; message: string } { if (!item) return { kind: 'lifecycle', title: type, message: '' }; switch (type) { case 'agentMessage': return { kind: 'assistant', title: '回复', message: readString(item.text) ?? '' }; case 'reasoning': return { kind: 'thought', title: '推理摘要', message: readTextArray(item.summary) || readTextArray(item.content), }; case 'commandExecution': return { kind: 'tool', title: '执行命令', message: readString(item.command) ?? describe(item.commandActions ?? item), }; case 'fileChange': return { kind: 'file', title: '修改文件', message: describe(item.changes ?? item) }; case 'mcpToolCall': return { kind: 'tool', title: `工具 ${cleanToolName(readString(item.tool))}`.trim(), message: describe(item.arguments ?? item.result ?? item), }; case 'plan': return { kind: 'plan', title: '计划', message: describe(item) }; default: return { kind: 'lifecycle', title: type, message: describe(item) }; } } function readTextArray(value: unknown): string { if (!Array.isArray(value)) return ''; return value .map((entry) => (typeof entry === 'string' ? entry : readString(asRecord(entry)?.text) ?? '')) .filter(Boolean) .join('\n'); } function describe(value: unknown): string { if (typeof value === 'string') return value; try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function clip(value: string, maxLength = 24_000): string { return value.length > maxLength ? `${value.slice(0, maxLength)}\n…(已截断)` : value; } function cleanToolName(value: string | null): string { return value?.replace(/[^A-Za-z0-9_.-]/gu, '').slice(0, 128) ?? ''; } function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function readString(value: unknown): string | null { return typeof value === 'string' ? value : null; } function readNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } /** 46122 → "4.6 万":给页面读的,不是给机器算的 */ function formatTokens(value: number | null): string { if (value === null) return '未知'; if (value >= 10_000) return `${(value / 10_000).toFixed(1)} 万`; return String(value); }