NodeDetailPanel.vue 9.64 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>
      </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>
          <dt>agent_run_id</dt><dd class="mono">{{ node.agentRunId || '—' }}</dd>
          <dt>child_run_id</dt><dd class="mono">{{ node.childRunId || '—' }}</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>
            <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">原始产出 + 上一轮修复意见</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>
        <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: '子图' }] : []),
]);

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)
    : []);

/** 回退到本节点时随之带下来的「应该如何修复」——它就是下一轮输入与原始产出的差量 */
const consumedNote = computed(() => {
  const rec = myLoops.value.find((r) => r.to_node_run_id === props.node?.id && r.remediation_note);
  return rec ? rec.remediation_note : null;
});

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; }
.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>