GraphConsole.vue
11.9 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
285
<template>
<div class="console">
<!-- 页头:公司 + 常驻预算条(§四.B.4) -->
<header class="top">
<div class="who">
<router-link :to="`/${companyId}/overview`" class="back">←</router-link>
<b>{{ company?.name || companyId }}</b>
<span class="sep">组织与执行</span>
</div>
<div class="budget" v-if="budget">
<span class="chip">
<i>calls</i> {{ spent.agent_calls || 0 }}/{{ budget.total_agent_calls || '∞' }}
<em class="bar"><em :style="{ width: pct(spent.agent_calls, budget.total_agent_calls) }" /></em>
</span>
<span class="chip">
<i>tokens</i> {{ fmt(spent.tokens) }}/{{ fmt(budget.total_tokens) }}
</span>
<span class="chip"><i>墙钟</i> {{ elapsed }}</span>
</div>
<div class="budget" v-else>
<span class="chip muted">无 root run 预算</span>
</div>
</header>
<!-- 面包屑:公司 › org-coder › (子图)—— 与 §四.B.1 的图一致,点任一层回到那一层 -->
<nav class="crumbs">
<button class="crumb" :class="{ on: !stack.length }" @click="popTo(-1)">公司</button>
<template v-for="(c, i) in stack" :key="c.runId">
<span class="arrow">›</span>
<button class="crumb" :class="{ on: i === stack.length - 1 }" @click="popTo(i)">{{ c.label }}</button>
</template>
</nav>
<div class="cols">
<!-- 左栏:org graph。与中栏是同一个画布组件的两个实例(§四.B.1 / R-15.4 的前端收益) -->
<aside class="col left">
<GraphRunPane
v-if="rootRun"
:run-id="rootRun.id"
title="Org Graph"
compact
@select="onOrgSelect"
@state="onRootState"
/>
<template v-else>
<header class="pane-head"><span class="ttl">Org Graph</span><span class="tag">定义(未执行)</span></header>
<p class="hint">这家公司还没有 graph run,下面是 org graph 定义本身,节点状态一律 pending。</p>
<WorkGraphCanvas :nodes="defNodes" :edges="defEdges" @select="onOrgSelect">
<template #empty>这家公司还没有 org graph,先跑 orggraph-planning。</template>
</WorkGraphCanvas>
</template>
</aside>
<!-- 中栏:选中 org node 的 work graph(下钻后是子 run) -->
<main class="col mid">
<div v-if="!currentRunId" class="empty-state">
<template v-if="orgNode">
<!-- §四.B.5 空态:从未跑过 work graph,不报错不留白 -->
<div class="es-icon">◔</div>
<h3>{{ orgNode.name || orgNode.defNodeId }} 还没有跑过 work graph</h3>
<p>
org node 的 work graph 由 <code>workgraph-planning</code> 在它接到任务时现规划(§四.A.1)。
这个节点被 <code>advanceOne</code> 拾取执行后,这里会出现它的图。
</p>
</template>
<template v-else>
<div class="es-icon">◧</div>
<h3>从左栏选一个 org node</h3>
<p>中栏显示该 org node 这次任务里跑的 work graph;控制按钮作用于当前这一层。</p>
</template>
</div>
<template v-else>
<div v-if="historyRuns.length > 1" class="history">
<label>历史 work run</label>
<select :value="stack[0]?.runId" @change="switchHistory($event.target.value)">
<option v-for="r in historyRuns" :key="r.id" :value="r.id">
{{ r.created_at }} · {{ r.run_status }} · {{ r.id }}
</option>
</select>
</div>
<GraphRunPane
:key="currentRunId"
:run-id="currentRunId"
:title="stack[stack.length - 1]?.label || 'Work Graph'"
@select="onNodeSelect"
@state="onMidState"
>
<template #empty>这张 work graph 还没有节点。</template>
</GraphRunPane>
</template>
</main>
<!-- 右栏:节点详情四 tab -->
<aside class="col right">
<NodeDetailPanel
:node="selectedNode"
:loop-records="midState.loopRecords"
:events="midState.events"
@dive="dive"
/>
</aside>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router';
import WorkGraphCanvas from '@/components/WorkGraphCanvas.vue';
import GraphRunPane from '@/components/graph-console/GraphRunPane.vue';
import NodeDetailPanel from '@/components/graph-console/NodeDetailPanel.vue';
/**
* 执行控制台(T3 + T4,§四.B)。三栏:org graph / work graph 画布 / 节点详情。
*
* 层级模型直接对应 §四.B.1 的面包屑「公司 › org-coder › (子图)」:
* stack = [] → 公司层(中栏提示选节点)
* stack = [work run] → 某个 org node 的 work graph
* stack = [work run, 子 run, …] → 逐层下钻,每层都是完整可交互的 pane(自带五个控制按钮)
*/
const route = useRoute();
const props = defineProps({ id: { type: String, default: '' } });
const companyId = computed(() => props.id || route.params.id);
const company = ref(null);
const rootRun = ref(null);
const allRuns = ref([]);
const defNodes = ref([]);
const defEdges = ref([]);
const orgNode = ref(null); // 左栏选中的 org node(run 状态合并后的对象)
const stack = ref([]); // 中栏层级栈
const selectedNode = ref(null); // 右栏详情对象
const rootState = ref({});
const midState = ref({ loopRecords: [], events: [] });
const currentRunId = computed(() => (stack.value.length ? stack.value[stack.value.length - 1].runId : null));
const budget = computed(() => rootState.value.budget || null);
const spent = computed(() => budget.value?.spent || {});
/** 同一个 org node 生命周期里可能跑过多张 work graph,默认最新、下拉切历史(§四.B.1) */
const historyRuns = computed(() =>
orgNode.value ? allRuns.value.filter((r) => r.parent_node_run_id === orgNode.value.id) : []);
const now = ref(Date.now());
let tick = null;
const elapsed = computed(() => {
const s0 = spent.value.started_at;
if (!s0) return '—';
const ms = now.value - Date.parse(s0);
if (!Number.isFinite(ms) || ms < 0) return '—';
const m = Math.floor(ms / 60000);
return m < 60 ? `${m}min` : `${Math.floor(m / 60)}h ${m % 60}min`;
});
onMounted(async () => {
tick = setInterval(() => { now.value = Date.now(); }, 30000);
await Promise.all([loadCompany(), loadRuns(), loadOrgDef()]);
});
onBeforeUnmount(() => clearInterval(tick));
async function loadCompany() {
try {
const r = await fetch(`/api/company/${companyId.value}`);
if (r.ok) company.value = await r.json();
} catch { /* 页头名字掉了不影响功能 */ }
}
async function loadRuns() {
try {
const r = await fetch(`/api/company/${companyId.value}/graph-runs`);
if (!r.ok) return;
const j = await r.json();
allRuns.value = j.runs || [];
// v1 一家公司同时只有一个 active root run(§一非目标);没有 active 的就取最近一个 root
const roots = allRuns.value.filter((x) => !x.parent_run_id);
rootRun.value = roots.find((x) => ['ready', 'running', 'paused'].includes(x.run_status)) || roots[0] || null;
} catch { /* 无 run 时左栏退到定义视图 */ }
}
/** 没有 run 时左栏用 org graph 定义画一张静态图,形态转成画布要的字段(org 节点全是 subgraph executor,§四.D.0) */
async function loadOrgDef() {
try {
const r = await fetch(`/api/company/${companyId.value}/org-graph`);
if (!r.ok) return;
const j = await r.json();
defNodes.value = (j.nodes || []).map((n) => ({
defNodeId: String(n.id), name: n.roleName, systemAgentId: n.systemAgentId,
nodeKind: 'subgraph', status: 'pending', attempt: 0,
}));
defEdges.value = (j.edges || []).map((e) => ({
from: String(e.from), to: String(e.to), condition: e.condition, edgeKind: e.edgeKind,
}));
} catch { /* ignore */ }
}
function onRootState(s) { rootState.value = s; }
function onMidState(s) {
midState.value = s;
// 中栏刷新后把右栏详情指向同一条最新记录,否则详情面板会停在旧快照上
if (selectedNode.value) {
const fresh = (s.nodes || []).find((n) => n.id === selectedNode.value.id);
if (fresh) selectedNode.value = fresh;
}
}
function onOrgSelect(node) {
orgNode.value = node;
selectedNode.value = node;
const runId = node.childRunId || historyRuns.value[0]?.id || null;
stack.value = runId ? [{ runId, label: node.name || node.defNodeId }] : [];
}
function onNodeSelect(node) { selectedNode.value = node; }
/** 「子图」tab 的下钻:把子 run 推到栈顶,那一层的控制按钮就作用在子 run 上(§四.B.3) */
function dive({ runId, label }) {
if (!runId) return;
stack.value = [...stack.value, { runId, label: `${label || '子图'} ›子图` }];
selectedNode.value = null;
}
function popTo(i) {
stack.value = i < 0 ? [] : stack.value.slice(0, i + 1);
selectedNode.value = null;
if (i < 0) orgNode.value = null;
}
function switchHistory(runId) {
const label = stack.value[0]?.label || 'work graph';
stack.value = [{ runId, label }];
selectedNode.value = null;
}
function pct(a, b) { return b ? `${Math.min(100, Math.round(((a || 0) / b) * 100))}%` : '0%'; }
function fmt(n) { return n == null ? '∞' : Number(n).toLocaleString(); }
</script>
<style scoped>
.console { display:flex; flex-direction:column; height:100vh; background:#fff; color:#1a1a1a; }
.top {
display:flex; align-items:center; gap:16px; padding:10px 18px;
border-bottom:1px solid #eee; flex:0 0 auto;
}
.who { display:flex; align-items:center; gap:9px; font-size:14px; }
.back { text-decoration:none; color:#888; font-size:16px; }
.sep { color:#999; font-size:12px; }
.budget { margin-left:auto; display:flex; gap:10px; align-items:center; }
.chip { font-size:11px; color:#444; background:#f7f7f9; border-radius:12px; padding:3px 10px; display:flex; align-items:center; gap:6px; }
.chip i { color:#999; font-style:normal; }
.chip.muted { color:#aaa; }
.bar { display:block; width:56px; height:4px; background:#e4e4e8; border-radius:2px; overflow:hidden; }
.bar em { display:block; height:100%; background:#2b6cb0; }
.crumbs { display:flex; align-items:center; gap:6px; padding:7px 18px; border-bottom:1px solid #f2f2f4; flex:0 0 auto; }
.crumb { border:none; background:none; cursor:pointer; font-size:12px; color:#2b6cb0; padding:2px 4px; }
.crumb.on { color:#1a1a1a; font-weight:600; cursor:default; }
.arrow { color:#ccc; font-size:12px; }
.cols { flex:1; display:grid; grid-template-columns:320px minmax(0, 1fr) 360px; min-height:0; }
.col { padding:12px 14px; overflow:auto; min-width:0; }
.left { border-right:1px solid #eee; background:#fcfcfd; }
.right { border-left:1px solid #eee; }
.pane-head { display:flex; align-items:center; gap:8px; font-size:13px; margin-bottom:6px; }
.ttl { font-weight:700; }
.tag { font-size:10px; color:#999; background:#f0f0f2; padding:1px 7px; border-radius:9px; }
.hint { font-size:11px; color:#999; margin:0 0 8px; line-height:1.6; }
.history { display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:12px; color:#666; }
.history select { font-size:11px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; background:#fff; max-width:100%; }
.empty-state { text-align:center; color:#888; padding:60px 24px; }
.es-icon { font-size:34px; color:#d8d8dc; }
.empty-state h3 { font-size:14px; color:#555; margin:10px 0 6px; }
.empty-state p { font-size:12px; line-height:1.8; max-width:460px; margin:0 auto; }
.empty-state code { background:#f2f2f4; padding:1px 5px; border-radius:3px; }
@media (max-width: 1100px) {
.cols { grid-template-columns:1fr; }
.left, .right { border:none; border-bottom:1px solid #eee; }
}
</style>