平台有两种运行展示形态,数据层完全相同(useWorkflowRunner + Visit 卡片),模板差异如下:
| 形态 | 参考文件 | 特征 |
|---|---|---|
| 回放态(历史记录页) | views/workflow/RunHistory.vue(完整自包含) |
卡片默认折叠、无实时状态面板、无 AskFor 交互 |
| 实时态(执行中) | 本文档(提取自平台 WorkflowEditor.vue 运行结果 Tab) |
running 强制展开、.live 高亮、AskFor 匹配、重连徽章 |
业务方做「发起运行 + 实时观看」的页面时,以 RunHistory.vue 的卡片结构为基础,叠加本文档的差异部分。
const {
// 状态
running, showResult, workflowResult, runMeta, // runMeta: { runId, workingDirFiles }
visits, // Visit 卡片数组(visitUuid 为主键)
expandedContext, expandedThinking, expandedLogs, // 三组折叠状态(reactive,按 visitUuid 索引)
pendingAsk, // 全局唯一 AskFor { nodeId, visitUuid, clarifyId, question, choices }
reconnecting, // SSE 重连中标志
// 方法
startRun, submitClarify,
toggleContext, toggleThinking, toggleLogs
} = useWorkflowRunner({ getNodes, nodeTypeInfo, workflowIdRef })
getNodes 说明(v1.2 更新):
getNodes),用于运行状态高亮与 nodeType/label 推断。ref([]):v1.2 起 node_status RUNNING 事件自身携带 nodeType / nodeLabel,ensureVisit 优先使用事件值建卡片,Visit 类型/名称显示不受影响(此前无画布时运行期间会显示「未知」,需等 visit_recorded 到达才修正)。workflowIdRef 同理:外部 API 场景按需传入运行所属工作流标识(仅用于拼接内部接口,外部端点路径不依赖它)。另需本地派生与工具(平台同名实现语义):
sortedVisits:computed,按 visit.sortOrder 升序排列(sortOrder 缺失的排尾部)highlightedVisitUuid:可选,画布/列表联动高亮用;无画布集成可省略statusText(s)、visitTypeInfo(nodeType)(nodeTypeInfo lookup + UNKNOWN_NODE_TYPE_INFO 兜底)formatTimestamp(ts)、formatContextValue(v)、logTypeLabel / isToolLogType(utils/logLabels)trimLeadingBlankLines(utils/hermesThinking)、renderMarkdown(utils/markdown)unwrapNodeOutput / nodeOutputStatus / nodeOutputMessage / parseVisitVariables(composables/useWorkflowRunner 与 utils/visitChain 导出)<!-- 运行结果区 -->
<div v-show="activeTab === 'result'" class="tab-pane">
<div v-if="visits.length === 0" class="empty-hint">暂无运行结果</div>
<!-- v2.0:DAG / AGENTIC 模式统一为 Visit 卡片链
每张卡片等价于"一次节点执行",按 sortOrder 升序全局编号 #1 #2 ...
DAG 模式:每节点 1 张卡片,无 iterSeq/fromNodeId/Planner 决策徽章
AGENTIC 模式:同节点多次访问时出现多张卡片,由 iterSeq / fromNodeId / Planner 决策区分 -->
<div v-if="visits.length > 0" class="visit-chain">
<div v-for="(v, idx) in sortedVisits" :key="v.visitUuid || idx"
:id="'visit-' + v.visitUuid"
class="node-result-card visit-card"
:class="{ highlighted: highlightedVisitUuid === v.visitUuid }"
:style="{ borderLeftColor: v.color }">
<div class="nrc-header">
<div class="nrc-icon" :style="{ background: v.color }">
<n-icon size="12">
<component :is="visitTypeInfo(v.nodeType).icon" />
</n-icon>
</div>
<div class="nrc-info">
<span class="nrc-label">
<span class="nrc-iter">#{{ idx + 1 }}</span>
{{ v.nodeLabel }}
</span>
<span class="nrc-type">{{ v.typeLabel }}</span>
</div>
<span v-if="v.iterSeq > 1" class="vc-iter-badge">第 {{ v.iterSeq }} 次</span>
<span class="nrc-status" :class="'status-' + v.status">{{ statusText(v.status) }}</span>
</div>
<!-- AGENTIC 自由跳转标识:仅当 decisionJson.offCanvas=true(非画布跳转)才展示 -->
<div v-if="v.offCanvas && v.fromNodeId" class="vc-from" :title="'从 ' + v.fromNodeId + ' 跳转(非画布连线)'">
自由跳转自 {{ v.fromNodeId }}
</div>
<!-- v1.1:实时状态(覆盖式,节点完成后由 useWorkflowRunner 清空,自动隐藏) -->
<ThinkingLive :status="v.currentStatus || ''" />
<!-- 流式思考过程(实时累加,节点完成后保留为历史)
节点 running 时强制展开;非 running 时由 expandedThinking 控制(默认折叠) -->
<div v-if="v.streamingThinking" class="nrc-thinking" :class="{ live: v.status === 'running' }">
<div class="nrc-thinking-header" @click="toggleThinking(v.visitUuid)" style="cursor: pointer">
<span class="nrc-thinking-tag">思考过程</span>
<span v-if="v.status === 'running'" class="nrc-thinking-live">实时输出中…</span>
<span v-else class="nrc-thinking-static">({{ v.streamingThinking.length }} 字)</span>
<span class="nrc-thinking-expand"
:class="{ expanded: v.status === 'running' || expandedThinking[v.visitUuid] === true }">▶</span>
</div>
<pre v-show="v.status === 'running' || expandedThinking[v.visitUuid] === true" class="nrc-thinking-body">{{
trimLeadingBlankLines(v.streamingThinking) }}</pre>
</div>
<!-- v1.1:Hermes 工具调用 / TODO / Askfor
仅当 visit 产生了任一 v1.1 字段时渲染;Askfor 额外要求 visitUuid 匹配 -->
<AgentThinkingPanel
v-if="v.currentTool || (v.toolHistory && v.toolHistory.length) || (v.todos && v.todos.length) || (pendingAsk && pendingAsk.visitUuid === v.visitUuid)"
:node="v" :pending-ask="pendingAsk && pendingAsk.visitUuid === v.visitUuid ? pendingAsk : null" :reconnecting="reconnecting" @submit-clarify="submitClarify" />
<!-- 执行日志:节点 running 时强制展开;完成后默认收起 -->
<div v-if="v.logs && v.logs.length" class="nrc-logs" :class="{ live: v.status === 'running' }">
<div class="nrc-logs-header" @click="toggleLogs(v.visitUuid)" style="cursor: pointer">
<span class="nrc-logs-tag">执行日志</span>
<span class="nrc-logs-count">({{ v.logs.length }})</span>
<span class="nrc-logs-expand"
:class="{ expanded: v.status === 'running' || expandedLogs[v.visitUuid] === true }">▶</span>
</div>
<div v-show="v.status === 'running' || expandedLogs[v.visitUuid] === true" class="nrc-logs-body">
<div class="nrc-log" v-for="(log, logIdx) in v.logs" :key="logIdx"
:class="'log-type-' + (log.type || 'info').toLowerCase()">
<span class="nrc-log-time">{{ formatTimestamp(log.timestamp) }}</span>
<span class="nrc-log-tag">{{ logTypeLabel(log.type) }}</span>
<template v-if="isToolLogType(log.type)">
<span class="nrc-log-tool">{{ log.message }}</span>
<span v-if="log.detail" class="nrc-log-msg">{{ log.detail }}</span>
</template>
<template v-else>
<span class="nrc-log-msg">{{ log.message }}</span>
<div v-if="log.detail" class="nrc-log-detail">{{ log.detail }}</div>
</template>
</div>
</div>
</div>
<!-- 节点输出(envelope 失败时显示红色徽章 + message) -->
<div class="nrc-vars" v-if="unwrapNodeOutput(v.output) && Object.keys(unwrapNodeOutput(v.output)).length">
<div v-if="nodeOutputStatus(v.output) === 400" class="nrc-env-fail">
<span class="nrc-env-badge failed">失败</span>
<span class="nrc-env-msg">{{ nodeOutputMessage(v.output) }}</span>
</div>
<div class="nrc-var" v-for="(value, key) in unwrapNodeOutput(v.output)" :key="key">
<span class="nrc-var-key" :style="{ color: v.color }">{{ key }}</span>
<span class="nrc-var-value md-body" v-if="typeof value === 'string'"
v-html="renderMarkdown(value)"></span>
<span class="nrc-var-value" v-else>{{ typeof value === 'object' ? JSON.stringify(value, null, 2) :
String(value)
}}</span>
</div>
</div>
<!-- 错误信息(失败 Visit 独有) -->
<div v-if="v.errorMsg" class="vc-error">{{ v.errorMsg }}</div>
<!-- 完整上下文快照(节点执行后的全部 variables,debug 视图)
DAG 模式从 nodeResult.contextSnapshot 拿到;AGENTIC 模式优先用 contextSnapshot,
缺失时回退到 visit_recorded 携带的 variablesJson -->
<div v-if="v.contextSnapshot || Object.keys(parseVisitVariables(v.variablesJson)).length"
class="nrc-ctx-toggle" @click="toggleContext(v.visitUuid)">
<span class="nrc-ctx-icon" :class="{ expanded: expandedContext[v.visitUuid] }">▶</span>
<span class="nrc-ctx-label">查看完整上下文({{ Object.keys(v.contextSnapshot || parseVisitVariables(v.variablesJson)).length }} 个变量)</span>
</div>
<div v-if="expandedContext[v.visitUuid] && (v.contextSnapshot || parseVisitVariables(v.variablesJson))"
class="nrc-ctx-panel">
<div class="nrc-ctx-var" v-for="(value, key) in (v.contextSnapshot || parseVisitVariables(v.variablesJson))" :key="key">
<span class="nrc-ctx-key">{{ key }}</span>
<span class="nrc-ctx-value">{{ formatContextValue(value) }}</span>
</div>
</div>
</div>
</div>
<div v-if="showResult && workflowResult" class="nrc-clear-row">
<!-- 元数据:运行ID + 工作空间文件列表,作为 workflow-run 的属性由 SSE 推送 -->
<div v-if="runMeta?.runId" class="run-meta-id">
<span class="run-meta-label">运行ID:</span>
<code class="run-meta-value">{{ runMeta.runId }}</code>
</div>
<details v-if="runMeta?.workingDirFiles" class="run-meta-files">
<summary><span class="run-meta-label">工作空间文件列表({{ runMeta.workingDirFiles.length }})</span></summary>
<ul class="run-meta-files-list">
<li v-for="f in runMeta.workingDirFiles" :key="f">{{ f }}</li>
</ul>
</details>
<!-- 操作组:下载 + 清除,水平并排 -->
<div class="nrc-actions">
<!-- 平台内部为 <a :href="/api/workflows/{id}/runs/{runId}/download"> 直链;
外部 API 下载需携带 X-API-Key 请求头,<a> 直链不可用,
改用 fetch + blob(见 ../../js-implementation.md §1 downloadWorkspace)
或业务方后端反代注入密钥后直链 -->
<button class="text-btn" @click="downloadWorkspace">下载工作空间</button>
<button class="text-btn danger" @click="closeResult">清除结果</button>
</div>
</div>
</div>
以下提取自平台 WorkflowEditor.vue 样式段。RunHistory.vue 覆盖回放态;实时视图需在其基础上补充:
/* 流式思考过程区块(RunHistory 已有基础版;.live 为实时态专属) */
.nrc-thinking.live {
border-color: rgba(168, 85, 247, 0.5);
box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.15);
}
.nrc-thinking-live {
color: #ec4899;
font-size: 10px;
animation: nrc-pulse 1.2s ease-in-out infinite;
}
.nrc-thinking-static {
color: #888;
font-size: 10px;
}
@keyframes nrc-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
.nrc-logs.live 同理(加强边框,与 .nrc-thinking.live 视觉一致)。
| 维度 | 回放态 RunHistory | 实时态(本文档) |
|---|---|---|
| 卡片默认状态 | 全部折叠(collapsed 由回放构建置 true) |
running 展开;首次进入终态时自动收起(useWorkflowRunner 在 FINISHED_STATES 分支 delete 折叠 key;重连/重复事件不重复折叠) |
| 思考/日志展开条件 | expandedXxx[visitUuid] === true |
v.status === 'running' \|\| expandedXxx[visitUuid] === true(running 强制展开) |
| 折叠头右侧提示 | (N 字) 静态字数 |
running 时「实时输出中…」脉冲动画,否则 (N 字) |
| 实时状态面板 | 不渲染(currentStatus 为空) |
<ThinkingLive :status="v.currentStatus" />;节点完成后 runner 清空自动隐藏 |
| AskFor | 无交互 | AgentThinkingPanel 挂载条件含 pendingAsk && pendingAsk.visitUuid === v.visitUuid,并传 :reconnecting 与 @submit-clarify |
| 区块 live 高亮 | 无 | .nrc-thinking / .nrc-logs 加 live 类(running 时) |
| 重连提示 | 无 | reconnecting 传给 AgentThinkingPanel → RunReconnectBadge |
| 运行元数据行 | 详情页 footer(outputMeta 从 run 记录解析) |
nrc-clear-row:runMeta.runId + workingDirFiles(SSE workflow_complete 推送)+ 下载/清除按钮 |
| 工作空间下载 | 内部直链 window.location.href |
外部 API 需 X-API-Key 头 → fetch + blob 或后端反代直链 |
平台在运行前弹出对话框收集输入变量与上传文件(inputFields 来自工作流 userInput 节点字段定义,外部 API 可从 GET /api/v1/workflows 的 inputs 获取):
* 标记 + 文本输入(数值型也用 text)选择文件(multiple)与 选择目录(webkitdirectory,浏览器原生提供 webkitRelativePath 保留目录结构,传入 startRun 的 relativePath)startRun(inputs) → useWorkflowRunner 内部走 api/workflow.js 的 runWorkflow(外部适配版:创建 → 启动 → 订阅 SSE)