eventMapper.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. import { randomUUID } from 'node:crypto';
  2. import type { AgentEvent, AgentEventKind } from './types';
  3. /**
  4. * 移植自 Noobi.ai src/main/eventMapper.ts。
  5. * 删掉了整块 stage 推断(那是游戏流水线专用)与 planner/implementer/reviewer 角色标签,
  6. * 事件维度改成 threadId;也删掉了 imageGeneration / dynamicToolCall 这类 Noobi 自有素材工具。
  7. */
  8. const UNTHREADED = 'unthreaded';
  9. export function threadIdOf(notification: { params?: unknown }): string {
  10. const params = asRecord(notification.params);
  11. const turn = asRecord(params?.turn);
  12. const item = asRecord(params?.item);
  13. return (
  14. readString(params?.threadId) ?? readString(turn?.threadId) ?? readString(item?.threadId) ?? UNTHREADED
  15. );
  16. }
  17. export function notificationToEvent(notification: {
  18. method: string;
  19. params?: unknown;
  20. }): AgentEvent | null {
  21. const params = asRecord(notification.params) ?? {};
  22. const item = asRecord(params.item);
  23. const turn = asRecord(params.turn);
  24. const threadId = threadIdOf(notification);
  25. const turnId = readString(params.turnId) ?? readString(turn?.id) ?? 'turn';
  26. let itemId = readString(params.itemId) ?? readString(item?.id) ?? undefined;
  27. const method = notification.method;
  28. let kind: AgentEventKind = 'lifecycle';
  29. let title = 'Codex';
  30. let message = '';
  31. let isDelta = false;
  32. switch (method) {
  33. case 'item/agentMessage/delta':
  34. kind = 'assistant';
  35. title = '回复';
  36. message = readString(params.delta) ?? '';
  37. isDelta = true;
  38. break;
  39. case 'item/reasoning/summaryTextDelta':
  40. kind = 'thought';
  41. title = '思考摘要';
  42. message = readString(params.delta) ?? '';
  43. isDelta = true;
  44. break;
  45. case 'item/commandExecution/outputDelta':
  46. kind = 'tool';
  47. title = '命令输出';
  48. message = readString(params.delta) ?? '';
  49. isDelta = true;
  50. break;
  51. case 'turn/plan/updated':
  52. kind = 'plan';
  53. title = '计划更新';
  54. message = describe(params.plan ?? params);
  55. break;
  56. case 'item/fileChange/patchUpdated':
  57. kind = 'file';
  58. title = '文件变更';
  59. message = readString(params.patch) ?? readString(params.diff) ?? '正在生成补丁';
  60. isDelta = true;
  61. break;
  62. case 'item/started':
  63. case 'item/completed': {
  64. const type = readString(item?.type) ?? 'item';
  65. const presentation = describeItem(item, type);
  66. kind = presentation.kind;
  67. title = presentation.title;
  68. message = presentation.message || (method === 'item/completed' ? '已完成' : '已开始');
  69. break;
  70. }
  71. case 'turn/started':
  72. title = '回合开始';
  73. // 自部署端点在首字之前整段静默是正常现象,先把预期讲清楚,免得被当成卡死
  74. message = 'Codex 已开始处理当前任务;端点要把完整上下文重新 prefill,首字之前不会有任何输出';
  75. break;
  76. case 'thread/tokenUsage/updated': {
  77. // 形状取自 rust-v0.155.1 ServerNotification.json:
  78. // params.tokenUsage = { last, total, modelContextWindow:int64 },last/total 都是 TokenUsageBreakdown
  79. const usage = asRecord(params.tokenUsage);
  80. const last = asRecord(usage?.last) ?? asRecord(usage?.total);
  81. const input = readNumber(last?.inputTokens);
  82. const cached = readNumber(last?.cachedInputTokens);
  83. const output = readNumber(last?.outputTokens);
  84. const window = readNumber(usage?.modelContextWindow);
  85. kind = 'lifecycle';
  86. title = '上下文用量';
  87. itemId = 'tokenUsage';
  88. message =
  89. `本轮请求:输入 ${formatTokens(input)} tokens` +
  90. (cached ? `(命中缓存 ${formatTokens(cached)})` : '') +
  91. `|输出 ${formatTokens(output)} tokens` +
  92. (window ? `|上下文窗口 ${formatTokens(window)} tokens` : '');
  93. break;
  94. }
  95. case 'turn/completed':
  96. title = '回合结束';
  97. message = `状态:${readString(turn?.status) ?? 'completed'}`;
  98. break;
  99. case 'error':
  100. kind = 'error';
  101. title = '运行错误';
  102. message = readString(params.message) ?? readString(asRecord(params.error)?.message) ?? describe(params);
  103. break;
  104. case 'warning':
  105. case 'configWarning':
  106. kind = 'error';
  107. title = '警告';
  108. message = readString(params.message) ?? describe(params);
  109. break;
  110. default:
  111. return null;
  112. }
  113. if (!message) return null;
  114. return {
  115. id: itemId ? `${threadId}:${turnId}:${itemId}:${kind}` : randomUUID(),
  116. threadId,
  117. turnId,
  118. kind,
  119. title,
  120. message: clip(message),
  121. timestamp: new Date().toISOString(),
  122. method,
  123. ...(itemId ? { itemId } : {}),
  124. ...(isDelta ? { isDelta: true } : {}),
  125. };
  126. }
  127. function describeItem(
  128. item: Record<string, unknown> | null,
  129. type: string,
  130. ): { kind: AgentEventKind; title: string; message: string } {
  131. if (!item) return { kind: 'lifecycle', title: type, message: '' };
  132. switch (type) {
  133. case 'agentMessage':
  134. return { kind: 'assistant', title: '回复', message: readString(item.text) ?? '' };
  135. case 'reasoning':
  136. return {
  137. kind: 'thought',
  138. title: '推理摘要',
  139. message: readTextArray(item.summary) || readTextArray(item.content),
  140. };
  141. case 'commandExecution':
  142. return {
  143. kind: 'tool',
  144. title: '执行命令',
  145. message: readString(item.command) ?? describe(item.commandActions ?? item),
  146. };
  147. case 'fileChange':
  148. return { kind: 'file', title: '修改文件', message: describe(item.changes ?? item) };
  149. case 'mcpToolCall':
  150. return {
  151. kind: 'tool',
  152. title: `工具 ${cleanToolName(readString(item.tool))}`.trim(),
  153. message: describe(item.arguments ?? item.result ?? item),
  154. };
  155. case 'plan':
  156. return { kind: 'plan', title: '计划', message: describe(item) };
  157. default:
  158. return { kind: 'lifecycle', title: type, message: describe(item) };
  159. }
  160. }
  161. function readTextArray(value: unknown): string {
  162. if (!Array.isArray(value)) return '';
  163. return value
  164. .map((entry) => (typeof entry === 'string' ? entry : readString(asRecord(entry)?.text) ?? ''))
  165. .filter(Boolean)
  166. .join('\n');
  167. }
  168. function describe(value: unknown): string {
  169. if (typeof value === 'string') return value;
  170. try {
  171. return JSON.stringify(value, null, 2);
  172. } catch {
  173. return String(value);
  174. }
  175. }
  176. function clip(value: string, maxLength = 24_000): string {
  177. return value.length > maxLength ? `${value.slice(0, maxLength)}\n…(已截断)` : value;
  178. }
  179. function cleanToolName(value: string | null): string {
  180. return value?.replace(/[^A-Za-z0-9_.-]/gu, '').slice(0, 128) ?? '';
  181. }
  182. function asRecord(value: unknown): Record<string, unknown> | null {
  183. return value && typeof value === 'object' && !Array.isArray(value)
  184. ? (value as Record<string, unknown>)
  185. : null;
  186. }
  187. function readString(value: unknown): string | null {
  188. return typeof value === 'string' ? value : null;
  189. }
  190. function readNumber(value: unknown): number | null {
  191. return typeof value === 'number' && Number.isFinite(value) ? value : null;
  192. }
  193. /** 46122 → "4.6 万":给页面读的,不是给机器算的 */
  194. function formatTokens(value: number | null): string {
  195. if (value === null) return '未知';
  196. if (value >= 10_000) return `${(value / 10_000).toFixed(1)} 万`;
  197. return String(value);
  198. }