NodeDetailPanel.vue
9.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
<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>