/** * 运行记录回放 utils:从持久化的 logs JSON 中还原运行时态数据。 * * 后端 HermesBridgeClient.java 在 SSE 推送的同时,已将所有事件持久化到 * WorkflowRunNode.logs(JSON 数组)。前端运行记录页从这里还原: * - streamingThinking:拼接所有 THINKING 日志的 message * - toolHistory:从 TOOL_CALL / TOOL_RESULT 按时间顺序配对 * - todos:取最后一条 TODO_UPDATE 的 detail(JSON 字符串),兼容数组 / {todos: [...]} * - nonThinkingLogs:过滤掉 THINKING 类型后的日志列表(用于执行日志面板) * * 这样运行记录页能完整复现编辑器"运行结果"Tab 中除"实时态"外的所有内容。 */ /** * 安全解析 JSON 字符串,失败返回 null。 */ function parseJson(str) { if (!str) return null try { return JSON.parse(str) } catch { return null } } /** * 从日志数组中提取思考过程(THINKING 类型日志的 message 拼接)。 * @param {string} logsJson 节点的 logs 字段(JSON 字符串) * @returns {string} 完整思考过程文本;无则返回空串 */ export function extractStreamingThinking(logsJson) { const logs = parseJson(logsJson) if (!Array.isArray(logs)) return '' return logs .filter(l => l.type === 'THINKING') .map(l => l.message || '') .join('') } /** * 从日志数组中还原工具调用历史(按时间顺序的 [{name, argsSummary, result}])。 * * 配对规则: * - TOOL_CALL 日志:message=toolName,detail=argsSummary * - TOOL_RESULT 日志:message=toolName,detail=resultSummary * - 相邻的 TOOL_CALL → TOOL_RESULT 配对一个完整工具调用 * - 若 TOOL_CALL 后跟另一个 TOOL_CALL(无 result),仍作为一条记录入栈,result=null * * @param {string} logsJson 节点的 logs 字段(JSON 字符串) * @returns {Array<{name:string, argsSummary:string, result:string|null}>} */ export function extractToolHistory(logsJson) { const logs = parseJson(logsJson) if (!Array.isArray(logs)) return [] const history = [] let pending = null // 等待 result 的 tool_call for (const log of logs) { if (log.type === 'TOOL_CALL') { // 上一个 tool_call 没 result,直接入栈 if (pending) { history.push(pending) } pending = { name: log.message || '(unknown)', argsSummary: log.detail || '', result: null, } } else if (log.type === 'TOOL_RESULT') { if (pending) { pending.result = log.detail || '' history.push(pending) pending = null } else { // 异常:tool_result 无对应 tool_call,作为孤儿记录 history.push({ name: log.message || '(unknown)', argsSummary: '', result: log.detail || '', }) } } } // 兜底:最后一个未配对的 tool_call if (pending) { history.push(pending) } // 编辑器实时展示是 unshift 顺序(最新在前),保持一致 return history.reverse() } /** * 从日志数组中取最后一条 TODO_UPDATE,解析出 todos 数组。 * 兼容两种格式: * - 数组:[{content, status, ...}, ...] * - 对象:{todos: [...], summary: {...}}(Hermes 内置 todo_tool 实际返回结构) * * @param {string} logsJson 节点的 logs 字段(JSON 字符串) * @returns {Array} todos 数组;无则返回空数组 */ export function extractLatestTodos(logsJson) { const logs = parseJson(logsJson) if (!Array.isArray(logs)) return [] // 从后往前找第一条 TODO_UPDATE for (let i = logs.length - 1; i >= 0; i--) { if (logs[i].type === 'TODO_UPDATE') { const detail = logs[i].detail || '' try { const parsed = JSON.parse(detail) const arr = Array.isArray(parsed) ? parsed : (parsed?.todos || []) return Array.isArray(arr) ? arr : [] } catch { /* ignore */ } } } return [] } /** * 过滤掉 THINKING 类型日志:思考过程已在 .nrc-thinking 集中展示, * 执行日志面板再显示会造成内容重复。 * @param {string} logsJson 节点的 logs 字段(JSON 字符串) * @returns {Array} 非 THINKING 类型的日志数组 */ export function extractNonThinkingLogs(logsJson) { const logs = parseJson(logsJson) if (!Array.isArray(logs)) return [] return logs.filter(l => l.type !== 'THINKING') } /** * 将节点(来自后端 WorkflowRunNode)转换为编辑器 nodeResults 中的节点格式。 * * 用于让运行记录页直接复用 AgentThinkingPanel 等组件: * - status: 后端返回的是大写(SUCCESS/FAILED/...),转换为小写以匹配组件判断 * - streamingThinking / toolHistory / todos 从 logs 还原 * - currentStatus / currentTool 留空(节点已完成,无实时态) * * @param {Object} node 后端 WorkflowRunNode(含 logs / output / contextSnapshot) * @returns {Object} 编辑器节点格式 */ export function buildReplayNode(node) { const logsJson = node?.logs || '' return { nodeId: node?.nodeId, type: node?.nodeType, label: node?.label || node?.nodeId || '节点', status: (node?.status || '').toLowerCase(), output: node?.output ? parseJson(node.output) : null, logs: extractNonThinkingLogs(logsJson), contextSnapshot: node?.contextSnapshot ? parseJson(node.contextSnapshot) : null, error: node?.error || '', // 从持久化 logs 还原的运行时态 streamingThinking: extractStreamingThinking(logsJson), toolHistory: extractToolHistory(logsJson), todos: extractLatestTodos(logsJson), // 已完成节点无实时态 currentStatus: '', currentTool: null, } }