NodeDetailPanel.vue 15.8 KB
<template>
  <div class="detail">
    <div v-if="!node" class="ph">
      在中间画布上点一个节点,这里显示它的详情。
    </div>

    <template v-else>
      <header class="head">
        <h3>{{ node.name || node.defNodeId }}</h3>
        <span class="badge" :class="node.status">{{ node.status }}</span>
      </header>
      <div class="sub">
        <span v-if="node.systemAgentId">本体 <code>{{ node.systemAgentId }}</code></span>
        <span>attempt <b>{{ (node.attempt || 0) + 1 }}</b></span>
        <span>{{ node.nodeKind === 'subgraph' ? '⊞ 子图节点' : 'agent 调用' }}</span>
        <!-- R-17.1:子 run 是复用的还是新起的,直接标在头上 —— 复用之后「这一层跑到哪了」
             不再等于「子图整个重来」,不标出来用户会以为子图从头跑了一遍 -->
        <span v-if="reusedChildRun" class="tag-reuse">子 run 复用中(服务 attempt #{{ (node.childRunAttempt || 0) + 1 }})</span>
      </div>

      <nav class="tabs">
        <button v-for="t in tabs" :key="t.key" :class="{ on: tab === t.key }" @click="tab = t.key">
          {{ t.label }}
        </button>
      </nav>

      <!-- 1 概览 -->
      <section v-if="tab === 'overview'" class="body">
        <dl class="kv">
          <dt>status</dt><dd><span class="badge" :class="node.status">{{ node.status }}</span></dd>
          <dt>attempt</dt><dd>{{ node.attempt || 0 }}</dd>
          <dt>started_at</dt><dd>{{ node.startedAt || '—' }}</dd>
          <dt>completed_at</dt><dd>{{ node.completedAt || '—' }}</dd>
          <dt>耗时</dt><dd>{{ duration }}</dd>
          <dt>fail_reason</dt><dd>{{ node.failReason || '—' }}</dd>
          <!-- R-17.1 跨层地址:归因把根因指到了「哪个 org node 的哪个 work graph node」。
               这是回退到底会退到哪一层的唯一依据,落库了就要在概览里看得见 -->
          <dt>fail_node_ref</dt>
          <dd>
            <template v-if="failRef">
              <code>{{ failRef.orgNode || '(按回退边的目标)' }}</code> / <code>{{ failRef.workNode }}</code>
              <em class="hint">归因指向的 work graph 节点(跨层)</em>
            </template>
            <template v-else>—</template>
          </dd>
          <dt>agent_run_id</dt><dd class="mono">{{ node.agentRunId || '—' }}</dd>
          <dt>child_run_id</dt>
          <dd class="mono">
            {{ node.childRunId || '—' }}
            <em v-if="node.childRunId" class="hint">
              {{ reusedChildRun ? '复用中:本轮不重新规划,只重跑被点名的节点' : `为 attempt #${(node.childRunAttempt || 0) + 1} 起` }}
            </em>
          </dd>
          <!-- 节点行上的判词(graph_run_nodes.remediation_note)。落库了就要在概览里看得见,
               否则「产生 → 传递 → 落库 → 读回」的最后一环还是空的(R5-F2) -->
          <dt>remediation_note</dt><dd class="wrap">{{ node.remediationNote || '—' }}</dd>
        </dl>

        <h4>回退记录({{ myLoops.length }})</h4>
        <ul v-if="myLoops.length" class="loops">
          <li v-for="r in myLoops" :key="r.id" :class="r.verdict">
            <div class="loop-head">
              <span class="lc">{{ r.loop_count }}/3</span>
              <span class="verdict">{{ r.verdict }}</span>
              <span class="dir">{{ r.from_node_run_id === node.id ? '从这里退出' : '退回到这里' }}</span>
            </div>
            <!-- R-17.1 回退粒度:退到 org node 整张图,还是跨层退到子图里的某一个节点。
                 账本上有 target_node_ref 就是后者(引擎真的只重置了那一个 node) -->
            <p class="scope" :class="{ cross: refOf(r) }">
              <template v-if="refOf(r)">
                跨层定点重置 → <code>{{ refOf(r).orgNode }}</code> / <code>{{ refOf(r).workNode }}</code>
                <span class="muted small">(复用子 run {{ refOf(r).childRunId }},只重跑这一个节点,
                  contract 未变的下游不动)</span>
              </template>
              <template v-else>整张 work graph 重跑(归因没点名具体节点,或地址解析不了)</template>
            </p>
            <p v-if="r.remediation_note" class="note">{{ r.remediation_note }}</p>
          </li>
        </ul>
        <p v-else class="muted">这个节点没有参与过回退。</p>
      </section>

      <!-- 2 输入输出 -->
      <section v-else-if="tab === 'io'" class="body">
        <h4>原始产出</h4>
        <p v-if="node.outputRef" class="mono path">{{ node.outputRef }}</p>
        <p v-else class="muted">还没有产出(output_ref 为空)。</p>
        <p class="muted small">
          产物正文需要 <code>GET /api/graph-runs/:id/nodes/:nodeId/output</code>,
          T6 端点清单里没有这一条;接上之前这里只显示路径。
        </p>

        <h4>实际被下游消费的形态</h4>
        <p class="muted small">
          正向边上与原始产出相同;经过回退后会拼上上一轮的 remediation_note,两者不能假设相等(§四.B.3)。
        </p>
        <div v-if="consumedNote" class="consumed">
          <div class="tag">原始产出 + 上一轮修复意见 <em class="src">来源:{{ consumedSource }}</em></div>
          <p class="note">{{ consumedNote }}</p>
        </div>
        <p v-else class="muted">本节点没有收到上一轮判词,消费形态 = 原始产出。</p>

        <h4>读过的 artifacts/</h4>
        <ul v-if="(node.artifactsRead || []).length" class="mono list">
          <li v-for="a in node.artifactsRead" :key="a">{{ a }}</li>
        </ul>
        <p v-else class="muted small">
          运行时未记录该节点读过哪些共享产物(graph_run_nodes 上没有这一列)。
        </p>
      </section>

      <!-- 3 运行日志 -->
      <section v-else-if="tab === 'log'" class="body">
        <NodeRunLog :node="node" :events="events" />
      </section>

      <!-- 4 子图 -->
      <section v-else-if="tab === 'sub'" class="body">
        <div class="sub-head">
          <span class="mono">{{ node.childRunId }}</span>
          <button class="btn go" @click="$emit('dive', { runId: node.childRunId, label: node.name })">
            进入子图 →
          </button>
        </div>
        <p class="muted small">
          下面是子 run 的只读缩略图;「进入子图」把它推到中间画布,那一层的五个控制按钮会作用在子 run 上,
          面包屑可以逐层返回(§四.B.3)。
        </p>
        <!-- R-17.1 明确接受的代价:跨层寻址破坏了层级封装,「暂停 / 下一步」作用于哪一层
             的语义因此变含糊,**必须在 UI 上显式说清楚**,否则用户会以为在这一层点单步
             也能推动子图里的节点。 -->
        <div class="layer-note">
          <div class="tag">层级语义(R-17.1 跨层寻址)</div>
          <ul>
            <li>
              <b>「下一步」只推你当前所在的这一层。</b>在本层单步只会推进本层节点;
              这个节点是 <code>subgraph</code>,它的一步 = 「把子 run 交给引擎」,
              子图内部的节点要<b>进入子图后</b>再单步。
            </li>
            <li>
              <b>「暂停 / 停止」是全局意图,向下级联</b>到这张子 run 和它的所有后代;
              <b>「启动」是它的镜像</b>,会把被级联暂停的后代一起拉回来。
            </li>
            <li>
              <b>这张子 run 会被跨层复用。</b>上一层的回退可能只重置子图里<b>某一个</b>节点
              (见「概览 → 回退记录」那一行的回退粒度),
              <em>不是</em>整张子图重跑——所以子图里各节点的 attempt 可能不一样。
            </li>
          </ul>
        </div>
        <WorkGraphCanvas
          :nodes="child.nodes"
          :edges="child.edges"
          :loop-records="child.loopRecords"
          @select="$emit('dive', { runId: node.childRunId, label: node.name })"
        >
          <template #empty>子图还没有节点</template>
        </WorkGraphCanvas>
      </section>
    </template>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue';
import WorkGraphCanvas from '../WorkGraphCanvas.vue';
import NodeRunLog from './NodeRunLog.vue';

/** 节点详情四 tab(T4 / §四.B.3) */
const props = defineProps({
  node: { type: Object, default: null },          // getRunState 的合并节点
  loopRecords: { type: Array, default: () => [] },
  events: { type: Array, default: () => [] },
});
defineEmits(['dive']);

const tab = ref('overview');
const child = ref({ nodes: [], edges: [], loopRecords: [] });

const hasSub = computed(() => props.node && props.node.nodeKind === 'subgraph' && props.node.childRunId);
const tabs = computed(() => [
  { key: 'overview', label: '概览' },
  { key: 'io', label: '输入输出' },
  { key: 'log', label: '运行日志' },
  ...(hasSub.value ? [{ key: 'sub', label: '子图' }] : []),
]);

/**
 * R-17.1 跨层地址的读回端。两处来源、两种语义,不要混:
 *   node.failNodeRef            = 归因的**主张**(graph_run_nodes.fail_node_ref)
 *   loopRecord.target_node_ref  = 引擎真的退到了哪(解析后,含 childRunId / nodeRunId)
 * 坏值一律当没有:面板不该因为一条 JSON 解析不了就整块白掉。
 */
function readRef(raw) {
  if (!raw) return null;
  try {
    const v = typeof raw === 'string' ? JSON.parse(raw) : raw;
    return v && v.workNode ? v : null;
  } catch { return null; }
}
const failRef = computed(() => readRef(props.node?.failNodeRef));
const refOf = (rec) => readRef(rec && rec.target_node_ref);
/** 子 run 服务的是更早的一轮 = 本轮在复用它(executeSubgraph 的代次判据在前端的镜像) */
const reusedChildRun = computed(() => {
  const n = props.node;
  return Boolean(n && n.childRunId && n.childRunAttempt != null && n.childRunAttempt < (n.attempt || 0));
});

const myLoops = computed(() =>
  props.node
    ? props.loopRecords.filter((r) => r.from_node_run_id === props.node.id || r.to_node_run_id === props.node.id)
    : []);

/**
 * 本节点这一轮实际拿到的「应该如何修复」——它就是下一轮输入与原始产出的差量。
 *
 * **权威来源是节点行上的 remediation_note,loop record 只是回落**(R5-F2):
 * loop record 仅在命中 backward 边时才有,而 forward fail 边 / 死路 blocked /
 * infra_flake 原地重跑这三条路径上一条记录都不会有 —— 只按 loop record 读,
 * 恰恰在这三条路径上会显示「本节点未经过回退」,而节点其实是拿到了判词的。
 * 判词由 graph_run_nodes.remediation_note 落库、getRunState 读回成 node.remediationNote。
 */
const consumedNote = computed(() => {
  if (props.node?.remediationNote) return props.node.remediationNote;
  const rec = myLoops.value.find((r) => r.to_node_run_id === props.node?.id && r.remediation_note);
  return rec ? rec.remediation_note : null;
});
const consumedSource = computed(() =>
  (props.node?.remediationNote ? '节点行 remediation_note' : '回退账本 loop record'));

const duration = computed(() => {
  const n = props.node;
  if (!n?.startedAt) return '—';
  const end = n.completedAt ? Date.parse(n.completedAt) : Date.now();
  const ms = end - Date.parse(n.startedAt);
  if (!Number.isFinite(ms) || ms < 0) return '—';
  const s = Math.round(ms / 1000);
  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
});

// tab 数量随节点变化,切到别的节点时若当前 tab 不存在要回落,否则面板会空白
watch(() => props.node?.id, () => {
  if (!tabs.value.some((t) => t.key === tab.value)) tab.value = 'overview';
  loadChild();
});
watch(tab, (t) => { if (t === 'sub') loadChild(); });

async function loadChild() {
  child.value = { nodes: [], edges: [], loopRecords: [] };
  if (!hasSub.value) return;
  try {
    const r = await fetch(`/api/graph-runs/${props.node.childRunId}`);
    if (!r.ok) return;
    const j = await r.json();
    child.value = { nodes: j.nodes || [], edges: j.edges || [], loopRecords: j.loopRecords || [] };
  } catch { /* 缩略图拉不到不该影响其它 tab */ }
}
</script>

<style scoped>
.detail { display:flex; flex-direction:column; min-height:0; height:100%; }
.ph { color:#999; font-size:13px; padding:24px 8px; text-align:center; }
.head { display:flex; align-items:center; gap:8px; }
.head h3 { margin:0; font-size:14px; font-family: ui-monospace, monospace; word-break:break-all; }
.sub { display:flex; flex-wrap:wrap; gap:10px; font-size:11px; color:#777; margin:6px 0 10px; }
.sub code { background:#f0f0f2; padding:1px 5px; border-radius:3px; }

.tabs { display:flex; gap:4px; border-bottom:1px solid #eee; margin-bottom:10px; }
.tabs button {
  border:none; background:none; padding:6px 10px; font-size:12px; cursor:pointer;
  color:#666; border-bottom:2px solid transparent;
}
.tabs button.on { color:#1a1a1a; font-weight:600; border-bottom-color:#2b6cb0; }

.body { flex:1; overflow-y:auto; min-height:0; }
h4 { font-size:11px; color:#888; text-transform:uppercase; margin:14px 0 6px; }
.kv { display:grid; grid-template-columns:104px 1fr; gap:4px 10px; font-size:12px; margin:0; }
.kv dt { color:#888; }
.kv dd { margin:0; word-break:break-all; }
.mono { font-family: ui-monospace, monospace; font-size:11px; }
.path { background:#f7f7f9; padding:5px 8px; border-radius:4px; word-break:break-all; }
.badge { font-size:10px; padding:2px 8px; border-radius:9px; background:#eee; color:#555; text-transform:uppercase; }
.badge.running { background:#cce5ff; color:#004085; }
.badge.success { background:#d4edda; color:#155724; }
.badge.fail    { background:#f8d7da; color:#721c24; }
.badge.blocked { background:#ffe8cc; color:#a35b00; }
.badge.skipped { background:#e9e9ec; color:#777; }

.loops { list-style:none; padding:0; margin:0; }
.loops li { border:1px solid #f0e0c8; background:#fffaf3; border-radius:5px; padding:7px 9px; margin-bottom:6px; }
.loops li.force_terminated { border-color:#e0a0a0; background:#fff5f5; }
.loop-head { display:flex; gap:8px; align-items:center; font-size:11px; }
.lc { font-weight:700; color:#8a4b00; }
.verdict { text-transform:uppercase; color:#555; }
.dir { color:#999; margin-left:auto; }
.note { font-size:12px; color:#444; margin:5px 0 0; white-space:pre-wrap; }
.consumed { border:1px solid #d6e3f5; background:#f7fbff; border-radius:5px; padding:7px 9px; }
.consumed .tag { font-size:10px; color:#2b6cb0; font-weight:600; }
.consumed .src { color:#8aa; font-style:normal; font-weight:400; margin-left:6px; }
.hint { color:#8aa; font-style:normal; font-size:10px; margin-left:6px; }
.tag-reuse { color:#2b6cb0; background:#eef4fb; border-radius:3px; padding:1px 6px; }
.scope { font-size:11px; color:#777; margin:5px 0 0; }
.scope.cross { color:#2b6cb0; }
.scope code { background:#eef4fb; padding:1px 4px; border-radius:3px; }
.layer-note { border:1px solid #e3dcf5; background:#faf8ff; border-radius:5px; padding:8px 10px; margin:8px 0; }
.layer-note .tag { font-size:10px; color:#6b46c1; font-weight:600; margin-bottom:4px; }
.layer-note ul { margin:0; padding-left:16px; }
.layer-note li { font-size:11px; color:#555; line-height:1.7; }
.layer-note code { background:#f0f0f2; padding:1px 4px; border-radius:3px; }
.kv dd.wrap { white-space:pre-wrap; color:#444; }
.list { padding-left:16px; margin:0; }
.muted { color:#999; font-size:12px; }
.muted.small { font-size:11px; line-height:1.6; }
.muted code { background:#f0f0f2; padding:1px 4px; border-radius:3px; }
.sub-head { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
.btn { border:1px solid #ddd; background:#fff; border-radius:5px; cursor:pointer; font-size:12px; padding:4px 10px; }
.btn.go { background:#2b6cb0; border-color:#2b6cb0; color:#fff; margin-left:auto; }
</style>