GraphRunPane.vue
11.5 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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
<template>
<div class="pane" :class="{ compact }">
<header class="pane-head">
<span class="ttl">{{ title }}</span>
<span v-if="run" class="run-status" :class="run.run_status">{{ run.run_status }}</span>
<span v-if="run" class="rid" :title="run.id">{{ run.id }}</span>
<span class="grow" />
<span class="conn" :class="sseStatus" :title="`SSE: ${sseStatus}`">●</span>
<button class="mini" :disabled="loading" @click="load()">刷新</button>
</header>
<!-- 控制栏(§四.C 五个操作;作用于本 pane 当前显示的这一层 run) -->
<div v-if="run" class="ctrl">
<label class="mode">
<select v-model="mode" :disabled="busy">
<option value="manual">手动</option>
<option value="auto">自动</option>
</select>
</label>
<button class="btn go" :disabled="busy || !can('start')" @click="onStart">▶ 启动</button>
<button class="btn" :disabled="busy || !can('next')" @click="act('next')">⏭ 下一步</button>
<button class="btn" :disabled="busy || !can('pause')" @click="act('pause')">⏸ 暂停</button>
<button class="btn" :disabled="busy || !can('stop')" @click="act('stop')">⏹ 停止</button>
<button class="btn retry" :disabled="busy || !canRetry" @click="onRetry">🔁 重试</button>
<span v-if="nextNode" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span>
</div>
<p v-if="err" class="err">{{ err }}</p>
<WorkGraphCanvas
:nodes="nodes"
:edges="edges"
:loop-records="loopRecords"
:selected-id="selectedDefId"
:next-node-id="nextNode ? String(nextNode.defNodeId) : null"
@select="onSelect"
>
<template #empty><slot name="empty">这张图还没有节点</slot></template>
</WorkGraphCanvas>
<!-- §四.B.4:自动模式启动前的预估成本确认,不是可选的礼貌 -->
<div v-if="confirmAuto" class="modal-back" @click.self="confirmAuto = false">
<div class="modal">
<h3>自动模式启动前确认</h3>
<p class="lead">自动模式一旦启动,引擎会连续调用 agent 直到收敛或撞预算,中途只能靠「暂停 / 停止」拦。</p>
<ul class="est">
<li>本次任务粗估:<b>60-100 次 agent 调用 · $10-20 · 1-2 小时</b>(27-P3 §四.A.3 量级)</li>
<li v-if="budget">
剩余预算:<b>{{ remain.calls }}</b> 次调用 ·
<b>{{ fmtNum(remain.tokens) }}</b> tokens
</li>
<li v-else>该 run 没挂预算(budget_json 为空)——扣减与熔断只在 root run 上生效。</li>
</ul>
<div class="modal-btns">
<button class="btn" @click="confirmAuto = false">取消</button>
<button class="btn go" @click="confirmAuto = false; act('start', { mode: 'auto' })">确认并启动</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import WorkGraphCanvas from '../WorkGraphCanvas.vue';
/**
* 一层 run 的完整视图(T3/T4):拉状态 + 控制栏 + 画布 + SSE。
* 一个页面上会挂多个实例(左栏 org run、中栏 work run、下钻后的子 run),彼此独立。
*
* 事件轨:SSE 带 ?lastEventId=0 连接 —— graph-run-sse 的 replayTo 会把 stream.log 从头重放,
* 所以**一条连接同时拿到历史与实时**,「运行日志」tab 的 history 轨不需要另开端点。
*/
const props = defineProps({
runId: { type: String, default: null },
title: { type: String, default: 'Graph Run' },
compact: { type: Boolean, default: false },
});
const emit = defineEmits(['select', 'state']);
const run = ref(null);
const nodes = ref([]);
const edges = ref([]);
const loopRecords = ref([]);
const budget = ref(null);
const events = ref([]); // SSE 事件缓冲,供详情面板的「运行日志」tab 消费
const selectedDefId = ref(null);
const loading = ref(false);
const busy = ref(false);
const err = ref('');
const mode = ref('manual');
const confirmAuto = ref(false);
const sseStatus = ref('idle');
let es = null;
let refreshTimer = null;
let pollTimer = null;
const MAX_EVENTS = 400;
/** 引擎按 (created_at, id) 取第一个 ready 节点;getRunState 用同样的排序,取首个 ready 即「下一步会跑哪个」 */
const nextNode = computed(() => nodes.value.find((n) => n.status === 'ready') || null);
const selectedNode = computed(() => nodes.value.find((n) => String(n.defNodeId) === selectedDefId.value) || null);
const canRetry = computed(() => selectedNode.value && ['fail', 'blocked'].includes(selectedNode.value.status));
const remain = computed(() => {
const b = budget.value || {};
const spent = b.spent || {};
return {
calls: Math.max(0, (b.total_agent_calls || 0) - (spent.agent_calls || 0)),
tokens: Math.max(0, (b.total_tokens || 0) - (spent.tokens || 0)),
};
});
const ALLOW = {
start: ['ready', 'paused', 'blocked'],
next: ['running', 'paused'],
pause: ['running'],
stop: ['running', 'paused', 'blocked'],
};
function can(op) { return run.value && ALLOW[op].includes(run.value.run_status); }
async function load() {
if (!props.runId) { run.value = null; nodes.value = []; edges.value = []; return; }
loading.value = true;
try {
const r = await fetch(`/api/graph-runs/${props.runId}`);
if (!r.ok) { err.value = `加载失败 HTTP ${r.status}`; return; }
const j = await r.json();
err.value = '';
run.value = j.run;
nodes.value = j.nodes || [];
edges.value = j.edges || [];
loopRecords.value = j.loopRecords || [];
budget.value = j.budget || null;
mode.value = j.run?.execution_mode || 'manual';
push();
} catch (e) {
err.value = e.message;
} finally {
loading.value = false;
}
}
function push() {
emit('state', {
run: run.value, nodes: nodes.value, edges: edges.value,
loopRecords: loopRecords.value, budget: budget.value, events: events.value,
});
}
/** 控制操作统一入口:五个端点形态一致,失败时把后端给的 error 直接显示(它已经写清了为什么不可用) */
async function act(op, body) {
busy.value = true;
try {
const url = op === 'retry'
? `/api/graph-runs/${props.runId}/nodes/${selectedNode.value.id}/retry`
: `/api/graph-runs/${props.runId}/${op}`;
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
const j = await r.json().catch(() => ({}));
err.value = r.ok ? '' : (j.error || `HTTP ${r.status}`);
} catch (e) {
err.value = e.message;
} finally {
busy.value = false;
await load();
}
}
function onStart() {
if (mode.value === 'auto') { confirmAuto.value = true; return; }
act('start', { mode: 'manual' });
}
function onRetry() { if (canRetry.value) act('retry'); }
function onSelect(node) {
selectedDefId.value = String(node.defNodeId);
emit('select', node);
}
// ── SSE ────────────────────────────────────────────────────────────
function connect() {
disconnect();
if (!props.runId) return;
sseStatus.value = 'connecting';
try {
// lastEventId=0 → 服务端 replayTo 从头重放 stream.log,历史 + 实时一条连接搞定
es = new EventSource(`/api/graph-runs/${props.runId}/stream?lastEventId=0`);
} catch { sseStatus.value = 'error'; return; }
es.onopen = () => { sseStatus.value = 'connected'; };
es.onerror = () => { sseStatus.value = 'reconnecting'; }; // EventSource 自带重连,不手工重建
for (const k of ['node.status', 'run.status', 'loop.registered', 'budget.spent', 'agent.call', 'node.output']) {
es.addEventListener(k, (ev) => onEvent(k, ev));
}
es.onmessage = (ev) => onEvent(ev.type || 'message', ev);
}
function onEvent(kind, ev) {
let data = {};
try { data = JSON.parse(ev.data); } catch { data = { raw: ev.data }; }
events.value.push({ id: ev.lastEventId, kind, data, ts: data.ts || Date.now() });
if (events.value.length > MAX_EVENTS) events.value.splice(0, events.value.length - MAX_EVENTS);
// 状态类事件去抖后重拉一次全量——SSE 帧只带增量,画布要的是一致快照
if (kind === 'node.status' || kind === 'run.status' || kind === 'loop.registered') {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(load, 300);
} else {
push();
}
}
function disconnect() {
if (es) { try { es.close(); } catch { /* ignore */ } es = null; }
clearTimeout(refreshTimer);
}
// ponytail: 引擎侧的 SSE 接线(T5→T2)尚未完成时事件轨是空的,靠这个 6s 轮询兜底,
// 只在 run 真的在跑的时候转。等引擎接上 emitTransition 后可以摘掉。
function startPoll() {
clearInterval(pollTimer);
pollTimer = setInterval(() => {
if (run.value && ['running', 'planning'].includes(run.value.run_status)) load();
}, 6000);
}
watch(() => props.runId, () => {
selectedDefId.value = null;
events.value = [];
load();
connect();
}, { immediate: true });
startPoll();
onBeforeUnmount(() => { disconnect(); clearInterval(pollTimer); });
function fmtNum(n) { return (n || 0).toLocaleString(); }
</script>
<style scoped>
.pane { display:flex; flex-direction:column; gap:8px; min-width:0; }
.pane-head { display:flex; align-items:center; gap:8px; font-size:13px; }
.ttl { font-weight:700; }
.grow { flex:1; }
.rid { font-family: ui-monospace, monospace; font-size:10px; color:#aaa; max-width:130px; overflow:hidden; text-overflow:ellipsis; }
.run-status { font-size:10px; padding:1px 7px; border-radius:9px; background:#eee; color:#555; text-transform:uppercase; }
.run-status.running { background:#cce5ff; color:#004085; }
.run-status.completed { background:#d4edda; color:#155724; }
.run-status.blocked { background:#ffe8cc; color:#a35b00; }
.run-status.failed { background:#f8d7da; color:#721c24; }
.run-status.paused { background:#fff3cd; color:#856404; }
.conn { font-size:11px; color:#ccc; }
.conn.connected { color:#0a6; }
.conn.reconnecting, .conn.connecting { color:#c70; }
.conn.error { color:#c00; }
.ctrl { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
.ctrl select { font-size:12px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; background:#fff; }
.btn, .mini {
border:1px solid #ddd; background:#fff; border-radius:5px; cursor:pointer;
font-size:12px; padding:4px 10px; color:#333;
}
.mini { font-size:11px; padding:2px 8px; color:#777; }
.btn:hover:not(:disabled) { border-color:#999; }
.btn:disabled { opacity:.4; cursor:not-allowed; }
.btn.go { background:#1a7f37; border-color:#1a7f37; color:#fff; }
.btn.retry { border-color:#c96a00; color:#c96a00; }
.next-hint { font-size:11px; color:#666; }
.next-hint code { background:#eef4ff; padding:1px 5px; border-radius:3px; }
.err { color:#c00; font-size:12px; margin:0; background:#fff5f5; border-left:3px solid #c00; padding:5px 8px; }
.compact .ctrl .btn { padding:3px 7px; font-size:11px; }
.modal-back { position:fixed; inset:0; background:rgba(0,0,0,.35); display:flex; align-items:center; justify-content:center; z-index:950; }
.modal { background:#fff; border-radius:10px; padding:20px 24px; width:460px; max-width:92vw; box-shadow:0 12px 40px rgba(0,0,0,.2); }
.modal h3 { margin:0 0 8px; font-size:15px; }
.lead { font-size:13px; color:#555; margin:0 0 10px; }
.est { font-size:12.5px; color:#444; padding-left:18px; margin:0 0 16px; }
.est li { margin-bottom:4px; }
.modal-btns { display:flex; justify-content:flex-end; gap:8px; }
</style>