runResultReplay.js 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. /**
  2. * 运行记录回放 utils:从持久化的 logs JSON 中还原运行时态数据。
  3. *
  4. * 后端 HermesBridgeClient.java 在 SSE 推送的同时,已将所有事件持久化到
  5. * WorkflowRunNode.logs(JSON 数组)。前端运行记录页从这里还原:
  6. * - streamingThinking:拼接所有 THINKING 日志的 message
  7. * - toolHistory:从 TOOL_CALL / TOOL_RESULT 按时间顺序配对
  8. * - todos:取最后一条 TODO_UPDATE 的 detail(JSON 字符串),兼容数组 / {todos: [...]}
  9. * - nonThinkingLogs:过滤掉 THINKING 类型后的日志列表(用于执行日志面板)
  10. *
  11. * 这样运行记录页能完整复现编辑器"运行结果"Tab 中除"实时态"外的所有内容。
  12. */
  13. /**
  14. * 安全解析 JSON 字符串,失败返回 null。
  15. */
  16. function parseJson(str) {
  17. if (!str) return null
  18. try { return JSON.parse(str) } catch { return null }
  19. }
  20. /**
  21. * 从日志数组中提取思考过程(THINKING 类型日志的 message 拼接)。
  22. * @param {string} logsJson 节点的 logs 字段(JSON 字符串)
  23. * @returns {string} 完整思考过程文本;无则返回空串
  24. */
  25. export function extractStreamingThinking(logsJson) {
  26. const logs = parseJson(logsJson)
  27. if (!Array.isArray(logs)) return ''
  28. return logs
  29. .filter(l => l.type === 'THINKING')
  30. .map(l => l.message || '')
  31. .join('')
  32. }
  33. /**
  34. * 从日志数组中还原工具调用历史(按时间顺序的 [{name, argsSummary, result}])。
  35. *
  36. * 配对规则:
  37. * - TOOL_CALL 日志:message=toolName,detail=argsSummary
  38. * - TOOL_RESULT 日志:message=toolName,detail=resultSummary
  39. * - 相邻的 TOOL_CALL → TOOL_RESULT 配对一个完整工具调用
  40. * - 若 TOOL_CALL 后跟另一个 TOOL_CALL(无 result),仍作为一条记录入栈,result=null
  41. *
  42. * @param {string} logsJson 节点的 logs 字段(JSON 字符串)
  43. * @returns {Array<{name:string, argsSummary:string, result:string|null}>}
  44. */
  45. export function extractToolHistory(logsJson) {
  46. const logs = parseJson(logsJson)
  47. if (!Array.isArray(logs)) return []
  48. const history = []
  49. let pending = null // 等待 result 的 tool_call
  50. for (const log of logs) {
  51. if (log.type === 'TOOL_CALL') {
  52. // 上一个 tool_call 没 result,直接入栈
  53. if (pending) {
  54. history.push(pending)
  55. }
  56. pending = {
  57. name: log.message || '(unknown)',
  58. argsSummary: log.detail || '',
  59. result: null,
  60. }
  61. } else if (log.type === 'TOOL_RESULT') {
  62. if (pending) {
  63. pending.result = log.detail || ''
  64. history.push(pending)
  65. pending = null
  66. } else {
  67. // 异常:tool_result 无对应 tool_call,作为孤儿记录
  68. history.push({
  69. name: log.message || '(unknown)',
  70. argsSummary: '',
  71. result: log.detail || '',
  72. })
  73. }
  74. }
  75. }
  76. // 兜底:最后一个未配对的 tool_call
  77. if (pending) {
  78. history.push(pending)
  79. }
  80. // 编辑器实时展示是 unshift 顺序(最新在前),保持一致
  81. return history.reverse()
  82. }
  83. /**
  84. * 从日志数组中取最后一条 TODO_UPDATE,解析出 todos 数组。
  85. * 兼容两种格式:
  86. * - 数组:[{content, status, ...}, ...]
  87. * - 对象:{todos: [...], summary: {...}}(Hermes 内置 todo_tool 实际返回结构)
  88. *
  89. * @param {string} logsJson 节点的 logs 字段(JSON 字符串)
  90. * @returns {Array} todos 数组;无则返回空数组
  91. */
  92. export function extractLatestTodos(logsJson) {
  93. const logs = parseJson(logsJson)
  94. if (!Array.isArray(logs)) return []
  95. // 从后往前找第一条 TODO_UPDATE
  96. for (let i = logs.length - 1; i >= 0; i--) {
  97. if (logs[i].type === 'TODO_UPDATE') {
  98. const detail = logs[i].detail || ''
  99. try {
  100. const parsed = JSON.parse(detail)
  101. const arr = Array.isArray(parsed) ? parsed : (parsed?.todos || [])
  102. return Array.isArray(arr) ? arr : []
  103. } catch { /* ignore */ }
  104. }
  105. }
  106. return []
  107. }
  108. /**
  109. * 过滤掉 THINKING 类型日志:思考过程已在 .nrc-thinking 集中展示,
  110. * 执行日志面板再显示会造成内容重复。
  111. * @param {string} logsJson 节点的 logs 字段(JSON 字符串)
  112. * @returns {Array} 非 THINKING 类型的日志数组
  113. */
  114. export function extractNonThinkingLogs(logsJson) {
  115. const logs = parseJson(logsJson)
  116. if (!Array.isArray(logs)) return []
  117. return logs.filter(l => l.type !== 'THINKING')
  118. }
  119. /**
  120. * 将节点(来自后端 WorkflowRunNode)转换为编辑器 nodeResults 中的节点格式。
  121. *
  122. * 用于让运行记录页直接复用 AgentThinkingPanel 等组件:
  123. * - status: 后端返回的是大写(SUCCESS/FAILED/...),转换为小写以匹配组件判断
  124. * - streamingThinking / toolHistory / todos 从 logs 还原
  125. * - currentStatus / currentTool 留空(节点已完成,无实时态)
  126. *
  127. * @param {Object} node 后端 WorkflowRunNode(含 logs / output / contextSnapshot)
  128. * @returns {Object} 编辑器节点格式
  129. */
  130. export function buildReplayNode(node) {
  131. const logsJson = node?.logs || ''
  132. return {
  133. nodeId: node?.nodeId,
  134. type: node?.nodeType,
  135. label: node?.label || node?.nodeId || '节点',
  136. status: (node?.status || '').toLowerCase(),
  137. output: node?.output ? parseJson(node.output) : null,
  138. logs: extractNonThinkingLogs(logsJson),
  139. contextSnapshot: node?.contextSnapshot ? parseJson(node.contextSnapshot) : null,
  140. error: node?.error || '',
  141. // 从持久化 logs 还原的运行时态
  142. streamingThinking: extractStreamingThinking(logsJson),
  143. toolHistory: extractToolHistory(logsJson),
  144. todos: extractLatestTodos(logsJson),
  145. // 已完成节点无实时态
  146. currentStatus: '',
  147. currentTool: null,
  148. }
  149. }