| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- 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<string, unknown> | 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<string, unknown> | null {
- return value && typeof value === 'object' && !Array.isArray(value)
- ? (value as Record<string, unknown>)
- : 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);
- }
|