useWorkflowRunner.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  1. import { ref, reactive } from 'vue'
  2. import { useMessage } from 'naive-ui'
  3. import { runWorkflow, reconnectRunStream, getRunState, submitClarifyAnswer } from '../api/workflow'
  4. import { stripHermesThinkingDecoration } from '../utils/hermesThinking'
  5. import { logTypeLabel as sharedLogTypeLabel } from '../utils/logLabels'
  6. import { parseVisitDecision } from '../utils/visitChain'
  7. /**
  8. * 判断对象是否为节点输出 envelope `{status, message, data}`。
  9. * 旧版直接输出 Map 的记录没有 status/data 字段,返回 false 以走兼容分支。
  10. */
  11. export function isEnvelope(o) {
  12. return !!(o && typeof o === 'object' && 'status' in o && 'data' in o)
  13. }
  14. /**
  15. * 解包节点输出:envelope 返回 data,否则原样返回。
  16. * 兜底 data 缺失时返回空对象,避免下游 v-for 报错。
  17. */
  18. export function unwrapNodeOutput(o) {
  19. return isEnvelope(o) ? (o.data || {}) : (o || {})
  20. }
  21. /**
  22. * 获取节点输出 envelope.status(200=成功,400=失败)。
  23. * 非 envelope 返回 null(视为成功)。
  24. */
  25. export function nodeOutputStatus(o) {
  26. return isEnvelope(o) ? o.status : null
  27. }
  28. /**
  29. * 获取节点输出 envelope.message(含失败原因、重试次数等提示)。
  30. * 非 envelope 返回空串。
  31. */
  32. export function nodeOutputMessage(o) {
  33. return isEnvelope(o) ? (o.message || '') : ''
  34. }
  35. /**
  36. * 工作流运行时 composable
  37. * 抽取自 WorkflowEditor.vue,承担 SSE 流解析、节点状态更新、运行结果管理
  38. *
  39. * <p>v2.0 卡片统一:DAG / AGENTIC 模式均使用 Visit 作为单一卡片数据源。
  40. * 所有 SSE 事件(node_status / node_stream / visit_recorded)按 visitUuid 路由到 visits 数组;
  41. * 同节点多次访问时各 visit 拥有独立 visitUuid,互不冲突。</p>
  42. *
  43. * @param {Object} options
  44. * @param {import('vue').Ref} options.getNodes 画布节点(用于按 nodeId 找节点 + 设置 class)
  45. * @param {Object} options.nodeTypeInfo 节点类型 → { color, typeLabel } 映射
  46. * @param {import('vue').Ref} options.workflowIdRef 当前工作流 name 或 id(仅用于 lastRunWorkflowId 记录)
  47. */
  48. export function useWorkflowRunner({ getNodes, nodeTypeInfo, workflowIdRef }) {
  49. const message = useMessage()
  50. const running = ref(false)
  51. const showRunDialog = ref(false)
  52. const showResult = ref(false)
  53. const workflowResult = ref(null)
  54. /**
  55. * 当前运行元数据 { runId, workingDirFiles }。
  56. * 这两个字段不再注入 output 节点 envelope,而是作为 workflow-run 的属性由后端 SSE 推送 /
  57. * 持久化到 WorkflowRun 实体。前端在"下载工作空间"按钮旁独立展示,与下载入口出现时机一致。
  58. */
  59. const runMeta = ref(null)
  60. // 画布节点状态(按 nodeId 索引,用于画布节点着色 / 徽章)
  61. const nodeStatuses = reactive({})
  62. const activeTab = ref('props')
  63. /**
  64. * v2.0:统一卡片列表(DAG / AGENTIC 模式均使用)。
  65. * 每个 visit 携带完整执行上下文:
  66. * - 元数据:visitUuid / nodeId / nodeType / nodeLabel / iterSeq / fromVisitUuid / fromNodeId / sortOrder
  67. * - 结果:status / output / logs / contextSnapshot / variablesJson / outputJson / errorMsg
  68. * - 流式:streamingThinking / currentStatus / currentTool / toolHistory / todos
  69. * - UI:color / typeLabel
  70. */
  71. const visits = reactive([])
  72. const expandedContext = reactive({})
  73. // 思考过程折叠状态:key=visitUuid,value=true/false。未设置默认折叠(避免运行结果页过长)。
  74. // 折叠时仅隐藏 UI,streamingThinking 仍持续累加,展开后可继续看到完整内容。
  75. const expandedThinking = reactive({})
  76. // 执行日志折叠状态:同 expandedThinking。Visit running 时强制展开,完成时自动收起(清空 key)。
  77. const expandedLogs = reactive({})
  78. const lastRunId = ref(null)
  79. const lastRunWorkflowId = ref(null)
  80. // v1.1:断线重连相关状态
  81. // lastEventId:从 SSE 帧的 id: 字段提取,用于断线重连续接
  82. // reconnecting:是否正在重连(控制 UI 提示)
  83. const lastEventId = ref(null)
  84. const reconnecting = ref(false)
  85. // 重连失败次数上限:超过则退化为 GET /state 轮询
  86. const MAX_RECONNECT_ATTEMPTS = 5
  87. // v1.1:全局唯一 Askfor 暂停点 {clarifyId, question, choices}
  88. const pendingAsk = ref(null)
  89. // v1.1:轮询定时器(重连耗尽后启动)
  90. const pollTimer = ref(null)
  91. function resetState() {
  92. Object.keys(nodeStatuses).forEach(k => delete nodeStatuses[k])
  93. visits.splice(0, visits.length)
  94. }
  95. function clearNodeStatusClasses() {
  96. getNodes.value.forEach(n => { n.class = '' })
  97. }
  98. /**
  99. * 启动工作流运行
  100. * @param {string|number} nameOrId 工作流 name(kebab-case)或旧数字 id
  101. * @param {Object} inputs 表单输入值
  102. * @param {Array<{file:File, relativePath:string}>} uploadFiles 上传文件列表
  103. */
  104. async function startRun(nameOrId, inputs, uploadFiles) {
  105. showRunDialog.value = false
  106. running.value = true
  107. showResult.value = false
  108. workflowResult.value = null
  109. runMeta.value = null
  110. resetState()
  111. clearNodeStatusClasses()
  112. // 清空折叠状态(不影响本轮首次默认展开)
  113. Object.keys(expandedThinking).forEach(k => delete expandedThinking[k])
  114. Object.keys(expandedLogs).forEach(k => delete expandedLogs[k])
  115. // 重置断线重连状态
  116. lastEventId.value = null
  117. reconnecting.value = false
  118. // H-7:清理上一轮可能残留的轮询定时器(用户未关闭结果页直接发起下次运行时,旧 pollTimer 会与新 run 并存)
  119. if (pollTimer.value) {
  120. clearInterval(pollTimer.value)
  121. pollTimer.value = null
  122. }
  123. // 启动时单次切到「执行结果」Tab;后续不再强制切换,允许用户自由回到「节点属性」
  124. activeTab.value = 'result'
  125. let response
  126. try {
  127. response = await runWorkflow(
  128. nameOrId,
  129. inputs,
  130. uploadFiles.map(f => f.file),
  131. uploadFiles.map(f => f.relativePath)
  132. )
  133. if (!response.ok) throw new Error(`HTTP ${response.status}`)
  134. } catch (e) {
  135. message.error('工作流启动失败: ' + e.message)
  136. running.value = false
  137. return
  138. }
  139. // 进入 SSE 消费循环(H-6/H-7:改为迭代式 runSSELoop,避免递归导致的重连计数重置与栈累积)
  140. await runSSELoop(response)
  141. }
  142. /**
  143. * H-6/H-7:消费单段 SSE 流(不递归、不自动重连)。
  144. * 只负责:解析事件 → 调 handleSSEEvent → 返回 { completed, receivedAny }。
  145. * 重连策略由 runSSELoop 统一管理,避免原 consumeSSEStream → tryReconnect → consumeSSEStream
  146. * 递归调用导致的 attempts 计数重置(每次递归都从 0 开始,实际重连次数无上限)与栈累积。
  147. *
  148. * @returns {{ completed: boolean, receivedAny: boolean }}
  149. * completed=true 表示工作流已终止或不可重连(外层循环应退出)
  150. * receivedAny=true 表示曾收到至少一个事件(连接健康,外层应清零重连计数)
  151. */
  152. async function consumeSSEStream(response) {
  153. const reader = response.body.getReader()
  154. const decoder = new TextDecoder()
  155. let buffer = ''
  156. let receivedAny = false
  157. try {
  158. while (true) {
  159. const { done, value } = await reader.read()
  160. if (done) break
  161. buffer += decoder.decode(value, { stream: true })
  162. const parts = buffer.split('\n\n')
  163. buffer = parts.pop()
  164. for (const part of parts) {
  165. const lines = part.split('\n')
  166. let data = null
  167. let eventId = null
  168. for (const line of lines) {
  169. if (line.startsWith('id:')) {
  170. eventId = line.substring(3).trim()
  171. } else if (line.startsWith('data:')) {
  172. try { data = JSON.parse(line.substring(5).trim()) } catch { /* skip */ }
  173. }
  174. }
  175. if (eventId != null) {
  176. lastEventId.value = Number(eventId)
  177. receivedAny = true
  178. }
  179. // comment 帧心跳(":keep-alive")已被 split('\n\n') 切割掉,正常忽略
  180. if (data) handleSSEEvent(data)
  181. }
  182. }
  183. // 流正常结束:可能后端早完成但 SSE 提前断,由 runSSELoop 决定是否重连
  184. return { completed: isTerminal() || !lastRunId.value, receivedAny }
  185. } catch (e) {
  186. // reader.read() 抛错:若已无 runId 或已终止,直接当作完成;否则交回 runSSELoop 重连
  187. if (!lastRunId.value || isTerminal()) {
  188. message.error('工作流执行失败: ' + e.message)
  189. return { completed: true, receivedAny }
  190. }
  191. return { completed: false, receivedAny }
  192. }
  193. }
  194. /**
  195. * 判断当前工作流是否已终止(complete / error),用于决定是否值得重连。
  196. */
  197. function isTerminal() {
  198. return showResult.value || workflowResult.value !== null
  199. }
  200. /**
  201. * H-6/H-7:迭代式 SSE 消费 + 重连驱动。
  202. */
  203. async function runSSELoop(initialResponse) {
  204. let response = initialResponse
  205. let attempts = 0
  206. while (true) {
  207. const result = await consumeSSEStream(response)
  208. if (result.receivedAny) {
  209. attempts = 0
  210. }
  211. if (result.completed) {
  212. reconnecting.value = false
  213. running.value = false
  214. return
  215. }
  216. if (attempts >= MAX_RECONNECT_ATTEMPTS) {
  217. reconnecting.value = false
  218. message.warning('重连失败次数过多,回退到状态轮询')
  219. await pollRunState()
  220. return
  221. }
  222. attempts++
  223. reconnecting.value = true
  224. message.warning(`连接中断,尝试重连 (${attempts}/${MAX_RECONNECT_ATTEMPTS})`)
  225. try {
  226. await new Promise(r => setTimeout(r, 3000))
  227. const resp = await reconnectRunStream(lastRunId.value, lastEventId.value)
  228. if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
  229. reconnecting.value = false
  230. message.success('已重新连接')
  231. response = resp
  232. } catch (e) {
  233. reconnecting.value = false
  234. }
  235. }
  236. }
  237. /**
  238. * v1.1:回退方案 —— 每 30 秒轮询 GET /state。
  239. */
  240. async function pollRunState() {
  241. if (!lastRunId.value) {
  242. running.value = false
  243. return
  244. }
  245. const poll = async () => {
  246. try {
  247. const res = await getRunState(lastRunId.value)
  248. const state = res?.data || res
  249. if (state.currentPause && state.status === 'RUNNING') {
  250. if (!pendingAsk.value) {
  251. // 重连场景:按 nodeId 找到对应 Visit(取最近一条),让 Askfor 卡片能按 visitUuid 匹配
  252. const pausedVisits = visits.filter(v => v.nodeId === state.currentPause.nodeId)
  253. const matchedVisit = pausedVisits[pausedVisits.length - 1]
  254. pendingAsk.value = {
  255. nodeId: state.currentPause.nodeId || null,
  256. visitUuid: matchedVisit?.visitUuid || null,
  257. clarifyId: state.currentPause.clarifyId,
  258. question: state.currentPause.question,
  259. choices: safeParseChoices(state.currentPause.choices)
  260. }
  261. }
  262. }
  263. if (state.status === 'SUCCESS' || state.status === 'FAILED') {
  264. running.value = false
  265. if (state.status === 'SUCCESS') {
  266. message.success('工作流执行完成(来自轮询)')
  267. } else {
  268. message.error('工作流执行失败(来自轮询)')
  269. }
  270. return true
  271. }
  272. return false
  273. } catch (e) {
  274. return false
  275. }
  276. }
  277. const terminated = await poll()
  278. if (terminated || !running.value) return
  279. pollTimer.value = setInterval(async () => {
  280. const done = await poll()
  281. if (done || !running.value) {
  282. clearInterval(pollTimer.value)
  283. pollTimer.value = null
  284. }
  285. }, 30000)
  286. }
  287. function safeParseChoices(s) {
  288. if (!s) return []
  289. try {
  290. const v = typeof s === 'string' ? JSON.parse(s) : s
  291. return Array.isArray(v) ? v : []
  292. } catch { return [] }
  293. }
  294. /**
  295. * v2.0:按 visitUuid 确保 visits 中存在对应条目。
  296. * <p>路由策略:
  297. * <ol>
  298. * <li>携带 visitUuid 时严格按 visitUuid 路由:找到则复用,找不到则新建(绝不 fallback 到 nodeId,
  299. * 否则同节点多次访问会被合并到同一张卡片——例如 A→B→B 实际只显示 A→B(第2次),B 第 1 次被覆盖)</li>
  300. * <li>visitUuid 缺失时按 nodeId 查最近一条(兼容老数据 / 事件乱序场景)</li>
  301. * <li>都查不到则创建新条目(nodeType/label 优先取事件携带值,其次按 nodeId 从 getNodes 推断)</li>
  302. * </ol>
  303. * </p>
  304. * <p>v1.2:新增 eventMeta 可选参数(node_status RUNNING 事件自身)。
  305. * 后端在事件中携带 nodeType/nodeLabel,外部集成场景无画布数据(getNodes 为空)时,
  306. * 优先使用事件值推断类型,避免运行期间显示"未知"。</p>
  307. */
  308. function ensureVisit(visitUuid, nodeId, eventMeta) {
  309. if (visitUuid) {
  310. const v = visits.find(r => r.visitUuid === visitUuid)
  311. if (v) return v
  312. // 携带了 visitUuid 但 visits 里没有 → 必须新建,不能 fallback 到同节点的旧 visit
  313. } else if (nodeId) {
  314. const byNode = visits.filter(r => r.nodeId === nodeId)
  315. if (byNode.length > 0) return byNode[byNode.length - 1]
  316. }
  317. const node = nodeId ? getNodes.value.find(n => n.id === nodeId) : null
  318. const nodeType = eventMeta?.nodeType || node?.type || null
  319. const nodeLabel = eventMeta?.nodeLabel || node?.data?.label || nodeId || '节点'
  320. const info = nodeTypeInfo[nodeType] || { color: '#666', typeLabel: '未知' }
  321. const newVisit = {
  322. visitUuid: visitUuid || `temp-${nodeId || 'unknown'}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
  323. nodeId: nodeId || null,
  324. nodeType: nodeType,
  325. nodeLabel: nodeLabel,
  326. iterSeq: 1,
  327. fromVisitUuid: null,
  328. fromNodeId: null,
  329. // offCanvas:等 visit_recorded 到达时按 decisionJson 同步;初始为 false(保守不显示)
  330. offCanvas: false,
  331. sortOrder: visits.length,
  332. status: 'running',
  333. errorMsg: null,
  334. output: null,
  335. logs: [],
  336. contextSnapshot: null,
  337. variablesJson: '{}',
  338. outputJson: null,
  339. streamingThinking: '',
  340. currentStatus: '',
  341. currentTool: null,
  342. toolHistory: [],
  343. todos: [],
  344. color: info.color,
  345. typeLabel: info.typeLabel
  346. }
  347. visits.push(newVisit)
  348. return newVisit
  349. }
  350. function handleSSEEvent(event) {
  351. // v1.1:从任意事件提取 runId,便于断线重连
  352. if (event.runId && !lastRunId.value) {
  353. lastRunId.value = event.runId
  354. lastRunWorkflowId.value = workflowIdRef.value
  355. }
  356. if (event.type === 'node_status' && event.nodeId) {
  357. const status = event.status?.toLowerCase() || 'running'
  358. nodeStatuses[event.nodeId] = status
  359. const node = getNodes.value.find(n => n.id === event.nodeId)
  360. if (node) node.class = `node-status-${status}`
  361. // 传入 event 本身:RUNNING 事件携带 nodeType/nodeLabel(v1.2),
  362. // 无画布数据时 ensureVisit 优先用事件值建卡片,避免类型显示"未知"
  363. const visit = ensureVisit(event.visitUuid, event.nodeId, event)
  364. // 节点完成(success/failed/skipped/no_run)时自动收起思考过程、清空实时状态
  365. // 仅 running → finished 的首次状态变更才触发折叠/清空;重连或重复事件不会反复折叠
  366. const FINISHED_STATES = ['success', 'failed', 'skipped', 'no_run']
  367. const wasFinished = FINISHED_STATES.includes(visit.status)
  368. const isFinished = FINISHED_STATES.includes(status)
  369. if (isFinished && !wasFinished) {
  370. delete expandedThinking[visit.visitUuid]
  371. delete expandedLogs[visit.visitUuid]
  372. }
  373. // 更新 visit 字段(保留 streamingThinking / toolHistory / todos 等流式累加字段)
  374. visit.status = status
  375. if (event.output !== undefined && event.output !== null) visit.output = event.output
  376. if (event.logs) visit.logs = filterNonThinkingLogs(event.logs)
  377. if (event.contextSnapshot) visit.contextSnapshot = event.contextSnapshot
  378. if (isFinished) visit.currentStatus = ''
  379. if (status === 'failed' && event.error) visit.errorMsg = event.error
  380. } else if (event.type === 'node_stream' && event.nodeId) {
  381. // 按 visitUuid 路由到对应 Visit 卡片;visitUuid 缺失时 fallback 到 nodeId
  382. const visit = ensureVisit(event.visitUuid, event.nodeId)
  383. if (!visit) return
  384. const kind = event.kind
  385. const content = event.content || ''
  386. const toolName = event.toolName || null
  387. if (kind === 'thinking') {
  388. if (content) visit.streamingThinking = (visit.streamingThinking || '') + content
  389. } else if (kind === 'status') {
  390. visit.currentStatus = stripHermesThinkingDecoration(content)
  391. } else if (kind === 'step') {
  392. try {
  393. const obj = JSON.parse(content)
  394. const iteration = obj.iteration ?? '?'
  395. visit.currentStatus = `第 ${iteration} 轮 LLM 调用`
  396. } catch { /* ignore */ }
  397. } else if (kind === 'tool_progress') {
  398. try {
  399. const obj = JSON.parse(content)
  400. if (obj.event_type === 'tool.started' && obj.name) {
  401. const tn = obj.name ?? '工具'
  402. visit.currentStatus = `正在调用 ${tn}`
  403. }
  404. } catch { /* ignore */ }
  405. } else if (kind === 'tool_call') {
  406. if (visit.currentTool) {
  407. visit.toolHistory.unshift(visit.currentTool)
  408. }
  409. visit.currentTool = { name: toolName, argsSummary: content, result: null }
  410. } else if (kind === 'tool_result') {
  411. if (visit.currentTool) {
  412. visit.currentTool.result = content
  413. visit.toolHistory.unshift(visit.currentTool)
  414. visit.currentTool = null
  415. }
  416. } else if (kind === 'todo_update') {
  417. try {
  418. const parsed = JSON.parse(content)
  419. const arr = Array.isArray(parsed) ? parsed : (parsed?.todos || [])
  420. visit.todos = Array.isArray(arr) ? arr : []
  421. } catch { /* ignore */ }
  422. } else if (kind === 'clarify_request') {
  423. // Askfor:全局唯一 pendingAsk(同 run 同时只有一个 pending)
  424. try {
  425. const obj = JSON.parse(content)
  426. if (obj.clarify_id) {
  427. pendingAsk.value = {
  428. nodeId: event.nodeId || null,
  429. visitUuid: event.visitUuid || null,
  430. clarifyId: obj.clarify_id,
  431. question: obj.question || '',
  432. choices: Array.isArray(obj.choices) ? obj.choices : []
  433. }
  434. }
  435. } catch { /* ignore */ }
  436. }
  437. } else if (event.type === 'visit_recorded' && event.visit) {
  438. // 更新对应 Visit 的元数据(visitUuid / iterSeq / fromNodeId / sortOrder / variablesJson / outputJson)
  439. const v = event.visit
  440. const existing = ensureVisit(v.visitUuid, v.nodeId)
  441. existing.visitUuid = v.visitUuid
  442. existing.nodeId = v.nodeId
  443. existing.nodeType = v.nodeType || existing.nodeType
  444. existing.nodeLabel = v.nodeLabel || v.nodeId || existing.nodeLabel
  445. existing.iterSeq = v.iterSeq || 1
  446. existing.fromVisitUuid = v.fromVisitUuid || null
  447. existing.fromNodeId = v.fromNodeId || null
  448. // offCanvas:仅当 decisionJson 显式标记为 true(按画布走 / 起始节点 均为 false)
  449. existing.offCanvas = parseVisitDecision(v.decisionJson)?.offCanvas === true
  450. existing.variablesJson = v.variablesJson || '{}'
  451. existing.outputJson = v.outputJson || null
  452. existing.status = (v.status || existing.status).toLowerCase()
  453. existing.errorMsg = v.errorMsg || existing.errorMsg
  454. existing.sortOrder = v.sortOrder ?? existing.sortOrder
  455. // 同步 color / typeLabel(如果 nodeType 变了)
  456. const node = getNodes.value.find(n => n.id === v.nodeId)
  457. const info = nodeTypeInfo[existing.nodeType] || nodeTypeInfo[node?.type] || { color: '#666', typeLabel: '未知' }
  458. existing.color = info.color
  459. existing.typeLabel = info.typeLabel
  460. } else if (event.type === 'workflow_complete') {
  461. workflowResult.value = event.output
  462. showResult.value = true
  463. if (event.runId) {
  464. lastRunId.value = event.runId
  465. }
  466. runMeta.value = {
  467. runId: event.runId || null,
  468. workingDirFiles: Array.isArray(event.workingDirFiles) ? event.workingDirFiles : null,
  469. }
  470. lastRunWorkflowId.value = workflowIdRef.value
  471. message.success('工作流执行完成')
  472. } else if (event.type === 'workflow_error') {
  473. message.error('执行失败: ' + (event.error || '未知错误'))
  474. }
  475. }
  476. /**
  477. * v1.1:用户提交 Askfor 答案。
  478. */
  479. async function submitClarify(answer) {
  480. if (!pendingAsk.value || !lastRunId.value) return
  481. const { clarifyId } = pendingAsk.value
  482. try {
  483. const res = await submitClarifyAnswer(lastRunId.value, clarifyId, answer)
  484. const resolved = res?.resolved ?? res?.data?.resolved ?? false
  485. if (resolved) {
  486. pendingAsk.value = null
  487. message.success('已提交回答,工作流继续执行')
  488. } else {
  489. message.warning('回答未生效,可能 clarify 已过期')
  490. pendingAsk.value = null
  491. }
  492. } catch (e) {
  493. message.error('提交回答失败: ' + e.message)
  494. }
  495. }
  496. function closeResult() {
  497. showResult.value = false
  498. resetState()
  499. clearNodeStatusClasses()
  500. activeTab.value = 'props'
  501. pendingAsk.value = null
  502. lastEventId.value = null
  503. reconnecting.value = false
  504. if (pollTimer.value) {
  505. clearInterval(pollTimer.value)
  506. pollTimer.value = null
  507. }
  508. Object.keys(expandedLogs).forEach(k => delete expandedLogs[k])
  509. }
  510. function openRunDialog(inputFields, formData) {
  511. Object.keys(formData).forEach(k => delete formData[k])
  512. inputFields.value.forEach(f => { formData[f.name] = '' })
  513. showRunDialog.value = true
  514. }
  515. function cancelRun() {
  516. showRunDialog.value = false
  517. }
  518. function statusText(s) {
  519. return { running: '执行中', success: '成功', failed: '失败', skipped: '跳过', no_run: '未运行' }[s] || s
  520. }
  521. function logTypeLabel(type) {
  522. return sharedLogTypeLabel(type)
  523. }
  524. function formatTimestamp(ts) {
  525. if (!ts) return ''
  526. const d = new Date(ts)
  527. return d.toLocaleTimeString('zh-CN', { hour12: false })
  528. }
  529. function filterNonThinkingLogs(logs) {
  530. if (!Array.isArray(logs)) return logs
  531. return logs.filter(l => l.type !== 'THINKING')
  532. }
  533. function toggleContext(visitUuid) {
  534. expandedContext[visitUuid] = !expandedContext[visitUuid]
  535. }
  536. function toggleThinking(visitUuid) {
  537. expandedThinking[visitUuid] = !expandedThinking[visitUuid]
  538. }
  539. function toggleLogs(visitUuid) {
  540. expandedLogs[visitUuid] = !expandedLogs[visitUuid]
  541. }
  542. /**
  543. * 格式化 contextSnapshot / variablesJson 中的值:
  544. * - 对象/数组:JSON.stringify(缩进 2)
  545. * - 字符串:原样返回,超长由 CSS 控制最大高度
  546. * - 其他:String(value)
  547. */
  548. function formatContextValue(value) {
  549. if (value === null || value === undefined) return 'null'
  550. if (typeof value === 'object') return JSON.stringify(value, null, 2)
  551. return String(value)
  552. }
  553. return {
  554. running,
  555. showRunDialog,
  556. showResult,
  557. workflowResult,
  558. runMeta,
  559. nodeStatuses,
  560. activeTab,
  561. // v2.0:单一卡片列表(替代旧版 nodeResults + visits 双数组)
  562. visits,
  563. expandedContext,
  564. expandedThinking,
  565. expandedLogs,
  566. lastRunId,
  567. lastRunWorkflowId,
  568. lastEventId,
  569. reconnecting,
  570. pendingAsk,
  571. startRun,
  572. submitClarify,
  573. openRunDialog,
  574. cancelRun,
  575. closeResult,
  576. statusText,
  577. logTypeLabel,
  578. formatTimestamp,
  579. toggleContext,
  580. toggleThinking,
  581. toggleLogs,
  582. formatContextValue
  583. }
  584. }