workflow-run-live.md 14 KB

实时执行视图参考(WorkflowEditor 运行结果 Tab 形态)

平台有两种运行展示形态,数据层完全相同(useWorkflowRunner + Visit 卡片),模板差异如下:

形态 参考文件 特征
回放态(历史记录页) views/workflow/RunHistory.vue(完整自包含) 卡片默认折叠、无实时状态面板、无 AskFor 交互
实时态(执行中) 本文档(提取自平台 WorkflowEditor.vue 运行结果 Tab) running 强制展开、.live 高亮、AskFor 匹配、重连徽章

业务方做「发起运行 + 实时观看」的页面时,以 RunHistory.vue 的卡片结构为基础,叠加本文档的差异部分。

1. script 侧:从 useWorkflowRunner 解构

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 更新):

  • 平台内使用时是画布节点列表的 ref(VueFlow 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 导出)

2. 模板主体(平台逐字提取)

<!-- 运行结果区 -->
<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>

3. 实时态专属样式(RunHistory.vue 副本中不存在,需补充)

以下提取自平台 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 视觉一致)。

4. 与回放态(RunHistory.vue)的关键行为差异

维度 回放态 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 或后端反代直链

5. 发起运行(平台运行对话框形态,可选参考)

平台在运行前弹出对话框收集输入变量与上传文件(inputFields 来自工作流 userInput 节点字段定义,外部 API 可从 GET /api/v1/workflows 的 inputs 获取):

  • 必填项 * 标记 + 文本输入(数值型也用 text)
  • 「变量导出 / 变量导入」按钮(JSON 文件)
  • 「上传文件到工作目录」:选择文件(multiple)与 选择目录(webkitdirectory,浏览器原生提供 webkitRelativePath 保留目录结构,传入 startRun 的 relativePath)
  • 确认后调 startRun(inputs) → useWorkflowRunner 内部走 api/workflow.js 的 runWorkflow(外部适配版:创建 → 启动 → 订阅 SSE)