dsh-plugin-agentflow 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -2
- package/cordis.patch.yml +195 -0
- package/index.js +448 -0
- package/lib/bridge.js +858 -0
- package/lib/client.js +4 -0
- package/package.json +42 -4
- package/runtime/boards.mjs +486 -0
- package/runtime/bridge.mjs +230 -0
- package/runtime/dsh-source.mjs +289 -0
- package/runtime/engine.mjs +352 -0
- package/runtime/index.html +3299 -0
- package/runtime/llm.mjs +252 -0
- package/runtime/mcp.mjs +823 -0
- package/runtime/preset-gen.mjs +1117 -0
- package/runtime/presets.mjs +399 -0
- package/runtime/run.mjs +76 -0
- package/runtime/server.mjs +1151 -0
- package/runtime/skills.mjs +131 -0
- package/runtime/store.mjs +103 -0
- package/runtime/verify.mjs +219 -0
- package/runtime/web/dist/assets/index-CkM6YPl4.js +348 -0
- package/runtime/web/dist/assets/index-DEZcpxgr.css +1 -0
- package/runtime/web/dist/index.html +13 -0
- package/scripts/build-client.mjs +36 -0
- package/scripts/sync-runtime.mjs +84 -0
|
@@ -0,0 +1,3299 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<link rel="icon" href="data:,">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>AgentFlow · 物料画布</title>
|
|
8
|
+
<style>
|
|
9
|
+
/* ==========================================================================
|
|
10
|
+
基础与浅色主题变量
|
|
11
|
+
用户明确要求浅色、绝不做深色,所以这里强制 color-scheme: light,
|
|
12
|
+
避免系统深色模式把 textarea / select 染黑。
|
|
13
|
+
========================================================================== */
|
|
14
|
+
:root {
|
|
15
|
+
color-scheme: light;
|
|
16
|
+
--bg: #f5f6f8;
|
|
17
|
+
--card: #ffffff;
|
|
18
|
+
--ink: #1f2430;
|
|
19
|
+
--muted: #7a8494;
|
|
20
|
+
--line: #e6e9ef;
|
|
21
|
+
--blue: #1565c0;
|
|
22
|
+
--blue-strong: #1976d2;
|
|
23
|
+
--green: #2e7d32;
|
|
24
|
+
--red: #c62828;
|
|
25
|
+
--gray: #9e9e9e;
|
|
26
|
+
--shadow: 0 2px 10px rgba(30, 40, 60, 0.08);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
* { box-sizing: border-box; }
|
|
30
|
+
html, body { height: 100%; margin: 0; }
|
|
31
|
+
body {
|
|
32
|
+
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB",
|
|
33
|
+
"Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
|
34
|
+
font-size: 15px;
|
|
35
|
+
line-height: 1.65;
|
|
36
|
+
color: var(--ink);
|
|
37
|
+
background: var(--bg);
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
-webkit-font-smoothing: antialiased;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
button { font: inherit; }
|
|
43
|
+
.hidden { display: none !important; }
|
|
44
|
+
|
|
45
|
+
/* ---------- 通用按钮 ---------- */
|
|
46
|
+
.btn {
|
|
47
|
+
height: 34px;
|
|
48
|
+
padding: 0 14px;
|
|
49
|
+
border: 1px solid #dfe3ea;
|
|
50
|
+
border-radius: 9px;
|
|
51
|
+
background: #fff;
|
|
52
|
+
color: #3c4658;
|
|
53
|
+
font-size: 14px;
|
|
54
|
+
line-height: 1;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
transition: background .15s, border-color .15s, color .15s, box-shadow .15s;
|
|
57
|
+
white-space: nowrap;
|
|
58
|
+
}
|
|
59
|
+
.btn:hover { background: #f2f6fd; border-color: #c9d9f5; color: var(--blue); }
|
|
60
|
+
.btn:active { transform: translateY(1px); }
|
|
61
|
+
.btn:disabled { opacity: .55; cursor: not-allowed; transform: none; }
|
|
62
|
+
.btn.primary {
|
|
63
|
+
background: var(--blue-strong);
|
|
64
|
+
border-color: var(--blue-strong);
|
|
65
|
+
color: #fff;
|
|
66
|
+
box-shadow: 0 2px 8px rgba(25, 118, 210, .22);
|
|
67
|
+
}
|
|
68
|
+
.btn.primary:hover { background: var(--blue); border-color: var(--blue); color: #fff; }
|
|
69
|
+
.btn.danger { color: var(--red); border-color: #f2d4d4; }
|
|
70
|
+
.btn.danger:hover { background: #fff5f5; border-color: #eab6b6; color: #b71c1c; }
|
|
71
|
+
.btn.small { height: 28px; font-size: 13px; padding: 0 12px; margin-top: 10px; }
|
|
72
|
+
|
|
73
|
+
/* ==========================================================================
|
|
74
|
+
顶栏
|
|
75
|
+
========================================================================== */
|
|
76
|
+
#topbar {
|
|
77
|
+
position: fixed;
|
|
78
|
+
top: 0; left: 0; right: 0;
|
|
79
|
+
height: 56px;
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: 12px;
|
|
83
|
+
padding: 0 18px;
|
|
84
|
+
background: rgba(255, 255, 255, .94);
|
|
85
|
+
-webkit-backdrop-filter: blur(8px);
|
|
86
|
+
backdrop-filter: blur(8px);
|
|
87
|
+
border-bottom: 1px solid var(--line);
|
|
88
|
+
z-index: 100;
|
|
89
|
+
}
|
|
90
|
+
.brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
|
91
|
+
.brand .logo {
|
|
92
|
+
width: 27px; height: 27px;
|
|
93
|
+
display: grid; place-items: center;
|
|
94
|
+
border-radius: 8px;
|
|
95
|
+
background: #e8f0fe;
|
|
96
|
+
color: var(--blue);
|
|
97
|
+
font-size: 15px;
|
|
98
|
+
flex: none;
|
|
99
|
+
}
|
|
100
|
+
.board-name {
|
|
101
|
+
font-weight: 600;
|
|
102
|
+
font-size: 15px;
|
|
103
|
+
max-width: 340px;
|
|
104
|
+
overflow: hidden;
|
|
105
|
+
text-overflow: ellipsis;
|
|
106
|
+
white-space: nowrap;
|
|
107
|
+
}
|
|
108
|
+
.top-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
|
109
|
+
.conn-dot {
|
|
110
|
+
width: 8px; height: 8px;
|
|
111
|
+
border-radius: 50%;
|
|
112
|
+
background: #c3c9d4;
|
|
113
|
+
display: inline-block;
|
|
114
|
+
flex: none;
|
|
115
|
+
}
|
|
116
|
+
.conn-dot.ok { background: #43a047; }
|
|
117
|
+
.conn-dot.err { background: #e53935; }
|
|
118
|
+
.mode-badge {
|
|
119
|
+
font-size: 13px;
|
|
120
|
+
line-height: 1.5;
|
|
121
|
+
padding: 2px 10px;
|
|
122
|
+
border-radius: 999px;
|
|
123
|
+
background: #fff8e1;
|
|
124
|
+
color: #a06a00;
|
|
125
|
+
border: 1px solid #ffe9b0;
|
|
126
|
+
white-space: nowrap;
|
|
127
|
+
}
|
|
128
|
+
.mode-badge.real { background: #e8f5e9; color: var(--green); border-color: #c6e7c9; }
|
|
129
|
+
.mode-badge.off { background: #ffebee; color: var(--red); border-color: #ffd1d1; }
|
|
130
|
+
|
|
131
|
+
/* ==========================================================================
|
|
132
|
+
无限画布
|
|
133
|
+
点阵网格铺在 #viewport 上,随 pan/zoom 更新 background-size/position;
|
|
134
|
+
所有卡片放在 #world 里,统一用 translate + scale 变换。
|
|
135
|
+
========================================================================== */
|
|
136
|
+
#viewport {
|
|
137
|
+
position: fixed;
|
|
138
|
+
top: 56px; left: 0; right: 0; bottom: 0;
|
|
139
|
+
overflow: hidden;
|
|
140
|
+
background-color: var(--bg);
|
|
141
|
+
background-image: radial-gradient(circle at 1px 1px, #d2d8e1 1.1px, transparent 1.1px);
|
|
142
|
+
cursor: grab;
|
|
143
|
+
}
|
|
144
|
+
#viewport.panning { cursor: grabbing; }
|
|
145
|
+
#viewport, .card-content { -webkit-user-select: none; user-select: none; }
|
|
146
|
+
.card-content { -webkit-user-select: text; user-select: text; }
|
|
147
|
+
|
|
148
|
+
#world {
|
|
149
|
+
position: absolute;
|
|
150
|
+
left: 0; top: 0;
|
|
151
|
+
width: 0; height: 0;
|
|
152
|
+
transform-origin: 0 0;
|
|
153
|
+
will-change: transform;
|
|
154
|
+
}
|
|
155
|
+
#edges {
|
|
156
|
+
position: absolute;
|
|
157
|
+
left: 0; top: 0;
|
|
158
|
+
overflow: visible;
|
|
159
|
+
pointer-events: none;
|
|
160
|
+
z-index: 0;
|
|
161
|
+
}
|
|
162
|
+
#edges path {
|
|
163
|
+
fill: none;
|
|
164
|
+
stroke: #c7cede;
|
|
165
|
+
stroke-width: 1.8;
|
|
166
|
+
}
|
|
167
|
+
#edges path.edge-hi { stroke: #64a0f0; stroke-width: 2.4; }
|
|
168
|
+
#edges path.edge-bad { stroke: #e0a3a3; stroke-dasharray: 5 5; }
|
|
169
|
+
|
|
170
|
+
/* ==========================================================================
|
|
171
|
+
物料卡片
|
|
172
|
+
左侧 3px 状态色条 + 标题行(图标 / 名字 / 状态角标 / + 按钮)
|
|
173
|
+
+ 内容 textarea + 参数行。
|
|
174
|
+
========================================================================== */
|
|
175
|
+
.card {
|
|
176
|
+
position: absolute;
|
|
177
|
+
width: 320px;
|
|
178
|
+
background: var(--card);
|
|
179
|
+
border: 1px solid var(--line);
|
|
180
|
+
border-radius: 12px;
|
|
181
|
+
box-shadow: var(--shadow);
|
|
182
|
+
overflow: hidden;
|
|
183
|
+
z-index: 1;
|
|
184
|
+
}
|
|
185
|
+
.card.selected {
|
|
186
|
+
box-shadow: 0 0 0 2px rgba(74, 134, 232, .45), 0 8px 20px rgba(30, 60, 120, .14);
|
|
187
|
+
}
|
|
188
|
+
.card::before {
|
|
189
|
+
content: "";
|
|
190
|
+
position: absolute;
|
|
191
|
+
left: 0; top: 0; bottom: 0;
|
|
192
|
+
width: 3px;
|
|
193
|
+
background: #d5d9e2;
|
|
194
|
+
}
|
|
195
|
+
.card[data-status="running"]::before { background: var(--blue-strong); }
|
|
196
|
+
.card[data-status="done"]::before { background: #43a047; }
|
|
197
|
+
.card[data-status="failed"]::before { background: #e53935; }
|
|
198
|
+
.card[data-status="blocked"]::before { background: #bdbdbd; }
|
|
199
|
+
|
|
200
|
+
/* running:蓝色呼吸动画 */
|
|
201
|
+
.card[data-status="running"] {
|
|
202
|
+
animation: breathe 1.8s ease-in-out infinite;
|
|
203
|
+
}
|
|
204
|
+
@keyframes breathe {
|
|
205
|
+
0%, 100% { box-shadow: 0 0 0 2px rgba(25, 118, 210, .10), 0 2px 10px rgba(30, 40, 60, .08); }
|
|
206
|
+
50% { box-shadow: 0 0 0 7px rgba(25, 118, 210, .20), 0 2px 10px rgba(30, 40, 60, .08); }
|
|
207
|
+
}
|
|
208
|
+
/* blocked:灰色虚线卡片 */
|
|
209
|
+
.card[data-status="blocked"] {
|
|
210
|
+
border-style: dashed;
|
|
211
|
+
border-color: #d9d9d9;
|
|
212
|
+
background: #fcfcfc;
|
|
213
|
+
}
|
|
214
|
+
.card.dragging { cursor: grabbing; }
|
|
215
|
+
|
|
216
|
+
.card-head {
|
|
217
|
+
display: flex;
|
|
218
|
+
align-items: center;
|
|
219
|
+
gap: 8px;
|
|
220
|
+
padding: 11px 12px 2px 15px;
|
|
221
|
+
}
|
|
222
|
+
.card-icon {
|
|
223
|
+
width: 18px;
|
|
224
|
+
flex: none;
|
|
225
|
+
text-align: center;
|
|
226
|
+
font-size: 14px;
|
|
227
|
+
color: #5f6b80;
|
|
228
|
+
}
|
|
229
|
+
.card-label {
|
|
230
|
+
flex: 1;
|
|
231
|
+
min-width: 0;
|
|
232
|
+
font-weight: 600;
|
|
233
|
+
font-size: 15px;
|
|
234
|
+
line-height: 1.4;
|
|
235
|
+
overflow: hidden;
|
|
236
|
+
text-overflow: ellipsis;
|
|
237
|
+
white-space: nowrap;
|
|
238
|
+
}
|
|
239
|
+
/* 状态角标:浅底深字 */
|
|
240
|
+
.badge {
|
|
241
|
+
flex: none;
|
|
242
|
+
font-size: 12px;
|
|
243
|
+
line-height: 1.5;
|
|
244
|
+
padding: 0 8px;
|
|
245
|
+
border-radius: 999px;
|
|
246
|
+
background: #f0f2f5;
|
|
247
|
+
color: #6b7280;
|
|
248
|
+
white-space: nowrap;
|
|
249
|
+
}
|
|
250
|
+
.card[data-status="done"] .badge { background: #e8f5e9; color: #2e7d32; }
|
|
251
|
+
.card[data-status="running"] .badge { background: #e3f2fd; color: #1565c0; }
|
|
252
|
+
.card[data-status="failed"] .badge { background: #ffebee; color: #c62828; }
|
|
253
|
+
.card[data-status="blocked"] .badge { background: #eeeeee; color: #9e9e9e; }
|
|
254
|
+
|
|
255
|
+
.icon-btn.add {
|
|
256
|
+
flex: none;
|
|
257
|
+
width: 24px; height: 24px;
|
|
258
|
+
padding: 0;
|
|
259
|
+
border: 1px solid #dfe3ea;
|
|
260
|
+
border-radius: 8px;
|
|
261
|
+
background: #fff;
|
|
262
|
+
color: #5b6472;
|
|
263
|
+
font-size: 16px;
|
|
264
|
+
line-height: 21px;
|
|
265
|
+
text-align: center;
|
|
266
|
+
cursor: pointer;
|
|
267
|
+
transition: background .15s, border-color .15s, color .15s;
|
|
268
|
+
}
|
|
269
|
+
.icon-btn.add:hover { background: #eef4ff; border-color: #bcd3f5; color: var(--blue); }
|
|
270
|
+
/* 左侧「+」= 添加上游,用绿色跟右侧区分 */
|
|
271
|
+
.icon-btn.add.prev { color: #9aa3b2; }
|
|
272
|
+
.icon-btn.add.prev:hover { background: #eef7ee; border-color: #bfe0bf; color: #2e7d32; }
|
|
273
|
+
|
|
274
|
+
/* 内容区:正文 15px / 1.65 行距,高度由 JS 自适应 */
|
|
275
|
+
.card-content {
|
|
276
|
+
display: block;
|
|
277
|
+
width: 100%;
|
|
278
|
+
min-height: 64px;
|
|
279
|
+
max-height: 460px;
|
|
280
|
+
padding: 4px 14px 6px 15px;
|
|
281
|
+
border: none;
|
|
282
|
+
background: transparent;
|
|
283
|
+
resize: none;
|
|
284
|
+
outline: none;
|
|
285
|
+
font-family: inherit;
|
|
286
|
+
font-size: 15px;
|
|
287
|
+
line-height: 1.65;
|
|
288
|
+
color: #2a3140;
|
|
289
|
+
overflow: hidden;
|
|
290
|
+
}
|
|
291
|
+
.card-content:focus { background: #fafcff; }
|
|
292
|
+
.card-content::placeholder { color: #aeb6c4; }
|
|
293
|
+
|
|
294
|
+
/* 参数行:13px */
|
|
295
|
+
.card-meta {
|
|
296
|
+
display: flex;
|
|
297
|
+
flex-wrap: wrap;
|
|
298
|
+
gap: 8px;
|
|
299
|
+
align-items: center;
|
|
300
|
+
padding: 2px 14px 10px 15px;
|
|
301
|
+
font-size: 13px;
|
|
302
|
+
line-height: 1.5;
|
|
303
|
+
color: var(--muted);
|
|
304
|
+
overflow: hidden;
|
|
305
|
+
white-space: nowrap;
|
|
306
|
+
text-overflow: ellipsis;
|
|
307
|
+
}
|
|
308
|
+
.card-error {
|
|
309
|
+
margin: 0 12px 10px 15px;
|
|
310
|
+
padding: 4px 8px;
|
|
311
|
+
border-radius: 6px;
|
|
312
|
+
background: #fff5f5;
|
|
313
|
+
color: var(--red);
|
|
314
|
+
font-size: 13px;
|
|
315
|
+
line-height: 1.5;
|
|
316
|
+
overflow: hidden;
|
|
317
|
+
white-space: nowrap;
|
|
318
|
+
text-overflow: ellipsis;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* ==========================================================================
|
|
322
|
+
空画布引导
|
|
323
|
+
========================================================================== */
|
|
324
|
+
.empty-state {
|
|
325
|
+
position: absolute;
|
|
326
|
+
top: 50%; left: 50%;
|
|
327
|
+
transform: translate(-50%, -50%);
|
|
328
|
+
width: min(430px, calc(100vw - 48px));
|
|
329
|
+
padding: 32px 40px 28px;
|
|
330
|
+
text-align: center;
|
|
331
|
+
background: #fff;
|
|
332
|
+
border: 1px solid var(--line);
|
|
333
|
+
border-radius: 18px;
|
|
334
|
+
box-shadow: 0 12px 32px rgba(20, 30, 50, .08);
|
|
335
|
+
z-index: 5;
|
|
336
|
+
}
|
|
337
|
+
.empty-icon { font-size: 30px; line-height: 1; color: #8fb4ea; margin-bottom: 8px; }
|
|
338
|
+
.empty-state h2 { margin: 0 0 6px; font-size: 17px; font-weight: 600; }
|
|
339
|
+
.empty-state p { margin: 0 0 18px; color: var(--muted); font-size: 14px; line-height: 1.65; }
|
|
340
|
+
.empty-hint { margin-top: 12px; font-size: 13px; color: #9aa3b1; }
|
|
341
|
+
.empty-hint.err { color: var(--red); }
|
|
342
|
+
|
|
343
|
+
/* ==========================================================================
|
|
344
|
+
缩放控件(左下)
|
|
345
|
+
========================================================================== */
|
|
346
|
+
.zoom-box {
|
|
347
|
+
position: fixed;
|
|
348
|
+
left: 18px; bottom: 18px;
|
|
349
|
+
display: flex;
|
|
350
|
+
align-items: center;
|
|
351
|
+
gap: 2px;
|
|
352
|
+
padding: 4px;
|
|
353
|
+
background: #fff;
|
|
354
|
+
border: 1px solid var(--line);
|
|
355
|
+
border-radius: 11px;
|
|
356
|
+
box-shadow: 0 4px 14px rgba(20, 30, 50, .08);
|
|
357
|
+
z-index: 40;
|
|
358
|
+
}
|
|
359
|
+
.zoom-box button {
|
|
360
|
+
height: 30px;
|
|
361
|
+
min-width: 34px;
|
|
362
|
+
padding: 0;
|
|
363
|
+
border: none;
|
|
364
|
+
border-radius: 8px;
|
|
365
|
+
background: none;
|
|
366
|
+
color: #43506a;
|
|
367
|
+
font-size: 15px;
|
|
368
|
+
cursor: pointer;
|
|
369
|
+
}
|
|
370
|
+
.zoom-box button:hover { background: #eef4ff; color: var(--blue); }
|
|
371
|
+
#zoomPct { width: 56px; font-size: 13px; }
|
|
372
|
+
|
|
373
|
+
/* ==========================================================================
|
|
374
|
+
底部浮动设置面板
|
|
375
|
+
========================================================================== */
|
|
376
|
+
#panel {
|
|
377
|
+
position: fixed;
|
|
378
|
+
left: 50%;
|
|
379
|
+
bottom: 18px;
|
|
380
|
+
transform: translateX(-50%);
|
|
381
|
+
width: min(800px, calc(100vw - 36px));
|
|
382
|
+
max-height: calc(100vh - 92px);
|
|
383
|
+
overflow-y: auto;
|
|
384
|
+
padding: 14px 16px 16px;
|
|
385
|
+
background: #fff;
|
|
386
|
+
border: 1px solid var(--line);
|
|
387
|
+
border-radius: 14px;
|
|
388
|
+
box-shadow: 0 14px 38px rgba(20, 30, 50, .16);
|
|
389
|
+
z-index: 50;
|
|
390
|
+
}
|
|
391
|
+
.panel-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
392
|
+
.panel-title { font-size: 14px; font-weight: 600; }
|
|
393
|
+
.panel-id { font-size: 12px; color: #a0a8b6; }
|
|
394
|
+
.panel-status {
|
|
395
|
+
font-size: 12px;
|
|
396
|
+
line-height: 1.5;
|
|
397
|
+
padding: 1px 9px;
|
|
398
|
+
border-radius: 999px;
|
|
399
|
+
background: #f0f2f5;
|
|
400
|
+
color: #6b7280;
|
|
401
|
+
}
|
|
402
|
+
.panel-status[data-status="done"] { background: #e8f5e9; color: #2e7d32; }
|
|
403
|
+
.panel-status[data-status="running"] { background: #e3f2fd; color: #1565c0; }
|
|
404
|
+
.panel-status[data-status="failed"] { background: #ffebee; color: #c62828; }
|
|
405
|
+
.panel-status[data-status="blocked"] { background: #eeeeee; color: #9e9e9e; }
|
|
406
|
+
|
|
407
|
+
.panel-row { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
|
|
408
|
+
.panel-grid {
|
|
409
|
+
display: grid;
|
|
410
|
+
grid-template-columns: 1fr 1fr;
|
|
411
|
+
gap: 10px;
|
|
412
|
+
margin-top: 10px;
|
|
413
|
+
}
|
|
414
|
+
.field { display: flex; flex-direction: column; gap: 3px; font-size: 13px; color: var(--muted); }
|
|
415
|
+
.field > span { font-size: 12px; color: #8a93a3; }
|
|
416
|
+
.field.grow { flex: 1; min-width: 190px; }
|
|
417
|
+
.field.grow input { width: 100%; }
|
|
418
|
+
.field select { width: 180px; }
|
|
419
|
+
/* 面板里的「上游」多选:独占一行,限制高度 */
|
|
420
|
+
.panel-up { margin-top: 12px; }
|
|
421
|
+
.panel-up select { width: 100%; max-width: 460px; height: auto; padding: 4px; }
|
|
422
|
+
.panel-up select option { padding: 3px 6px; border-radius: 6px; }
|
|
423
|
+
.panel-actions { display: flex; gap: 8px; margin-left: auto; }
|
|
424
|
+
|
|
425
|
+
input[type="text"], input[type="password"], select, textarea {
|
|
426
|
+
font-family: inherit;
|
|
427
|
+
font-size: 14px;
|
|
428
|
+
line-height: 1.55;
|
|
429
|
+
color: var(--ink);
|
|
430
|
+
background: #fbfcfe;
|
|
431
|
+
border: 1px solid #e2e6ee;
|
|
432
|
+
border-radius: 9px;
|
|
433
|
+
padding: 7px 10px;
|
|
434
|
+
outline: none;
|
|
435
|
+
transition: border-color .15s, box-shadow .15s, background .15s;
|
|
436
|
+
}
|
|
437
|
+
input[type="text"]:focus, input[type="password"]:focus, select:focus, textarea:focus {
|
|
438
|
+
background: #fff;
|
|
439
|
+
border-color: #7aa7f8;
|
|
440
|
+
box-shadow: 0 0 0 3px rgba(100, 160, 240, .15);
|
|
441
|
+
}
|
|
442
|
+
#fTask, #fCriteria { width: 100%; min-height: 58px; resize: vertical; }
|
|
443
|
+
.panel-close {
|
|
444
|
+
margin-left: auto;
|
|
445
|
+
padding: 2px 7px;
|
|
446
|
+
border: none;
|
|
447
|
+
border-radius: 6px;
|
|
448
|
+
background: none;
|
|
449
|
+
color: #8a93a3;
|
|
450
|
+
font-size: 19px;
|
|
451
|
+
line-height: 1;
|
|
452
|
+
cursor: pointer;
|
|
453
|
+
}
|
|
454
|
+
.panel-close:hover { background: #f0f2f5; color: #3c4658; }
|
|
455
|
+
|
|
456
|
+
/* ==========================================================================
|
|
457
|
+
设置弹窗
|
|
458
|
+
========================================================================== */
|
|
459
|
+
.modal {
|
|
460
|
+
position: fixed;
|
|
461
|
+
inset: 0;
|
|
462
|
+
display: flex;
|
|
463
|
+
align-items: center;
|
|
464
|
+
justify-content: center;
|
|
465
|
+
background: rgba(28, 36, 52, .32);
|
|
466
|
+
z-index: 200;
|
|
467
|
+
}
|
|
468
|
+
.modal-card {
|
|
469
|
+
width: min(480px, calc(100vw - 40px));
|
|
470
|
+
padding: 20px 22px 18px;
|
|
471
|
+
background: #fff;
|
|
472
|
+
border-radius: 16px;
|
|
473
|
+
box-shadow: 0 24px 60px rgba(15, 25, 45, .25);
|
|
474
|
+
}
|
|
475
|
+
.modal-head { display: flex; align-items: center; margin-bottom: 14px; }
|
|
476
|
+
.modal-head h3 { margin: 0; font-size: 16px; font-weight: 600; }
|
|
477
|
+
.modal-close {
|
|
478
|
+
margin-left: auto;
|
|
479
|
+
padding: 2px 7px;
|
|
480
|
+
border: none;
|
|
481
|
+
border-radius: 6px;
|
|
482
|
+
background: none;
|
|
483
|
+
color: #8a93a3;
|
|
484
|
+
font-size: 19px;
|
|
485
|
+
line-height: 1;
|
|
486
|
+
cursor: pointer;
|
|
487
|
+
}
|
|
488
|
+
.modal-close:hover { background: #f0f2f5; color: #3c4658; }
|
|
489
|
+
.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; font-size: 13px; color: #5f6b80; }
|
|
490
|
+
.form-row > span:first-child { font-weight: 500; color: #4a5568; }
|
|
491
|
+
.form-row input, .form-row textarea { width: 100%; }
|
|
492
|
+
.form-row textarea { resize: vertical; line-height: 1.65; }
|
|
493
|
+
.form-hint { font-size: 12px; color: #98a1b0; }
|
|
494
|
+
.modal-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
|
495
|
+
.modal-card { max-height: 86vh; overflow-y: auto; }
|
|
496
|
+
|
|
497
|
+
/* mock 开关 */
|
|
498
|
+
.mock-row { flex-direction: row; align-items: center; gap: 8px; cursor: pointer; }
|
|
499
|
+
.mock-row input { width: auto; margin: 0; }
|
|
500
|
+
.mock-row span { font-weight: 500; color: #4a5568; }
|
|
501
|
+
|
|
502
|
+
/* provider 逐项编辑(实际后端的配置形态) */
|
|
503
|
+
.provider-tip { margin: -2px 0 10px; }
|
|
504
|
+
.provider-row {
|
|
505
|
+
margin-bottom: 10px;
|
|
506
|
+
padding: 9px 12px 11px;
|
|
507
|
+
background: #fcfdff;
|
|
508
|
+
border: 1px solid var(--line);
|
|
509
|
+
border-radius: 10px;
|
|
510
|
+
}
|
|
511
|
+
.provider-head {
|
|
512
|
+
display: flex;
|
|
513
|
+
align-items: center;
|
|
514
|
+
margin-bottom: 6px;
|
|
515
|
+
font-size: 13px;
|
|
516
|
+
font-weight: 600;
|
|
517
|
+
color: #3c4658;
|
|
518
|
+
}
|
|
519
|
+
.provider-head .key-state {
|
|
520
|
+
margin-left: auto;
|
|
521
|
+
font-size: 12px;
|
|
522
|
+
font-weight: 400;
|
|
523
|
+
color: #98a1b0;
|
|
524
|
+
}
|
|
525
|
+
.provider-fields { display: grid; grid-template-columns: 1fr 1.3fr; gap: 8px; }
|
|
526
|
+
.provider-fields .field { font-size: 12px; }
|
|
527
|
+
.provider-fields .field.full { grid-column: 1 / -1; }
|
|
528
|
+
.provider-fields .field > span { font-size: 11px; }
|
|
529
|
+
.provider-fields input { width: 100%; }
|
|
530
|
+
|
|
531
|
+
/* ==========================================================================
|
|
532
|
+
结果判断(底部面板单选)
|
|
533
|
+
========================================================================== */
|
|
534
|
+
.verify-field { margin-top: 12px; }
|
|
535
|
+
.verify-options {
|
|
536
|
+
display: grid;
|
|
537
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
538
|
+
gap: 8px;
|
|
539
|
+
}
|
|
540
|
+
.verify-opt {
|
|
541
|
+
display: flex;
|
|
542
|
+
flex-direction: column;
|
|
543
|
+
gap: 2px;
|
|
544
|
+
padding: 7px 9px 8px;
|
|
545
|
+
border: 1px solid #e2e6ee;
|
|
546
|
+
border-radius: 9px;
|
|
547
|
+
background: #fbfcfe;
|
|
548
|
+
cursor: pointer;
|
|
549
|
+
transition: background .15s, border-color .15s;
|
|
550
|
+
}
|
|
551
|
+
.verify-opt:hover { background: #f4f8ff; border-color: #c9d9f5; }
|
|
552
|
+
.verify-opt.on { background: #eef4ff; border-color: #9fc0f3; }
|
|
553
|
+
.verify-head { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #3c4658; }
|
|
554
|
+
.verify-head input { margin: 0; accent-color: var(--blue); flex: none; }
|
|
555
|
+
.verify-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
556
|
+
.verify-desc { font-size: 11px; line-height: 1.45; color: #98a1b0; }
|
|
557
|
+
|
|
558
|
+
/* ==========================================================================
|
|
559
|
+
技能 / 工具复选框列表(底部面板)
|
|
560
|
+
原生 checkbox,每项一行;工具按 MCP server 分组,列表最高 180px。
|
|
561
|
+
========================================================================== */
|
|
562
|
+
.panel-multi-grid { margin-top: 12px; }
|
|
563
|
+
.check-list {
|
|
564
|
+
max-height: 180px;
|
|
565
|
+
overflow-y: auto;
|
|
566
|
+
padding: 4px;
|
|
567
|
+
background: #fbfcfe;
|
|
568
|
+
border: 1px solid #e2e6ee;
|
|
569
|
+
border-radius: 9px;
|
|
570
|
+
}
|
|
571
|
+
.check-list:focus-within { border-color: #7aa7f8; box-shadow: 0 0 0 3px rgba(100, 160, 240, .12); }
|
|
572
|
+
.check-item {
|
|
573
|
+
display: flex;
|
|
574
|
+
align-items: center;
|
|
575
|
+
gap: 6px;
|
|
576
|
+
padding: 3px 6px;
|
|
577
|
+
border-radius: 6px;
|
|
578
|
+
cursor: pointer;
|
|
579
|
+
font-size: 13px;
|
|
580
|
+
line-height: 1.5;
|
|
581
|
+
color: #3c4658;
|
|
582
|
+
}
|
|
583
|
+
.check-item:hover { background: #f0f5ff; }
|
|
584
|
+
.check-item:has(input:checked) { background: #e8f0fe; }
|
|
585
|
+
.check-item input { margin: 0; flex: none; accent-color: var(--blue); }
|
|
586
|
+
.check-name {
|
|
587
|
+
flex: none;
|
|
588
|
+
max-width: 46%;
|
|
589
|
+
overflow: hidden;
|
|
590
|
+
text-overflow: ellipsis;
|
|
591
|
+
white-space: nowrap;
|
|
592
|
+
color: #3c4658;
|
|
593
|
+
}
|
|
594
|
+
.check-sep { flex: none; font-size: 12px; color: #b8c0cc; }
|
|
595
|
+
.check-desc {
|
|
596
|
+
flex: 1;
|
|
597
|
+
min-width: 0;
|
|
598
|
+
overflow: hidden;
|
|
599
|
+
text-overflow: ellipsis;
|
|
600
|
+
white-space: nowrap;
|
|
601
|
+
font-size: 12px;
|
|
602
|
+
color: #98a1b0;
|
|
603
|
+
}
|
|
604
|
+
.check-group + .check-group { margin-top: 4px; padding-top: 4px; border-top: 1px dashed #e9edf3; }
|
|
605
|
+
.check-group-head {
|
|
606
|
+
position: sticky;
|
|
607
|
+
top: -4px;
|
|
608
|
+
z-index: 1;
|
|
609
|
+
display: flex;
|
|
610
|
+
align-items: center;
|
|
611
|
+
gap: 6px;
|
|
612
|
+
padding: 3px 6px;
|
|
613
|
+
background: #f4f6fa;
|
|
614
|
+
border-radius: 6px;
|
|
615
|
+
font-size: 12px;
|
|
616
|
+
font-weight: 600;
|
|
617
|
+
color: #5f6b80;
|
|
618
|
+
}
|
|
619
|
+
.check-group-head .mcp-dot { width: 7px; height: 7px; }
|
|
620
|
+
.check-group-count { margin-left: auto; font-weight: 400; font-size: 11px; color: #98a1b0; }
|
|
621
|
+
.check-empty { padding: 10px 8px; font-size: 12px; line-height: 1.6; color: #98a1b0; text-align: center; }
|
|
622
|
+
.check-empty.err { color: var(--red); }
|
|
623
|
+
.field-note { font-size: 12px; line-height: 1.5; color: #98a1b0; }
|
|
624
|
+
.field-note.err { color: var(--red); cursor: pointer; }
|
|
625
|
+
|
|
626
|
+
/* 待人工确认(status=review)时的面板底部按钮 */
|
|
627
|
+
.panel-verify {
|
|
628
|
+
display: flex;
|
|
629
|
+
align-items: center;
|
|
630
|
+
gap: 8px;
|
|
631
|
+
margin-top: 12px;
|
|
632
|
+
padding-top: 10px;
|
|
633
|
+
border-top: 1px dashed #ffe0b2;
|
|
634
|
+
}
|
|
635
|
+
.panel-verify-tip { margin-right: auto; font-size: 12px; color: #b26a00; }
|
|
636
|
+
|
|
637
|
+
/* 卡片上的判定结果行 */
|
|
638
|
+
.card-verdict {
|
|
639
|
+
padding: 0 14px 8px 15px;
|
|
640
|
+
font-size: 12px;
|
|
641
|
+
line-height: 1.5;
|
|
642
|
+
overflow: hidden;
|
|
643
|
+
white-space: nowrap;
|
|
644
|
+
text-overflow: ellipsis;
|
|
645
|
+
}
|
|
646
|
+
.card-verdict.pass { color: var(--green); }
|
|
647
|
+
.card-verdict.fail { color: var(--red); }
|
|
648
|
+
|
|
649
|
+
/* 新增状态 review:待人工确认(橙色) */
|
|
650
|
+
.card[data-status="review"]::before { background: #ef6c00; }
|
|
651
|
+
.card[data-status="review"] .badge { background: #fff8e1; color: #ef6c00; }
|
|
652
|
+
.panel-status[data-status="review"] { background: #fff8e1; color: #ef6c00; }
|
|
653
|
+
|
|
654
|
+
/* ==========================================================================
|
|
655
|
+
MCP 服务器列表(设置弹窗)
|
|
656
|
+
========================================================================== */
|
|
657
|
+
.mcp-block { margin: 4px 0 14px; }
|
|
658
|
+
.mcp-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
659
|
+
.mcp-head .mcp-title { font-size: 13px; font-weight: 600; color: #4a5568; }
|
|
660
|
+
.mcp-head .btn.small { margin: 0 0 0 auto; }
|
|
661
|
+
.mcp-list { display: flex; flex-direction: column; gap: 6px; max-height: 230px; overflow-y: auto; }
|
|
662
|
+
.mcp-row {
|
|
663
|
+
border: 1px solid var(--line);
|
|
664
|
+
border-radius: 10px;
|
|
665
|
+
background: #fcfdff;
|
|
666
|
+
overflow: hidden;
|
|
667
|
+
}
|
|
668
|
+
.mcp-row-head {
|
|
669
|
+
display: flex;
|
|
670
|
+
align-items: center;
|
|
671
|
+
gap: 8px;
|
|
672
|
+
padding: 8px 10px;
|
|
673
|
+
cursor: pointer;
|
|
674
|
+
user-select: none;
|
|
675
|
+
}
|
|
676
|
+
.mcp-row-head:hover { background: #f4f8ff; }
|
|
677
|
+
.mcp-dot { width: 8px; height: 8px; border-radius: 50%; background: #c3c9d4; flex: none; }
|
|
678
|
+
.mcp-dot.ok { background: #43a047; }
|
|
679
|
+
.mcp-dot.bad { background: #e53935; }
|
|
680
|
+
.mcp-name {
|
|
681
|
+
font-size: 13px;
|
|
682
|
+
font-weight: 600;
|
|
683
|
+
color: #3c4658;
|
|
684
|
+
overflow: hidden;
|
|
685
|
+
text-overflow: ellipsis;
|
|
686
|
+
white-space: nowrap;
|
|
687
|
+
min-width: 0;
|
|
688
|
+
}
|
|
689
|
+
.mcp-count { margin-left: auto; font-size: 12px; color: #98a1b0; white-space: nowrap; flex: none; }
|
|
690
|
+
.mcp-caret { font-size: 11px; color: #a0a8b6; transition: transform .15s; flex: none; }
|
|
691
|
+
.mcp-caret.open { transform: rotate(180deg); }
|
|
692
|
+
.mcp-tools { padding: 4px 12px 9px; border-top: 1px dashed #e9edf3; }
|
|
693
|
+
.mcp-tool { padding: 4px 0; font-size: 13px; line-height: 1.5; }
|
|
694
|
+
.mcp-tool-name {
|
|
695
|
+
color: #3c4658;
|
|
696
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
697
|
+
font-size: 12px;
|
|
698
|
+
}
|
|
699
|
+
.mcp-tool-desc { display: block; font-size: 12px; color: #98a1b0; line-height: 1.5; }
|
|
700
|
+
.mcp-err { padding: 2px 12px 9px; font-size: 12px; line-height: 1.55; color: var(--red); word-break: break-all; }
|
|
701
|
+
.mcp-msg { padding: 2px 0; font-size: 12px; line-height: 1.55; color: #98a1b0; word-break: break-all; }
|
|
702
|
+
.mcp-msg.err { color: var(--red); }
|
|
703
|
+
.mcp-add { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #e9edf3; }
|
|
704
|
+
.mcp-add summary { font-size: 13px; color: var(--blue); cursor: pointer; outline: none; }
|
|
705
|
+
.mcp-add-fields { margin-top: 8px; }
|
|
706
|
+
.mcp-add-fields .form-row { margin-bottom: 8px; }
|
|
707
|
+
.mcp-add-foot { display: flex; align-items: center; gap: 8px; }
|
|
708
|
+
.mcp-add-foot .form-hint { margin-right: auto; }
|
|
709
|
+
.mcp-add-foot .btn.small { margin-top: 0; }
|
|
710
|
+
|
|
711
|
+
/* ==========================================================================
|
|
712
|
+
轻提示(所有 fetch 失败都会在这里露出)
|
|
713
|
+
========================================================================== */
|
|
714
|
+
#toasts {
|
|
715
|
+
position: fixed;
|
|
716
|
+
top: 68px;
|
|
717
|
+
left: 50%;
|
|
718
|
+
transform: translateX(-50%);
|
|
719
|
+
display: flex;
|
|
720
|
+
flex-direction: column;
|
|
721
|
+
gap: 8px;
|
|
722
|
+
align-items: center;
|
|
723
|
+
z-index: 300;
|
|
724
|
+
pointer-events: none;
|
|
725
|
+
}
|
|
726
|
+
.toast {
|
|
727
|
+
max-width: min(560px, calc(100vw - 48px));
|
|
728
|
+
padding: 9px 14px;
|
|
729
|
+
background: #fff;
|
|
730
|
+
border: 1px solid var(--line);
|
|
731
|
+
border-left: 3px solid var(--blue-strong);
|
|
732
|
+
border-radius: 10px;
|
|
733
|
+
box-shadow: 0 8px 24px rgba(20, 30, 50, .14);
|
|
734
|
+
font-size: 14px;
|
|
735
|
+
line-height: 1.55;
|
|
736
|
+
color: #333c4d;
|
|
737
|
+
pointer-events: auto;
|
|
738
|
+
animation: toast-in .18s ease-out;
|
|
739
|
+
transition: opacity .25s;
|
|
740
|
+
}
|
|
741
|
+
.toast.error { border-left-color: #e53935; background: #fffafa; color: #a12622; }
|
|
742
|
+
.toast.warn { border-left-color: #f0a020; background: #fffdf7; color: #8a5a00; }
|
|
743
|
+
.toast.ok { border-left-color: #43a047; background: #fafffa; color: #236b28; }
|
|
744
|
+
@keyframes toast-in {
|
|
745
|
+
from { opacity: 0; transform: translateY(-6px); }
|
|
746
|
+
to { opacity: 1; transform: none; }
|
|
747
|
+
}
|
|
748
|
+
</style>
|
|
749
|
+
</head>
|
|
750
|
+
<body>
|
|
751
|
+
|
|
752
|
+
<!-- ====================== 顶栏 ====================== -->
|
|
753
|
+
<header id="topbar">
|
|
754
|
+
<div class="brand">
|
|
755
|
+
<span class="logo">◈</span>
|
|
756
|
+
<span class="board-name" id="boardName">物料画布</span>
|
|
757
|
+
</div>
|
|
758
|
+
<div class="top-actions">
|
|
759
|
+
<span class="conn-dot" id="connDot" title="后端连接状态"></span>
|
|
760
|
+
<span class="mode-badge" id="modeBadge">连接中…</span>
|
|
761
|
+
<button class="btn primary" id="btnRun">▶ 跑全图</button>
|
|
762
|
+
<button class="btn" id="btnSettings">设置</button>
|
|
763
|
+
</div>
|
|
764
|
+
</header>
|
|
765
|
+
|
|
766
|
+
<!-- ====================== 画布 ====================== -->
|
|
767
|
+
<main id="viewport">
|
|
768
|
+
<div id="world">
|
|
769
|
+
<!-- 连线画在卡片下层 -->
|
|
770
|
+
<svg id="edges" xmlns="http://www.w3.org/2000/svg"></svg>
|
|
771
|
+
</div>
|
|
772
|
+
|
|
773
|
+
<!-- 空画布引导 -->
|
|
774
|
+
<div id="empty" class="empty-state">
|
|
775
|
+
<div class="empty-icon">▦</div>
|
|
776
|
+
<h2>画布还是空的</h2>
|
|
777
|
+
<p>每个节点是一张可编辑的产物卡片。<br>拖空白平移,滚轮缩放,卡片可以单独重跑。</p>
|
|
778
|
+
<button class="btn primary" id="btnFirstNode">+ 新建第一个节点</button>
|
|
779
|
+
<div class="empty-hint" id="emptyHint">正在连接后端…</div>
|
|
780
|
+
<button class="btn small hidden" id="btnRetry">重试连接</button>
|
|
781
|
+
</div>
|
|
782
|
+
</main>
|
|
783
|
+
|
|
784
|
+
<!-- ====================== 缩放控件 ====================== -->
|
|
785
|
+
<div class="zoom-box">
|
|
786
|
+
<button id="zoomOut" title="缩小">−</button>
|
|
787
|
+
<button id="zoomReset" title="重置为 100%"><span id="zoomPct">100%</span></button>
|
|
788
|
+
<button id="zoomIn" title="放大">+</button>
|
|
789
|
+
</div>
|
|
790
|
+
|
|
791
|
+
<!-- ====================== 底部选中卡片面板 ====================== -->
|
|
792
|
+
<section id="panel" class="hidden" aria-label="卡片设置">
|
|
793
|
+
<div class="panel-head">
|
|
794
|
+
<span class="panel-title">卡片设置</span>
|
|
795
|
+
<span class="panel-status" id="panelStatus" data-status="idle">空闲</span>
|
|
796
|
+
<span class="panel-id" id="panelId"></span>
|
|
797
|
+
<button class="panel-close" id="btnPanelClose" title="关闭">×</button>
|
|
798
|
+
</div>
|
|
799
|
+
<div class="panel-row">
|
|
800
|
+
<label class="field grow">
|
|
801
|
+
<span>名字 label</span>
|
|
802
|
+
<input id="fLabel" type="text" placeholder="给这张卡片起个名">
|
|
803
|
+
</label>
|
|
804
|
+
<label class="field">
|
|
805
|
+
<span>模型 model</span>
|
|
806
|
+
<select id="fModel"></select>
|
|
807
|
+
</label>
|
|
808
|
+
<div class="panel-actions">
|
|
809
|
+
<button class="btn" id="btnRerun">↻ 重跑此卡</button>
|
|
810
|
+
<button class="btn danger" id="btnDelete">删除</button>
|
|
811
|
+
</div>
|
|
812
|
+
</div>
|
|
813
|
+
<div class="panel-grid">
|
|
814
|
+
<label class="field">
|
|
815
|
+
<span>任务 task</span>
|
|
816
|
+
<textarea id="fTask" placeholder="这张卡要做什么"></textarea>
|
|
817
|
+
</label>
|
|
818
|
+
<label class="field">
|
|
819
|
+
<span>标准 criteria</span>
|
|
820
|
+
<textarea id="fCriteria" placeholder="做到什么程度算完成"></textarea>
|
|
821
|
+
</label>
|
|
822
|
+
</div>
|
|
823
|
+
<!-- 结果判断:跑完后谁来判定是否达标 -->
|
|
824
|
+
<div class="field verify-field">
|
|
825
|
+
<span>结果判断</span>
|
|
826
|
+
<div class="verify-options" id="verifyOptions">
|
|
827
|
+
<label class="verify-opt">
|
|
828
|
+
<span class="verify-head">
|
|
829
|
+
<input type="radio" name="verify" value="">
|
|
830
|
+
<span class="verify-title">不校验</span>
|
|
831
|
+
</span>
|
|
832
|
+
<span class="verify-desc">跑完直接出结果</span>
|
|
833
|
+
</label>
|
|
834
|
+
<label class="verify-opt">
|
|
835
|
+
<span class="verify-head">
|
|
836
|
+
<input type="radio" name="verify" value="human">
|
|
837
|
+
<span class="verify-title">人工</span>
|
|
838
|
+
</span>
|
|
839
|
+
<span class="verify-desc">跑完停住,等你点通过</span>
|
|
840
|
+
</label>
|
|
841
|
+
<label class="verify-opt">
|
|
842
|
+
<span class="verify-head">
|
|
843
|
+
<input type="radio" name="verify" value="jev">
|
|
844
|
+
<span class="verify-title">jev</span>
|
|
845
|
+
</span>
|
|
846
|
+
<span class="verify-desc">调 TypeSafe 判定工具打分</span>
|
|
847
|
+
</label>
|
|
848
|
+
<label class="verify-opt">
|
|
849
|
+
<span class="verify-head">
|
|
850
|
+
<input type="radio" name="verify" value="self">
|
|
851
|
+
<span class="verify-title">模型自行判断</span>
|
|
852
|
+
</span>
|
|
853
|
+
<span class="verify-desc">同一个模型自评是否达标</span>
|
|
854
|
+
</label>
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<!-- 技能 / 工具:复选框列表,勾选即 PATCH(工具按 MCP server 分组) -->
|
|
859
|
+
<div class="panel-grid panel-multi-grid">
|
|
860
|
+
<div class="field panel-multi">
|
|
861
|
+
<span>技能 skills(勾选即挂到这张卡)</span>
|
|
862
|
+
<div class="check-list" id="skillList" role="group" aria-label="技能"></div>
|
|
863
|
+
<span class="field-note" id="skillsHint">正在读取技能…</span>
|
|
864
|
+
</div>
|
|
865
|
+
<div class="field panel-multi">
|
|
866
|
+
<span>工具 tools(MCP 工具,按服务器分组)</span>
|
|
867
|
+
<div class="check-list" id="toolList" role="group" aria-label="工具"></div>
|
|
868
|
+
<span class="field-note" id="toolsHint">正在读取 MCP…</span>
|
|
869
|
+
</div>
|
|
870
|
+
</div>
|
|
871
|
+
<label class="field panel-up">
|
|
872
|
+
<span>上游 input(勾中的卡片会把内容喂给它,可多选;会成环的不让选)</span>
|
|
873
|
+
<select id="fUp" multiple size="3"></select>
|
|
874
|
+
</label>
|
|
875
|
+
<!-- 待人工确认时才显示 -->
|
|
876
|
+
<div class="panel-verify hidden" id="verifyActions">
|
|
877
|
+
<span class="panel-verify-tip">这张卡在等人工确认</span>
|
|
878
|
+
<button class="btn primary" type="button" id="btnVerifyPass">✓ 通过</button>
|
|
879
|
+
<button class="btn danger" type="button" id="btnVerifyFail">↻ 打回重跑</button>
|
|
880
|
+
</div>
|
|
881
|
+
</section>
|
|
882
|
+
|
|
883
|
+
<!-- ====================== 设置弹窗 ====================== -->
|
|
884
|
+
<div id="modal" class="modal hidden">
|
|
885
|
+
<form class="modal-card" id="settingsForm" role="dialog" aria-modal="true" aria-label="设置">
|
|
886
|
+
<div class="modal-head">
|
|
887
|
+
<h3>设置</h3>
|
|
888
|
+
<button class="modal-close" id="btnModalClose" title="关闭">×</button>
|
|
889
|
+
</div>
|
|
890
|
+
<label class="form-row mock-row">
|
|
891
|
+
<input id="cfgMock" type="checkbox">
|
|
892
|
+
<span>mock 模式(不真正调用模型,只返回假输出)</span>
|
|
893
|
+
</label>
|
|
894
|
+
|
|
895
|
+
<!-- 实际后端:按 provider 编辑 baseURL / model / apiKey -->
|
|
896
|
+
<div id="providerBlock" class="hidden">
|
|
897
|
+
<div class="form-hint provider-tip">apiKey 留空表示不修改已保存的值;模型下拉框里每一项就是一个 provider。</div>
|
|
898
|
+
<div id="providerRows"></div>
|
|
899
|
+
</div>
|
|
900
|
+
|
|
901
|
+
<!-- 契约形态:单一 baseURL + 模型列表 -->
|
|
902
|
+
<div id="simpleBlock" class="hidden">
|
|
903
|
+
<label class="form-row">
|
|
904
|
+
<span>baseURL</span>
|
|
905
|
+
<input id="cfgBase" type="text" placeholder="https://api.deepseek.com/v1">
|
|
906
|
+
<span class="form-hint">OpenAI 兼容接口地址,末尾不要带 /chat/completions</span>
|
|
907
|
+
</label>
|
|
908
|
+
<label class="form-row">
|
|
909
|
+
<span>apiKey</span>
|
|
910
|
+
<input id="cfgKey" type="password" placeholder="留空则保持已保存的 key 不变" autocomplete="off">
|
|
911
|
+
<span class="form-hint">只发给本机后端,不会出现在页面源码里</span>
|
|
912
|
+
</label>
|
|
913
|
+
<label class="form-row">
|
|
914
|
+
<span>模型列表</span>
|
|
915
|
+
<textarea id="cfgModels" rows="3" placeholder="每行一个,例如 deepseek-chat"></textarea>
|
|
916
|
+
<span class="form-hint">用于卡片上的模型下拉框</span>
|
|
917
|
+
</label>
|
|
918
|
+
</div>
|
|
919
|
+
|
|
920
|
+
<!-- MCP 服务器:状态 + 工具列表 + 添加 stdio server -->
|
|
921
|
+
<div class="mcp-block" id="mcpBlock">
|
|
922
|
+
<div class="mcp-head">
|
|
923
|
+
<span class="mcp-title">MCP 服务器</span>
|
|
924
|
+
<button class="btn small" type="button" id="btnMcpRefresh">↻ 刷新</button>
|
|
925
|
+
</div>
|
|
926
|
+
<div class="mcp-list" id="mcpList"></div>
|
|
927
|
+
<div class="mcp-msg" id="mcpMsg">正在读取…</div>
|
|
928
|
+
<details class="mcp-add">
|
|
929
|
+
<summary>+ 添加 stdio server</summary>
|
|
930
|
+
<div class="mcp-add-fields">
|
|
931
|
+
<label class="form-row">
|
|
932
|
+
<span>name</span>
|
|
933
|
+
<input id="mcpName" type="text" placeholder="例如 chrome-devtools-mcp" autocomplete="off">
|
|
934
|
+
</label>
|
|
935
|
+
<label class="form-row">
|
|
936
|
+
<span>command</span>
|
|
937
|
+
<input id="mcpCommand" type="text" value="/usr/local/bin/node" placeholder="/usr/local/bin/node">
|
|
938
|
+
</label>
|
|
939
|
+
<label class="form-row">
|
|
940
|
+
<span>args(一行一个)</span>
|
|
941
|
+
<textarea id="mcpArgs" rows="3" placeholder="/path/to/server.js"></textarea>
|
|
942
|
+
</label>
|
|
943
|
+
<div class="mcp-add-foot">
|
|
944
|
+
<span class="form-hint" id="mcpAddHint">添加后会自动重新读取 MCP 状态</span>
|
|
945
|
+
<button class="btn small" type="button" id="btnMcpAdd">添加</button>
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
</details>
|
|
949
|
+
</div>
|
|
950
|
+
|
|
951
|
+
<div class="form-hint" id="cfgMockHint"></div>
|
|
952
|
+
<div class="modal-foot">
|
|
953
|
+
<button class="btn" type="button" id="btnCancelCfg">取消</button>
|
|
954
|
+
<button class="btn primary" type="button" id="btnSaveCfg">保存</button>
|
|
955
|
+
</div>
|
|
956
|
+
</form>
|
|
957
|
+
</div>
|
|
958
|
+
|
|
959
|
+
<!-- ====================== 轻提示 ====================== -->
|
|
960
|
+
<div id="toasts"></div>
|
|
961
|
+
|
|
962
|
+
<noscript>
|
|
963
|
+
<div style="position:fixed;inset:0;display:grid;place-items:center;background:#fff;color:#c62828;font-size:16px;">
|
|
964
|
+
这个页面需要启用 JavaScript 才能显示画布。
|
|
965
|
+
</div>
|
|
966
|
+
</noscript>
|
|
967
|
+
|
|
968
|
+
<script>
|
|
969
|
+
/* ==========================================================================
|
|
970
|
+
AgentFlow 物料画布 —— 单文件前端,零依赖。
|
|
971
|
+
设计原则:节点不是流程步骤,而是一份一直摆在画布上的产物卡片;
|
|
972
|
+
内容可看、可改、可单独重跑、可派生下一张。
|
|
973
|
+
========================================================================== */
|
|
974
|
+
(function () {
|
|
975
|
+
'use strict';
|
|
976
|
+
|
|
977
|
+
/* ------------------------------ 常量 ------------------------------ */
|
|
978
|
+
var CARD_W = 320; // 卡片宽度(和 CSS 保持一致)
|
|
979
|
+
var MIN_SCALE = 0.3; // 最小缩放
|
|
980
|
+
var MAX_SCALE = 2.5; // 最大缩放
|
|
981
|
+
var GRID_BASE = 24; // 点阵基础间距
|
|
982
|
+
var DEFAULT_MODELS = ['deepseek-chat', 'qwen-max', 'glm-4'];
|
|
983
|
+
var TYPE_ICONS = { llm: '✦', mcp: '⚙', judge: '⚖', human: '👤', report: '📋' };
|
|
984
|
+
var STATUS_TEXT = { idle: '空闲', running: '运行中', done: '完成', failed: '失败', blocked: '阻塞', review: '待确认' };
|
|
985
|
+
var STATUS_SET = { idle: 1, running: 1, done: 1, failed: 1, blocked: 1, review: 1 };
|
|
986
|
+
|
|
987
|
+
// ?demo=1:无后端时的本地演示数据,方便调试布局(真实使用不会走这里)
|
|
988
|
+
var DEMO = new URLSearchParams(location.search).has('demo');
|
|
989
|
+
|
|
990
|
+
/* ------------------------------ 演示数据 ------------------------------ */
|
|
991
|
+
var DEMO_BOARD = {
|
|
992
|
+
id: 'demo',
|
|
993
|
+
name: 'AgentFlow · demo',
|
|
994
|
+
nodes: [
|
|
995
|
+
{
|
|
996
|
+
id: 'brief', label: '剧本梗概', type: 'llm', x: 80, y: 150,
|
|
997
|
+
model: 'qwen-max', task: '扩写三幕剧本梗概', criteria: '三幕各有成长弧线',
|
|
998
|
+
content: '第一幕:主角在雨夜捡到一只会说话的猫,猫自称能实现一个愿望。\n\n第二幕:猫其实是三年前走失的搭档,愿望早就用掉了——用在主角身上。',
|
|
999
|
+
status: 'done', usage: { total_tokens: 1284, ms: 2100 }
|
|
1000
|
+
},
|
|
1001
|
+
{
|
|
1002
|
+
id: 'person', label: '人物弧光', type: 'llm', x: 470, y: 50,
|
|
1003
|
+
model: 'deepseek-chat', task: '优化人物弧光', criteria: '三幕各有成长弧线',
|
|
1004
|
+
content: '', status: 'running', usage: {}
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
id: 'scene', label: '场景清单', type: 'llm', x: 470, y: 330,
|
|
1008
|
+
model: 'glm-4', task: '列出关键场景', criteria: '至少 8 个可拍场景',
|
|
1009
|
+
content: '1. 雨夜巷口\n2. 旧书店\n3. 天台', status: 'blocked',
|
|
1010
|
+
error: '被阻塞:上游节点失败', usage: {}
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
id: 'review', label: '终稿审阅', type: 'human', x: 860, y: 170,
|
|
1014
|
+
model: '', task: '人审终稿', criteria: '节奏不拖',
|
|
1015
|
+
content: '', status: 'idle', usage: {}
|
|
1016
|
+
}
|
|
1017
|
+
],
|
|
1018
|
+
edges: [
|
|
1019
|
+
{ from: 'brief', to: 'person' },
|
|
1020
|
+
{ from: 'brief', to: 'scene' },
|
|
1021
|
+
{ from: 'person', to: 'review' },
|
|
1022
|
+
{ from: 'scene', to: 'review' }
|
|
1023
|
+
]
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
/* ------------------------------ 状态 ------------------------------ */
|
|
1027
|
+
var state = {
|
|
1028
|
+
board: { id: 'board', name: '物料画布', nodes: [], edges: [] },
|
|
1029
|
+
// providers 为实际后端的结构(deepseek/qwen/glm 各一套);也兼容契约里的 baseURL+models
|
|
1030
|
+
config: { baseURL: '', models: [], providers: null, mock: true },
|
|
1031
|
+
view: { x: 80, y: 64, s: 1 },
|
|
1032
|
+
selectedId: null,
|
|
1033
|
+
running: false,
|
|
1034
|
+
connected: false,
|
|
1035
|
+
runEnded: false, // 本轮是否收到 run:end(用来识别事件流提前中断)
|
|
1036
|
+
cards: new Map(), // nodeId -> 卡片 DOM 引用
|
|
1037
|
+
patchBuffers: new Map(), // nodeId -> { data, timer }
|
|
1038
|
+
persistTimer: null,
|
|
1039
|
+
demoSeq: 0,
|
|
1040
|
+
// 技能 / MCP 接口状态:idle | loading | ok | error
|
|
1041
|
+
skills: [], // [{ name, description }]
|
|
1042
|
+
skillsStatus: 'idle',
|
|
1043
|
+
skillsError: '',
|
|
1044
|
+
mcpServers: [], // [{ name, ok, error, tools: [{ name, description }] }]
|
|
1045
|
+
mcpStatus: 'idle',
|
|
1046
|
+
mcpError: '',
|
|
1047
|
+
mcpOpen: {}, // serverName -> 工具列表是否展开
|
|
1048
|
+
mcpSpecs: {}, // 本地已知的 mcpServers 配置,保存设置时带回去避免被覆盖
|
|
1049
|
+
skillsLoading: false, // 防止启动 / 重试 / 手动刷新同时打接口
|
|
1050
|
+
mcpLoading: false
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
/* ------------------------------ 小工具 ------------------------------ */
|
|
1054
|
+
function $(sel) { return document.querySelector(sel); }
|
|
1055
|
+
|
|
1056
|
+
// 创建 DOM 元素的小助手,避免到处 innerHTML(用户输入一律 textContent,防 XSS)
|
|
1057
|
+
function make(tag, props, children) {
|
|
1058
|
+
var el = document.createElement(tag);
|
|
1059
|
+
props = props || {};
|
|
1060
|
+
Object.keys(props).forEach(function (key) {
|
|
1061
|
+
var value = props[key];
|
|
1062
|
+
if (key === 'class') el.className = value;
|
|
1063
|
+
else if (key === 'text') el.textContent = value;
|
|
1064
|
+
else if (key === 'style') Object.assign(el.style, value);
|
|
1065
|
+
else if (key.indexOf('on') === 0 && typeof value === 'function') {
|
|
1066
|
+
el.addEventListener(key.slice(2).toLowerCase(), value);
|
|
1067
|
+
} else if (value !== null && value !== undefined) {
|
|
1068
|
+
el.setAttribute(key, value);
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
(children || []).forEach(function (child) { el.appendChild(child); });
|
|
1072
|
+
return el;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function clamp(v, min, max) { return Math.min(max, Math.max(min, v)); }
|
|
1076
|
+
|
|
1077
|
+
function fmtNum(n) {
|
|
1078
|
+
var num = Number(n);
|
|
1079
|
+
if (!isFinite(num)) return '0';
|
|
1080
|
+
return num.toLocaleString('zh-CN');
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function fmtMs(ms) {
|
|
1084
|
+
var num = Number(ms);
|
|
1085
|
+
if (!isFinite(num) || num <= 0) return '';
|
|
1086
|
+
return num >= 1000 ? (num / 1000).toFixed(1) + 's' : Math.round(num) + 'ms';
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// usage 可能来自两种命名:camelCase(实际后端)或 snake_case(契约)
|
|
1090
|
+
function usageTokens(usage) {
|
|
1091
|
+
if (!usage || typeof usage !== 'object') return 0;
|
|
1092
|
+
var total = Number(usage.total_tokens !== undefined ? usage.total_tokens : usage.totalTokens);
|
|
1093
|
+
if (isFinite(total)) return total;
|
|
1094
|
+
var p = Number(usage.promptTokens !== undefined ? usage.promptTokens : usage.prompt_tokens) || 0;
|
|
1095
|
+
var c = Number(usage.completionTokens !== undefined ? usage.completionTokens : usage.completion_tokens) || 0;
|
|
1096
|
+
return p + c;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
|
1100
|
+
|
|
1101
|
+
function getNode(id) {
|
|
1102
|
+
if (id === null || id === undefined) return null;
|
|
1103
|
+
for (var i = 0; i < state.board.nodes.length; i++) {
|
|
1104
|
+
if (state.board.nodes[i].id === id) return state.board.nodes[i];
|
|
1105
|
+
}
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/* ------------------------------ 轻提示 ------------------------------ */
|
|
1110
|
+
// 所有请求失败都必须走到这里,界面上能看见,不能静默。
|
|
1111
|
+
function toast(message, kind) {
|
|
1112
|
+
var box = $('#toasts');
|
|
1113
|
+
if (!box) return;
|
|
1114
|
+
var item = make('div', { class: 'toast ' + (kind || 'info'), text: String(message) });
|
|
1115
|
+
box.appendChild(item);
|
|
1116
|
+
setTimeout(function () {
|
|
1117
|
+
item.style.opacity = '0';
|
|
1118
|
+
setTimeout(function () { item.remove(); }, 260);
|
|
1119
|
+
}, kind === 'error' ? 5200 : 3200);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/* ------------------------------ 连接状态 ------------------------------ */
|
|
1123
|
+
function setConn(ok, message) {
|
|
1124
|
+
state.connected = !!ok;
|
|
1125
|
+
var dot = $('#connDot');
|
|
1126
|
+
if (dot) {
|
|
1127
|
+
dot.className = 'conn-dot ' + (ok ? 'ok' : 'err');
|
|
1128
|
+
dot.title = message || (ok ? '后端已连接' : '后端未连接');
|
|
1129
|
+
}
|
|
1130
|
+
var hint = $('#emptyHint');
|
|
1131
|
+
var retry = $('#btnRetry');
|
|
1132
|
+
if (!hint || !retry) return;
|
|
1133
|
+
if (ok) {
|
|
1134
|
+
hint.textContent = DEMO ? '演示模式:未连接后端,改动不落盘' : '已连接后端';
|
|
1135
|
+
hint.classList.remove('err');
|
|
1136
|
+
retry.classList.add('hidden');
|
|
1137
|
+
} else {
|
|
1138
|
+
hint.textContent = '后端未就绪:' + (message || '连接失败');
|
|
1139
|
+
hint.classList.add('err');
|
|
1140
|
+
retry.classList.remove('hidden');
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function renderModeBadge(offline) {
|
|
1145
|
+
var badge = $('#modeBadge');
|
|
1146
|
+
if (!badge) return;
|
|
1147
|
+
if (offline) {
|
|
1148
|
+
badge.textContent = '未连接';
|
|
1149
|
+
badge.className = 'mode-badge off';
|
|
1150
|
+
badge.title = '读取后端配置失败';
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
if (DEMO) {
|
|
1154
|
+
badge.textContent = 'demo 模式';
|
|
1155
|
+
badge.className = 'mode-badge';
|
|
1156
|
+
badge.title = '本地演示数据,未连接后端';
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
badge.textContent = state.config.mock ? 'mock 模式' : 'real 模式';
|
|
1160
|
+
badge.className = 'mode-badge' + (state.config.mock ? '' : ' real');
|
|
1161
|
+
badge.title = state.config.mock
|
|
1162
|
+
? '当前为 mock 模式:不真正调用模型'
|
|
1163
|
+
: '当前为 real 模式:会调用真实模型接口';
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/* ------------------------------ 请求封装 ------------------------------ */
|
|
1167
|
+
// 统一处理 JSON、错误文本、非 JSON 响应;调用方一律 try/catch。
|
|
1168
|
+
async function api(path, options) {
|
|
1169
|
+
var opts = Object.assign({}, options || {});
|
|
1170
|
+
if (opts.body && typeof opts.body !== 'string') {
|
|
1171
|
+
opts.body = JSON.stringify(opts.body);
|
|
1172
|
+
}
|
|
1173
|
+
opts.headers = Object.assign({}, opts.headers || {});
|
|
1174
|
+
if (opts.body) opts.headers['content-type'] = 'application/json';
|
|
1175
|
+
|
|
1176
|
+
var res = await fetch(path, opts);
|
|
1177
|
+
if (!res.ok) {
|
|
1178
|
+
var detail = '';
|
|
1179
|
+
try { detail = (await res.text()).slice(0, 240); } catch (err) { detail = ''; }
|
|
1180
|
+
throw new Error((opts.method || 'GET') + ' ' + path + ' → ' + res.status +
|
|
1181
|
+
(detail ? ' · ' + detail : ''));
|
|
1182
|
+
}
|
|
1183
|
+
var ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
1184
|
+
if (ct.indexOf('application/json') !== -1) return res.json();
|
|
1185
|
+
return res.text();
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/* ------------------------------ 数据归一化 ------------------------------ */
|
|
1189
|
+
// verdict 可能来自 board,也可能来自运行事件;字段不合法就当作没有
|
|
1190
|
+
function normalizeVerdict(raw) {
|
|
1191
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
1192
|
+
if (typeof raw.pass !== 'boolean') return null;
|
|
1193
|
+
return {
|
|
1194
|
+
by: raw.by === 'human' || raw.by === 'jev' || raw.by === 'self' ? raw.by : '',
|
|
1195
|
+
pass: raw.pass,
|
|
1196
|
+
reason: typeof raw.reason === 'string' ? raw.reason : '',
|
|
1197
|
+
at: isFinite(Number(raw.at)) ? Number(raw.at) : 0
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function normalizeNode(raw) {
|
|
1202
|
+
if (!raw || raw.id === null || raw.id === undefined) return null;
|
|
1203
|
+
var id = String(raw.id);
|
|
1204
|
+
return {
|
|
1205
|
+
id: id,
|
|
1206
|
+
label: raw.label || id,
|
|
1207
|
+
type: raw.type || 'llm',
|
|
1208
|
+
x: isFinite(Number(raw.x)) ? Number(raw.x) : 0,
|
|
1209
|
+
y: isFinite(Number(raw.y)) ? Number(raw.y) : 0,
|
|
1210
|
+
model: raw.model || '',
|
|
1211
|
+
fallback: Array.isArray(raw.fallback) ? raw.fallback : [],
|
|
1212
|
+
// 新字段:老数据没有这些字段时按默认值处理,不能报错
|
|
1213
|
+
skills: Array.isArray(raw.skills)
|
|
1214
|
+
? raw.skills.filter(function (s) { return typeof s === 'string'; })
|
|
1215
|
+
: [],
|
|
1216
|
+
tools: Array.isArray(raw.tools)
|
|
1217
|
+
? raw.tools.filter(function (t) { return typeof t === 'string'; })
|
|
1218
|
+
: [],
|
|
1219
|
+
verify: raw.verify === 'human' || raw.verify === 'jev' || raw.verify === 'self'
|
|
1220
|
+
? raw.verify
|
|
1221
|
+
: '',
|
|
1222
|
+
task: raw.task || '',
|
|
1223
|
+
criteria: raw.criteria || '',
|
|
1224
|
+
content: typeof raw.content === 'string' ? raw.content : '',
|
|
1225
|
+
status: STATUS_SET[raw.status] ? raw.status : 'idle',
|
|
1226
|
+
usage: raw.usage && typeof raw.usage === 'object' ? raw.usage : {},
|
|
1227
|
+
error: typeof raw.error === 'string' ? raw.error : '',
|
|
1228
|
+
usedModel: typeof raw.usedModel === 'string'
|
|
1229
|
+
? raw.usedModel
|
|
1230
|
+
: (raw.usage && typeof raw.usage.model === 'string' ? raw.usage.model : ''),
|
|
1231
|
+
verdict: normalizeVerdict(raw.verdict)
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function normalizeBoard(raw) {
|
|
1236
|
+
var b = raw && typeof raw === 'object' ? raw : {};
|
|
1237
|
+
var rawNodes = Array.isArray(b.nodes) ? b.nodes : [];
|
|
1238
|
+
var rawEdges = Array.isArray(b.edges) ? b.edges : [];
|
|
1239
|
+
var seen = {};
|
|
1240
|
+
var nodes = [];
|
|
1241
|
+
rawNodes.forEach(function (n) {
|
|
1242
|
+
var node = normalizeNode(n);
|
|
1243
|
+
if (!node || seen[node.id]) return;
|
|
1244
|
+
seen[node.id] = true;
|
|
1245
|
+
nodes.push(node);
|
|
1246
|
+
});
|
|
1247
|
+
var edges = [];
|
|
1248
|
+
var edgeSeen = {};
|
|
1249
|
+
rawEdges.forEach(function (e) {
|
|
1250
|
+
if (!e || e.from === undefined || e.to === undefined) return;
|
|
1251
|
+
var from = String(e.from);
|
|
1252
|
+
var to = String(e.to);
|
|
1253
|
+
if (!seen[from] || !seen[to]) return;
|
|
1254
|
+
var key = from + '->' + to;
|
|
1255
|
+
if (edgeSeen[key]) return;
|
|
1256
|
+
edgeSeen[key] = true;
|
|
1257
|
+
edges.push({ from: from, to: to });
|
|
1258
|
+
});
|
|
1259
|
+
return {
|
|
1260
|
+
id: b.id ? String(b.id) : 'board',
|
|
1261
|
+
name: b.name || '物料画布',
|
|
1262
|
+
nodes: nodes,
|
|
1263
|
+
edges: edges
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/* ------------------------------ 加载数据 ------------------------------ */
|
|
1268
|
+
// 配置有两种形态:
|
|
1269
|
+
// a) 实际后端:{ mock, providers: { deepseek: {label, baseURL, model, hasKey}, ... } }
|
|
1270
|
+
// b) 契约形态:{ baseURL, models: [], mock }
|
|
1271
|
+
// 这里统一成内部结构,设置弹窗按有没有 providers 走两套表单。
|
|
1272
|
+
function normalizeConfig(raw) {
|
|
1273
|
+
var c = { baseURL: '', models: [], providers: null, mock: !!(raw && raw.mock), mcpServers: null };
|
|
1274
|
+
// 后端可能把 mcpServers 一起回传(GET /api/config),拿到来用于保存时回写
|
|
1275
|
+
if (raw && raw.mcpServers && typeof raw.mcpServers === 'object') {
|
|
1276
|
+
c.mcpServers = raw.mcpServers;
|
|
1277
|
+
}
|
|
1278
|
+
if (raw && raw.providers && typeof raw.providers === 'object') {
|
|
1279
|
+
c.providers = {};
|
|
1280
|
+
Object.keys(raw.providers).forEach(function (name) {
|
|
1281
|
+
var p = raw.providers[name] || {};
|
|
1282
|
+
c.providers[name] = {
|
|
1283
|
+
label: p.label || name,
|
|
1284
|
+
baseURL: p.baseURL || '',
|
|
1285
|
+
model: p.model || '',
|
|
1286
|
+
hasKey: !!p.hasKey
|
|
1287
|
+
};
|
|
1288
|
+
});
|
|
1289
|
+
c.models = Object.keys(c.providers);
|
|
1290
|
+
var first = c.models[0];
|
|
1291
|
+
c.baseURL = first ? c.providers[first].baseURL : '';
|
|
1292
|
+
} else if (raw) {
|
|
1293
|
+
c.baseURL = raw.baseURL || '';
|
|
1294
|
+
c.models = Array.isArray(raw.models) ? raw.models : [];
|
|
1295
|
+
}
|
|
1296
|
+
return c;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
async function loadConfig() {
|
|
1300
|
+
if (DEMO) {
|
|
1301
|
+
state.config = {
|
|
1302
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
1303
|
+
models: DEFAULT_MODELS.slice(),
|
|
1304
|
+
providers: null,
|
|
1305
|
+
mock: true
|
|
1306
|
+
};
|
|
1307
|
+
renderModeBadge();
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
var cfg = await api('/api/config');
|
|
1312
|
+
state.config = normalizeConfig(cfg);
|
|
1313
|
+
if (state.config.mcpServers) state.mcpSpecs = state.config.mcpServers;
|
|
1314
|
+
renderModeBadge();
|
|
1315
|
+
} catch (err) {
|
|
1316
|
+
// 配置读不到时只降级显示,具体错误由画布加载那条 toast 负责
|
|
1317
|
+
renderModeBadge(true);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async function loadBoard() {
|
|
1322
|
+
if (DEMO) {
|
|
1323
|
+
state.board = normalizeBoard(DEMO_BOARD);
|
|
1324
|
+
setConn(true, '演示模式');
|
|
1325
|
+
renderBoard();
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
try {
|
|
1329
|
+
var raw = await api('/api/board');
|
|
1330
|
+
state.board = normalizeBoard(raw);
|
|
1331
|
+
setConn(true, '已连接');
|
|
1332
|
+
renderBoard();
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
setConn(false, err.message);
|
|
1335
|
+
toast('读取画布失败:' + err.message, 'error');
|
|
1336
|
+
renderBoard();
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/* ------------------------------ 视图变换 ------------------------------ */
|
|
1341
|
+
function applyView() {
|
|
1342
|
+
var world = $('#world');
|
|
1343
|
+
if (world) {
|
|
1344
|
+
world.style.transform = 'translate(' + state.view.x + 'px, ' + state.view.y + 'px) scale(' + state.view.s + ')';
|
|
1345
|
+
}
|
|
1346
|
+
// 点阵网格随缩放调整密度:屏幕间距尽量保持在 16~60px
|
|
1347
|
+
var step = GRID_BASE;
|
|
1348
|
+
while (step * state.view.s < 16) step *= 2;
|
|
1349
|
+
while (step * state.view.s > 60) step /= 2;
|
|
1350
|
+
var size = step * state.view.s;
|
|
1351
|
+
var vp = $('#viewport');
|
|
1352
|
+
if (vp) {
|
|
1353
|
+
vp.style.backgroundSize = size + 'px ' + size + 'px';
|
|
1354
|
+
vp.style.backgroundPosition = state.view.x + 'px ' + state.view.y + 'px';
|
|
1355
|
+
}
|
|
1356
|
+
var pct = $('#zoomPct');
|
|
1357
|
+
if (pct) pct.textContent = Math.round(state.view.s * 100) + '%';
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// 以视口内某点为锚缩放(anchorX/anchorY 是相对 viewport 的像素)
|
|
1361
|
+
function setZoom(nextScale, anchorX, anchorY) {
|
|
1362
|
+
var s = clamp(nextScale, MIN_SCALE, MAX_SCALE);
|
|
1363
|
+
var rect = $('#viewport').getBoundingClientRect();
|
|
1364
|
+
var px = (anchorX === null || anchorX === undefined) ? rect.width / 2 : anchorX;
|
|
1365
|
+
var py = (anchorY === null || anchorY === undefined) ? rect.height / 2 : anchorY;
|
|
1366
|
+
var wx = (px - state.view.x) / state.view.s;
|
|
1367
|
+
var wy = (py - state.view.y) / state.view.s;
|
|
1368
|
+
state.view.s = s;
|
|
1369
|
+
state.view.x = px - wx * s;
|
|
1370
|
+
state.view.y = py - wy * s;
|
|
1371
|
+
applyView();
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function resetView() {
|
|
1375
|
+
state.view = { x: 80, y: 64, s: 1 };
|
|
1376
|
+
applyView();
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/* ------------------------------ 卡片渲染 ------------------------------ */
|
|
1380
|
+
function autosize(ta) {
|
|
1381
|
+
if (!ta) return;
|
|
1382
|
+
ta.style.height = 'auto';
|
|
1383
|
+
var max = 460;
|
|
1384
|
+
var h = Math.min(ta.scrollHeight, max);
|
|
1385
|
+
ta.style.height = h + 'px';
|
|
1386
|
+
ta.style.overflowY = ta.scrollHeight > max ? 'auto' : 'hidden';
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// 创建一张卡片的 DOM(只建一次,后续原地更新,避免输入框失焦)
|
|
1390
|
+
function createCard(node) {
|
|
1391
|
+
var nodeId = node.id;
|
|
1392
|
+
var card = {};
|
|
1393
|
+
|
|
1394
|
+
card.el = make('div', { class: 'card', 'data-id': nodeId });
|
|
1395
|
+
var head = make('div', { class: 'card-head' });
|
|
1396
|
+
card.icon = make('span', { class: 'card-icon' });
|
|
1397
|
+
card.label = make('span', { class: 'card-label' });
|
|
1398
|
+
card.badge = make('span', { class: 'badge' });
|
|
1399
|
+
card.addBtn = make('button', {
|
|
1400
|
+
class: 'icon-btn add',
|
|
1401
|
+
title: '派生下一张卡(自动连线)',
|
|
1402
|
+
text: '+',
|
|
1403
|
+
type: 'button'
|
|
1404
|
+
});
|
|
1405
|
+
// 左侧「+」:给这张卡加一个上游(新节点 → 本节点)
|
|
1406
|
+
card.prevBtn = make('button', {
|
|
1407
|
+
class: 'icon-btn add prev',
|
|
1408
|
+
title: '在这张卡前面加一个上游(自动连线)',
|
|
1409
|
+
text: '+',
|
|
1410
|
+
type: 'button'
|
|
1411
|
+
});
|
|
1412
|
+
head.append(card.prevBtn, card.icon, card.label, card.badge, card.addBtn);
|
|
1413
|
+
|
|
1414
|
+
card.ta = make('textarea', {
|
|
1415
|
+
class: 'card-content',
|
|
1416
|
+
placeholder: '内容还没生成…可以直接编辑',
|
|
1417
|
+
spellcheck: 'false'
|
|
1418
|
+
});
|
|
1419
|
+
card.meta = make('div', { class: 'card-meta' });
|
|
1420
|
+
card.verdict = make('div', { class: 'card-verdict hidden' });
|
|
1421
|
+
card.err = make('div', { class: 'card-error hidden' });
|
|
1422
|
+
|
|
1423
|
+
card.el.append(head, card.ta, card.meta, card.verdict, card.err);
|
|
1424
|
+
|
|
1425
|
+
// 点卡片:选中 + 准备拖动(输入框、按钮上不拖)
|
|
1426
|
+
card.el.addEventListener('pointerdown', function (e) {
|
|
1427
|
+
if (e.button !== 0) return;
|
|
1428
|
+
selectNode(nodeId);
|
|
1429
|
+
if (e.target.closest('button, textarea, input, select, a')) return;
|
|
1430
|
+
var live = getNode(nodeId);
|
|
1431
|
+
if (live) startCardDrag(e, live, card);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
// + 按钮:派生下一张卡
|
|
1435
|
+
card.addBtn.addEventListener('click', function (e) {
|
|
1436
|
+
e.stopPropagation();
|
|
1437
|
+
var live = getNode(nodeId);
|
|
1438
|
+
if (live) deriveNode(live, 'next');
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
// 左侧 + 按钮:加一个上游卡片
|
|
1442
|
+
card.prevBtn.addEventListener('click', function (e) {
|
|
1443
|
+
e.stopPropagation();
|
|
1444
|
+
var live = getNode(nodeId);
|
|
1445
|
+
if (live) deriveNode(live, 'prev');
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
// 内容编辑:写回本地状态 + 防抖 PATCH
|
|
1449
|
+
card.ta.addEventListener('input', function () {
|
|
1450
|
+
var live = getNode(nodeId);
|
|
1451
|
+
if (!live) return;
|
|
1452
|
+
live.content = card.ta.value;
|
|
1453
|
+
autosize(card.ta);
|
|
1454
|
+
drawEdges();
|
|
1455
|
+
queuePatch(nodeId, { content: card.ta.value });
|
|
1456
|
+
});
|
|
1457
|
+
card.ta.addEventListener('pointerdown', function (e) { e.stopPropagation(); });
|
|
1458
|
+
card.ta.addEventListener('focus', function () { selectNode(nodeId); });
|
|
1459
|
+
|
|
1460
|
+
return card;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// 把节点数据刷到卡片上(不重建 DOM)
|
|
1464
|
+
function updateCard(node, card) {
|
|
1465
|
+
card.el.dataset.status = node.status || 'idle';
|
|
1466
|
+
card.el.style.left = node.x + 'px';
|
|
1467
|
+
card.el.style.top = node.y + 'px';
|
|
1468
|
+
card.el.style.zIndex = state.selectedId === node.id ? '3' : '1';
|
|
1469
|
+
card.el.classList.toggle('selected', state.selectedId === node.id);
|
|
1470
|
+
|
|
1471
|
+
card.icon.textContent = TYPE_ICONS[node.type] || '✦';
|
|
1472
|
+
card.icon.title = node.type || 'llm';
|
|
1473
|
+
card.label.textContent = node.label || node.id;
|
|
1474
|
+
card.label.title = node.label || node.id;
|
|
1475
|
+
|
|
1476
|
+
card.badge.textContent = STATUS_TEXT[node.status] || '空闲';
|
|
1477
|
+
card.badge.title = node.error || (STATUS_TEXT[node.status] || '空闲');
|
|
1478
|
+
|
|
1479
|
+
var hasErr = !!node.error;
|
|
1480
|
+
card.err.classList.toggle('hidden', !hasErr);
|
|
1481
|
+
card.err.textContent = hasErr ? node.error : '';
|
|
1482
|
+
card.err.title = node.error || '';
|
|
1483
|
+
|
|
1484
|
+
// 正在输入时不要覆盖用户的光标位置
|
|
1485
|
+
if (document.activeElement !== card.ta) {
|
|
1486
|
+
var text = node.content || '';
|
|
1487
|
+
if (card.ta.value !== text) card.ta.value = text;
|
|
1488
|
+
autosize(card.ta);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// 参数行:model · tokens · ms(13px)
|
|
1492
|
+
var usage = node.usage || {};
|
|
1493
|
+
var total = usageTokens(usage);
|
|
1494
|
+
var bits = [];
|
|
1495
|
+
var modelName = node.usedModel || usage.model || node.model;
|
|
1496
|
+
bits.push(modelName || '未设置模型');
|
|
1497
|
+
if (total > 0) bits.push(fmtNum(total) + ' tokens');
|
|
1498
|
+
if (usage.ms) bits.push(fmtMs(usage.ms));
|
|
1499
|
+
card.meta.textContent = bits.join(' · ');
|
|
1500
|
+
card.meta.title = bits.join(' · ');
|
|
1501
|
+
|
|
1502
|
+
// 判定结果:有 verdict 才显示,放在参数行下面
|
|
1503
|
+
var verdict = node.verdict;
|
|
1504
|
+
if (!verdict) {
|
|
1505
|
+
card.verdict.classList.add('hidden');
|
|
1506
|
+
card.verdict.textContent = '';
|
|
1507
|
+
card.verdict.title = '';
|
|
1508
|
+
} else if (verdict.pass) {
|
|
1509
|
+
var okLabel = { human: '人工通过', jev: 'jev 通过', self: '自评通过' }[verdict.by] || '判定通过';
|
|
1510
|
+
card.verdict.className = 'card-verdict pass';
|
|
1511
|
+
card.verdict.textContent = '✓ ' + okLabel;
|
|
1512
|
+
card.verdict.title = verdict.reason || okLabel;
|
|
1513
|
+
} else {
|
|
1514
|
+
var why = truncateText(verdict.reason || '', 36);
|
|
1515
|
+
card.verdict.className = 'card-verdict fail';
|
|
1516
|
+
card.verdict.textContent = '✗ 未达标' + (why ? ':' + why : '');
|
|
1517
|
+
card.verdict.title = verdict.reason || '未达标';
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function renderBoard() {
|
|
1522
|
+
var world = $('#world');
|
|
1523
|
+
var nameEl = $('#boardName');
|
|
1524
|
+
if (nameEl) nameEl.textContent = state.board.name || '物料画布';
|
|
1525
|
+
document.title = (state.board.name || '物料画布') + ' · AgentFlow';
|
|
1526
|
+
|
|
1527
|
+
var alive = {};
|
|
1528
|
+
state.board.nodes.forEach(function (n) { alive[n.id] = true; });
|
|
1529
|
+
|
|
1530
|
+
// 删掉已经不存在的卡片
|
|
1531
|
+
state.cards.forEach(function (card, id) {
|
|
1532
|
+
if (!alive[id]) {
|
|
1533
|
+
card.el.remove();
|
|
1534
|
+
state.cards.delete(id);
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
// 新增或更新卡片
|
|
1539
|
+
state.board.nodes.forEach(function (node) {
|
|
1540
|
+
var card = state.cards.get(node.id);
|
|
1541
|
+
if (!card) {
|
|
1542
|
+
card = createCard(node);
|
|
1543
|
+
state.cards.set(node.id, card);
|
|
1544
|
+
world.appendChild(card.el);
|
|
1545
|
+
}
|
|
1546
|
+
updateCard(node, card);
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
// 选中的卡片被删掉了 → 收起面板
|
|
1550
|
+
if (state.selectedId && !alive[state.selectedId]) {
|
|
1551
|
+
state.selectedId = null;
|
|
1552
|
+
$('#panel').classList.add('hidden');
|
|
1553
|
+
} else if (state.selectedId) {
|
|
1554
|
+
var current = getNode(state.selectedId);
|
|
1555
|
+
if (current) syncPanel(current);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// 空画布引导
|
|
1559
|
+
var empty = $('#empty');
|
|
1560
|
+
if (empty) empty.classList.toggle('hidden', state.board.nodes.length > 0);
|
|
1561
|
+
|
|
1562
|
+
// 卡片高度要等布局完成后再量,连线放下一帧画;
|
|
1563
|
+
// 内容长短不一时卡片会变高,这里把重叠的卡片往下让一让,避免互相盖住。
|
|
1564
|
+
requestAnimationFrame(function () {
|
|
1565
|
+
drawEdges();
|
|
1566
|
+
resolveOverlaps();
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/* ------------------------------ 重叠避让 ------------------------------ */
|
|
1571
|
+
// 只处理卡片矩形在垂直方向上的重叠:保持上面的卡片不动,把下面的往下推。
|
|
1572
|
+
// 初始数据 / 派生新卡都可能挤在一起,整理一次后把坐标持久化。
|
|
1573
|
+
function resolveOverlaps() {
|
|
1574
|
+
var nodes = state.board.nodes;
|
|
1575
|
+
if (nodes.length < 2) return;
|
|
1576
|
+
var gap = 28;
|
|
1577
|
+
var changed = false;
|
|
1578
|
+
var placed = [];
|
|
1579
|
+
|
|
1580
|
+
var sorted = nodes.slice().sort(function (a, b) {
|
|
1581
|
+
if (a.y !== b.y) return a.y - b.y;
|
|
1582
|
+
return a.x - b.x;
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
sorted.forEach(function (node) {
|
|
1586
|
+
var card = state.cards.get(node.id);
|
|
1587
|
+
var h = card ? card.el.offsetHeight : 160;
|
|
1588
|
+
var guard = 0;
|
|
1589
|
+
var moved = true;
|
|
1590
|
+
while (moved && guard < 100) {
|
|
1591
|
+
moved = false;
|
|
1592
|
+
guard += 1;
|
|
1593
|
+
for (var i = 0; i < placed.length; i += 1) {
|
|
1594
|
+
var other = placed[i];
|
|
1595
|
+
var otherCard = state.cards.get(other.id);
|
|
1596
|
+
var otherH = otherCard ? otherCard.el.offsetHeight : 160;
|
|
1597
|
+
var xOverlap = node.x < other.x + CARD_W + gap && node.x + CARD_W + gap > other.x;
|
|
1598
|
+
var yOverlap = node.y < other.y + otherH + gap && node.y + h + gap > other.y;
|
|
1599
|
+
if (xOverlap && yOverlap) {
|
|
1600
|
+
node.y = Math.round(other.y + otherH + gap);
|
|
1601
|
+
moved = true;
|
|
1602
|
+
changed = true;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
placed.push(node);
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
if (!changed) return;
|
|
1610
|
+
state.board.nodes.forEach(function (n) {
|
|
1611
|
+
var c = state.cards.get(n.id);
|
|
1612
|
+
if (!c) return;
|
|
1613
|
+
c.el.style.left = n.x + 'px';
|
|
1614
|
+
c.el.style.top = n.y + 'px';
|
|
1615
|
+
});
|
|
1616
|
+
drawEdges();
|
|
1617
|
+
persistBoardSoon();
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/* ------------------------------ 连线 ------------------------------ */
|
|
1621
|
+
// 贝塞尔连线:父卡右中点 → 子卡左中点,SVG 在卡片下层。
|
|
1622
|
+
// SVG 的 left/top/宽高按所有卡片的包围盒动态计算(世界坐标)。
|
|
1623
|
+
function drawEdges() {
|
|
1624
|
+
var svg = $('#edges');
|
|
1625
|
+
if (!svg) return;
|
|
1626
|
+
var nodes = state.board.nodes;
|
|
1627
|
+
if (!nodes.length) {
|
|
1628
|
+
svg.innerHTML = '';
|
|
1629
|
+
svg.style.display = 'none';
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
svg.style.display = 'block';
|
|
1633
|
+
|
|
1634
|
+
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1635
|
+
nodes.forEach(function (n) {
|
|
1636
|
+
var card = state.cards.get(n.id);
|
|
1637
|
+
var h = card ? card.el.offsetHeight : 140;
|
|
1638
|
+
minX = Math.min(minX, n.x);
|
|
1639
|
+
minY = Math.min(minY, n.y);
|
|
1640
|
+
maxX = Math.max(maxX, n.x + CARD_W);
|
|
1641
|
+
maxY = Math.max(maxY, n.y + h);
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
var pad = 160;
|
|
1645
|
+
var left = minX - pad;
|
|
1646
|
+
var top = minY - pad;
|
|
1647
|
+
var width = Math.max(1, maxX - minX + pad * 2);
|
|
1648
|
+
var height = Math.max(1, maxY - minY + pad * 2);
|
|
1649
|
+
svg.style.left = left + 'px';
|
|
1650
|
+
svg.style.top = top + 'px';
|
|
1651
|
+
svg.style.width = width + 'px';
|
|
1652
|
+
svg.style.height = height + 'px';
|
|
1653
|
+
|
|
1654
|
+
var nodeById = new Map();
|
|
1655
|
+
nodes.forEach(function (n) { nodeById.set(n.id, n); });
|
|
1656
|
+
|
|
1657
|
+
var paths = '';
|
|
1658
|
+
state.board.edges.forEach(function (edge) {
|
|
1659
|
+
var from = nodeById.get(edge.from);
|
|
1660
|
+
var to = nodeById.get(edge.to);
|
|
1661
|
+
if (!from || !to) return;
|
|
1662
|
+
var fromCard = state.cards.get(from.id);
|
|
1663
|
+
var toCard = state.cards.get(to.id);
|
|
1664
|
+
var fromH = fromCard ? fromCard.el.offsetHeight : 140;
|
|
1665
|
+
var toH = toCard ? toCard.el.offsetHeight : 140;
|
|
1666
|
+
var x1 = from.x + CARD_W - left;
|
|
1667
|
+
var y1 = from.y + fromH / 2 - top;
|
|
1668
|
+
var x2 = to.x - left;
|
|
1669
|
+
var y2 = to.y + toH / 2 - top;
|
|
1670
|
+
var dx = Math.max(48, Math.abs(x2 - x1) * 0.45);
|
|
1671
|
+
var cls = ['edge'];
|
|
1672
|
+
if (state.selectedId && (edge.from === state.selectedId || edge.to === state.selectedId)) {
|
|
1673
|
+
cls.push('edge-hi');
|
|
1674
|
+
}
|
|
1675
|
+
if (from.status === 'failed' || to.status === 'blocked') cls.push('edge-bad');
|
|
1676
|
+
paths += '<path class="' + cls.join(' ') + '" vector-effect="non-scaling-stroke" d="M ' +
|
|
1677
|
+
x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 +
|
|
1678
|
+
', ' + x2 + ' ' + y2 + '" />';
|
|
1679
|
+
});
|
|
1680
|
+
svg.innerHTML = paths;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/* ------------------------------ 选中与面板 ------------------------------ */
|
|
1684
|
+
function selectNode(id) {
|
|
1685
|
+
if (id !== null && id !== undefined && !getNode(id)) id = null;
|
|
1686
|
+
state.selectedId = id === undefined ? null : id;
|
|
1687
|
+
|
|
1688
|
+
state.cards.forEach(function (card, nid) {
|
|
1689
|
+
var on = nid === state.selectedId;
|
|
1690
|
+
card.el.classList.toggle('selected', on);
|
|
1691
|
+
card.el.style.zIndex = on ? '3' : '1';
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
var panel = $('#panel');
|
|
1695
|
+
if (!panel) return;
|
|
1696
|
+
if (state.selectedId === null) {
|
|
1697
|
+
panel.classList.add('hidden');
|
|
1698
|
+
drawEdges();
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
var node = getNode(state.selectedId);
|
|
1702
|
+
if (!node) {
|
|
1703
|
+
panel.classList.add('hidden');
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
panel.classList.remove('hidden');
|
|
1707
|
+
syncPanel(node);
|
|
1708
|
+
drawEdges();
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// 下拉选项:实际后端按 provider 出(value 是 provider key,text 带真实模型名);
|
|
1712
|
+
// 契约形态则直接用 models 数组。画布上已有的模型即使配置里没有也保留。
|
|
1713
|
+
function modelChoices() {
|
|
1714
|
+
var choices = [];
|
|
1715
|
+
var providers = state.config.providers;
|
|
1716
|
+
if (providers && Object.keys(providers).length) {
|
|
1717
|
+
Object.keys(providers).forEach(function (name) {
|
|
1718
|
+
var p = providers[name];
|
|
1719
|
+
choices.push({
|
|
1720
|
+
value: name,
|
|
1721
|
+
text: (p.label || name) + (p.model ? ' · ' + p.model : '')
|
|
1722
|
+
});
|
|
1723
|
+
});
|
|
1724
|
+
} else {
|
|
1725
|
+
var models = state.config.models.length ? state.config.models : DEFAULT_MODELS;
|
|
1726
|
+
models.forEach(function (m) { choices.push({ value: m, text: m }); });
|
|
1727
|
+
}
|
|
1728
|
+
state.board.nodes.forEach(function (n) {
|
|
1729
|
+
if (!n.model) return;
|
|
1730
|
+
var exists = choices.some(function (c) { return c.value === n.model; });
|
|
1731
|
+
if (!exists) choices.push({ value: n.model, text: n.model });
|
|
1732
|
+
});
|
|
1733
|
+
return choices;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
function fillModelSelect(current) {
|
|
1737
|
+
var sel = $('#fModel');
|
|
1738
|
+
if (!sel) return;
|
|
1739
|
+
var choices = modelChoices();
|
|
1740
|
+
if (current && !choices.some(function (c) { return c.value === current; })) {
|
|
1741
|
+
choices.push({ value: current, text: current });
|
|
1742
|
+
}
|
|
1743
|
+
sel.innerHTML = '';
|
|
1744
|
+
sel.appendChild(make('option', { value: '', text: '(不用模型)' }));
|
|
1745
|
+
choices.forEach(function (c) {
|
|
1746
|
+
sel.appendChild(make('option', { value: c.value, text: c.text }));
|
|
1747
|
+
});
|
|
1748
|
+
sel.value = current || '';
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// 把选中节点的数据填进底部面板(正在输入的字段不覆盖)
|
|
1752
|
+
function syncPanel(node) {
|
|
1753
|
+
if (!node) return;
|
|
1754
|
+
var setValue = function (sel, value) {
|
|
1755
|
+
var el = $(sel);
|
|
1756
|
+
if (el && document.activeElement !== el) el.value = value;
|
|
1757
|
+
};
|
|
1758
|
+
setValue('#fLabel', node.label || '');
|
|
1759
|
+
setValue('#fTask', node.task || '');
|
|
1760
|
+
setValue('#fCriteria', node.criteria || '');
|
|
1761
|
+
|
|
1762
|
+
var sel = $('#fModel');
|
|
1763
|
+
if (sel && document.activeElement !== sel) fillModelSelect(node.model || '');
|
|
1764
|
+
|
|
1765
|
+
var st = $('#panelStatus');
|
|
1766
|
+
if (st) {
|
|
1767
|
+
st.textContent = STATUS_TEXT[node.status] || '空闲';
|
|
1768
|
+
st.dataset.status = node.status || 'idle';
|
|
1769
|
+
}
|
|
1770
|
+
var pid = $('#panelId');
|
|
1771
|
+
if (pid) pid.textContent = '#' + node.id;
|
|
1772
|
+
var rerun = $('#btnRerun');
|
|
1773
|
+
var del = $('#btnDelete');
|
|
1774
|
+
if (rerun) rerun.disabled = state.running;
|
|
1775
|
+
if (del) del.disabled = state.running;
|
|
1776
|
+
syncVerify(node);
|
|
1777
|
+
syncReviewActions(node);
|
|
1778
|
+
syncSkills(node);
|
|
1779
|
+
syncTools(node);
|
|
1780
|
+
syncUpstreams(node);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// 判断「from → to」这条边会不会成环(from 是否已经能从 to 走到)
|
|
1784
|
+
function wouldCycle(from, to) {
|
|
1785
|
+
var seen = {};
|
|
1786
|
+
var stack = [to];
|
|
1787
|
+
while (stack.length) {
|
|
1788
|
+
var cur = stack.pop();
|
|
1789
|
+
if (cur === from) return true;
|
|
1790
|
+
if (seen[cur]) continue;
|
|
1791
|
+
seen[cur] = 1;
|
|
1792
|
+
state.board.edges.forEach(function (e) {
|
|
1793
|
+
if (e.from === cur) stack.push(e.to);
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
return false;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// 填充面板里的「上游」多选:勾中的就是当前所有前驱
|
|
1800
|
+
function syncUpstreams(node) {
|
|
1801
|
+
var sel = $('#fUp');
|
|
1802
|
+
if (!sel || document.activeElement === sel) return;
|
|
1803
|
+
var current = {};
|
|
1804
|
+
state.board.edges.forEach(function (e) {
|
|
1805
|
+
if (e.to === node.id) current[e.from] = true;
|
|
1806
|
+
});
|
|
1807
|
+
sel.innerHTML = '';
|
|
1808
|
+
state.board.nodes.forEach(function (n) {
|
|
1809
|
+
if (n.id === node.id) return;
|
|
1810
|
+
var opt = document.createElement('option');
|
|
1811
|
+
opt.value = n.id;
|
|
1812
|
+
opt.textContent = (n.label || n.id) + ' · ' + n.id;
|
|
1813
|
+
if (current[n.id]) {
|
|
1814
|
+
opt.selected = true;
|
|
1815
|
+
} else if (wouldCycle(n.id, node.id)) {
|
|
1816
|
+
// 会成环的直接禁掉并写明原因,不静默拦截
|
|
1817
|
+
opt.disabled = true;
|
|
1818
|
+
opt.textContent += '(会成环)';
|
|
1819
|
+
}
|
|
1820
|
+
sel.appendChild(opt);
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/* ------------------------------ 技能 / MCP 工具 ------------------------------ */
|
|
1825
|
+
// 接口契约:
|
|
1826
|
+
// GET /api/skills → { dirs, skills: [{ name, description }] }
|
|
1827
|
+
// GET /api/mcp → { servers: [{ name, ok, error, tools: [{ name, description }] }] }
|
|
1828
|
+
// 后端可能还没实现,404/500 时只在界面上降级成「暂不可用」,绝不能让画布崩。
|
|
1829
|
+
// 初次失败自动重试一次,之后靠用户点提示 / 设置里的刷新手动重试。
|
|
1830
|
+
|
|
1831
|
+
function truncateText(text, max) {
|
|
1832
|
+
var s = String(text || '').replace(/\s+/g, ' ').trim();
|
|
1833
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function normalizeSkillList(raw) {
|
|
1837
|
+
var list = raw && Array.isArray(raw.skills) ? raw.skills : [];
|
|
1838
|
+
var seen = {};
|
|
1839
|
+
var out = [];
|
|
1840
|
+
list.forEach(function (item) {
|
|
1841
|
+
var name = typeof item === 'string' ? item : (item && item.name);
|
|
1842
|
+
if (!name || seen[name]) return;
|
|
1843
|
+
seen[name] = true;
|
|
1844
|
+
out.push({
|
|
1845
|
+
name: String(name),
|
|
1846
|
+
description: item && typeof item.description === 'string' ? item.description : ''
|
|
1847
|
+
});
|
|
1848
|
+
});
|
|
1849
|
+
return out;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function normalizeMcpList(raw) {
|
|
1853
|
+
var list = raw && Array.isArray(raw.servers) ? raw.servers : [];
|
|
1854
|
+
var seen = {};
|
|
1855
|
+
var out = [];
|
|
1856
|
+
list.forEach(function (srv) {
|
|
1857
|
+
if (!srv || srv.name === undefined || srv.name === null) return;
|
|
1858
|
+
var name = String(srv.name);
|
|
1859
|
+
if (!name || seen[name]) return;
|
|
1860
|
+
seen[name] = true;
|
|
1861
|
+
var tools = Array.isArray(srv.tools) ? srv.tools : [];
|
|
1862
|
+
out.push({
|
|
1863
|
+
name: name,
|
|
1864
|
+
ok: !!srv.ok,
|
|
1865
|
+
error: typeof srv.error === 'string' ? srv.error : '',
|
|
1866
|
+
tools: tools.filter(function (t) { return t && t.name; }).map(function (t) {
|
|
1867
|
+
return {
|
|
1868
|
+
name: String(t.name),
|
|
1869
|
+
description: typeof t.description === 'string' ? t.description : ''
|
|
1870
|
+
};
|
|
1871
|
+
})
|
|
1872
|
+
});
|
|
1873
|
+
});
|
|
1874
|
+
return out;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
async function loadSkills(options) {
|
|
1878
|
+
var opts = options || {};
|
|
1879
|
+
if (state.skillsLoading) return; // 已有请求在飞,别重复打接口
|
|
1880
|
+
state.skillsLoading = true;
|
|
1881
|
+
state.skillsStatus = 'loading';
|
|
1882
|
+
syncPanelMulti();
|
|
1883
|
+
try {
|
|
1884
|
+
var raw = await api('/api/skills');
|
|
1885
|
+
state.skills = normalizeSkillList(raw);
|
|
1886
|
+
state.skillsStatus = 'ok';
|
|
1887
|
+
state.skillsError = '';
|
|
1888
|
+
} catch (err) {
|
|
1889
|
+
state.skillsStatus = 'error';
|
|
1890
|
+
state.skillsError = err.message;
|
|
1891
|
+
if (opts.notify) toast('技能列表读取失败:' + err.message, 'error');
|
|
1892
|
+
if (opts.retry) {
|
|
1893
|
+
setTimeout(function () { loadSkills({ retry: false }); }, 1600);
|
|
1894
|
+
}
|
|
1895
|
+
} finally {
|
|
1896
|
+
state.skillsLoading = false;
|
|
1897
|
+
syncPanelMulti();
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
async function loadMcp(options) {
|
|
1902
|
+
var opts = options || {};
|
|
1903
|
+
if (state.mcpLoading) return; // MCP 连接可能几秒,避免同时打多次
|
|
1904
|
+
state.mcpLoading = true;
|
|
1905
|
+
state.mcpStatus = 'loading';
|
|
1906
|
+
syncPanelMulti();
|
|
1907
|
+
renderMcpList();
|
|
1908
|
+
try {
|
|
1909
|
+
var raw = await api('/api/mcp');
|
|
1910
|
+
state.mcpServers = normalizeMcpList(raw);
|
|
1911
|
+
state.mcpStatus = 'ok';
|
|
1912
|
+
state.mcpError = '';
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
state.mcpStatus = 'error';
|
|
1915
|
+
state.mcpError = err.message;
|
|
1916
|
+
if (opts.notify) toast('MCP 状态读取失败:' + err.message, 'error');
|
|
1917
|
+
if (opts.retry) {
|
|
1918
|
+
setTimeout(function () { loadMcp({ retry: false }); }, 1600);
|
|
1919
|
+
}
|
|
1920
|
+
} finally {
|
|
1921
|
+
state.mcpLoading = false;
|
|
1922
|
+
syncPanelMulti();
|
|
1923
|
+
renderMcpList();
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// 接口数据回来或选中节点变化时,只刷新面板里的两个多选,不做整页重渲染
|
|
1928
|
+
function syncPanelMulti() {
|
|
1929
|
+
if (!state.selectedId) return;
|
|
1930
|
+
var node = getNode(state.selectedId);
|
|
1931
|
+
if (!node) return;
|
|
1932
|
+
syncSkills(node);
|
|
1933
|
+
syncTools(node);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// 接口数据回来或选中节点变化时,只刷新面板里的复选框/单选,不做整页重渲染
|
|
1937
|
+
function syncPanelMulti() {
|
|
1938
|
+
if (!state.selectedId) return;
|
|
1939
|
+
var node = getNode(state.selectedId);
|
|
1940
|
+
if (!node) return;
|
|
1941
|
+
syncSkills(node);
|
|
1942
|
+
syncTools(node);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function checkedValues(list) {
|
|
1946
|
+
var picked = [];
|
|
1947
|
+
if (!list) return picked;
|
|
1948
|
+
var boxes = list.querySelectorAll('input[type="checkbox"]');
|
|
1949
|
+
for (var i = 0; i < boxes.length; i += 1) {
|
|
1950
|
+
if (boxes[i].checked && boxes[i].value) picked.push(boxes[i].value);
|
|
1951
|
+
}
|
|
1952
|
+
return picked;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// 一条复选框:<label><input type=checkbox> 名字 <灰字描述></label>
|
|
1956
|
+
function makeCheckItem(value, name, desc, checked, title) {
|
|
1957
|
+
var label = make('label', { class: 'check-item' });
|
|
1958
|
+
var box = make('input', { type: 'checkbox', value: value });
|
|
1959
|
+
box.checked = !!checked;
|
|
1960
|
+
label.appendChild(box);
|
|
1961
|
+
label.appendChild(make('span', { class: 'check-name', text: name }));
|
|
1962
|
+
if (desc) {
|
|
1963
|
+
label.appendChild(make('span', { class: 'check-sep', text: '·' }));
|
|
1964
|
+
label.appendChild(make('span', { class: 'check-desc', text: desc, title: title || desc }));
|
|
1965
|
+
}
|
|
1966
|
+
label.title = title || name;
|
|
1967
|
+
return label;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function makeCheckEmpty(text, isErr) {
|
|
1971
|
+
return make('div', { class: 'check-empty' + (isErr ? ' err' : ''), text: text });
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// 技能复选框:node.skills 里的名字为勾选;列表里没有的也保留,避免静默丢选择
|
|
1975
|
+
function syncSkills(node) {
|
|
1976
|
+
var list = $('#skillList');
|
|
1977
|
+
var hint = $('#skillsHint');
|
|
1978
|
+
if (!list || !hint) return;
|
|
1979
|
+
|
|
1980
|
+
// 用户正在列表里点选时不重建,避免丢焦点;只更新下方提示
|
|
1981
|
+
if (!list.contains(document.activeElement)) {
|
|
1982
|
+
list.innerHTML = '';
|
|
1983
|
+
var chosen = {};
|
|
1984
|
+
(node.skills || []).forEach(function (name) { chosen[name] = true; });
|
|
1985
|
+
|
|
1986
|
+
if (state.skillsStatus === 'loading' && !state.skills.length) {
|
|
1987
|
+
list.appendChild(makeCheckEmpty('正在读取技能…'));
|
|
1988
|
+
} else if (state.skillsStatus === 'error' && !state.skills.length) {
|
|
1989
|
+
list.appendChild(makeCheckEmpty('技能接口暂不可用', true));
|
|
1990
|
+
} else if (!state.skills.length) {
|
|
1991
|
+
list.appendChild(makeCheckEmpty('还没有发现技能'));
|
|
1992
|
+
} else {
|
|
1993
|
+
var listed = {};
|
|
1994
|
+
state.skills.forEach(function (skill) {
|
|
1995
|
+
listed[skill.name] = true;
|
|
1996
|
+
list.appendChild(makeCheckItem(
|
|
1997
|
+
skill.name,
|
|
1998
|
+
skill.name,
|
|
1999
|
+
truncateText(skill.description || '', 48),
|
|
2000
|
+
!!chosen[skill.name],
|
|
2001
|
+
skill.description || skill.name
|
|
2002
|
+
));
|
|
2003
|
+
});
|
|
2004
|
+
(node.skills || []).forEach(function (name) {
|
|
2005
|
+
if (listed[name]) return;
|
|
2006
|
+
list.appendChild(makeCheckItem(
|
|
2007
|
+
name,
|
|
2008
|
+
name,
|
|
2009
|
+
'已不在技能列表中,勾选状态仍会保留',
|
|
2010
|
+
true,
|
|
2011
|
+
'技能列表里没有这个技能,当前选择仍会保留'
|
|
2012
|
+
));
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
updateSkillsHint(node, hint);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
function updateSkillsHint(node, hint) {
|
|
2021
|
+
var count = (node.skills || []).length;
|
|
2022
|
+
hint.className = 'field-note';
|
|
2023
|
+
hint.title = '';
|
|
2024
|
+
if (state.skillsStatus === 'loading') {
|
|
2025
|
+
hint.textContent = '正在读取技能…';
|
|
2026
|
+
} else if (state.skillsStatus === 'error') {
|
|
2027
|
+
hint.className = 'field-note err';
|
|
2028
|
+
hint.textContent = '技能接口暂不可用' +
|
|
2029
|
+
(state.skills.length ? '(显示上次结果)' : '') + ' · 点此重试';
|
|
2030
|
+
hint.title = state.skillsError || '技能接口暂不可用';
|
|
2031
|
+
} else if (!state.skills.length) {
|
|
2032
|
+
hint.textContent = '还没有发现技能';
|
|
2033
|
+
} else {
|
|
2034
|
+
hint.textContent = (count ? '已勾选 ' + count + ' 个' : '未勾选技能') + ' · 勾选即保存';
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// 工具复选框:按 MCP server 分组,value = serverName.toolName
|
|
2039
|
+
function syncTools(node) {
|
|
2040
|
+
var list = $('#toolList');
|
|
2041
|
+
var hint = $('#toolsHint');
|
|
2042
|
+
if (!list || !hint) return;
|
|
2043
|
+
|
|
2044
|
+
if (!list.contains(document.activeElement)) {
|
|
2045
|
+
list.innerHTML = '';
|
|
2046
|
+
var chosen = {};
|
|
2047
|
+
(node.tools || []).forEach(function (name) { chosen[name] = true; });
|
|
2048
|
+
|
|
2049
|
+
if (state.mcpStatus === 'loading' && !state.mcpServers.length) {
|
|
2050
|
+
list.appendChild(makeCheckEmpty('正在读取 MCP…'));
|
|
2051
|
+
} else if (state.mcpStatus === 'error' && !state.mcpServers.length) {
|
|
2052
|
+
list.appendChild(makeCheckEmpty('MCP 接口暂不可用', true));
|
|
2053
|
+
} else if (!state.mcpServers.length) {
|
|
2054
|
+
list.appendChild(makeCheckEmpty('还没配置 MCP 服务器'));
|
|
2055
|
+
} else {
|
|
2056
|
+
var listed = {};
|
|
2057
|
+
state.mcpServers.forEach(function (srv) {
|
|
2058
|
+
var group = make('div', { class: 'check-group' });
|
|
2059
|
+
var head = make('div', { class: 'check-group-head' });
|
|
2060
|
+
head.appendChild(make('span', { class: 'mcp-dot ' + (srv.ok ? 'ok' : 'bad') }));
|
|
2061
|
+
head.appendChild(make('span', { class: 'check-group-name', text: srv.name }));
|
|
2062
|
+
head.appendChild(make('span', {
|
|
2063
|
+
class: 'check-group-count',
|
|
2064
|
+
text: srv.ok ? srv.tools.length + ' 个工具' : '连接失败'
|
|
2065
|
+
}));
|
|
2066
|
+
group.appendChild(head);
|
|
2067
|
+
|
|
2068
|
+
if (!srv.tools.length) {
|
|
2069
|
+
group.appendChild(makeCheckEmpty(srv.ok ? '没有可用工具' : (srv.error || '连接失败')));
|
|
2070
|
+
}
|
|
2071
|
+
srv.tools.forEach(function (tool) {
|
|
2072
|
+
var value = srv.name + '.' + tool.name;
|
|
2073
|
+
listed[value] = true;
|
|
2074
|
+
group.appendChild(makeCheckItem(
|
|
2075
|
+
value,
|
|
2076
|
+
tool.name,
|
|
2077
|
+
truncateText(tool.description || '', 40),
|
|
2078
|
+
!!chosen[value],
|
|
2079
|
+
(tool.description || value) + (srv.ok ? '' : '\n(该 MCP 服务器当前连接失败)')
|
|
2080
|
+
));
|
|
2081
|
+
});
|
|
2082
|
+
list.appendChild(group);
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
// 节点上挂着、但当前列表里没有的工具也保留,避免静默丢选择
|
|
2086
|
+
var unknown = (node.tools || []).filter(function (name) { return !listed[name]; });
|
|
2087
|
+
if (unknown.length) {
|
|
2088
|
+
var staleGroup = make('div', { class: 'check-group' });
|
|
2089
|
+
staleGroup.appendChild(make('div', { class: 'check-group-head' }, [
|
|
2090
|
+
make('span', { class: 'check-group-name', text: '已保存(当前不可用)' })
|
|
2091
|
+
]));
|
|
2092
|
+
unknown.forEach(function (name) {
|
|
2093
|
+
staleGroup.appendChild(makeCheckItem(
|
|
2094
|
+
name, name, '当前 MCP 列表里没有这个工具', true,
|
|
2095
|
+
'当前 MCP 列表里没有这个工具,勾选状态仍会保留'
|
|
2096
|
+
));
|
|
2097
|
+
});
|
|
2098
|
+
list.appendChild(staleGroup);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
updateToolsHint(node, hint);
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
function updateToolsHint(node, hint) {
|
|
2107
|
+
var totalTools = 0;
|
|
2108
|
+
state.mcpServers.forEach(function (srv) { totalTools += srv.tools.length; });
|
|
2109
|
+
var count = (node.tools || []).length;
|
|
2110
|
+
hint.className = 'field-note';
|
|
2111
|
+
hint.title = '';
|
|
2112
|
+
if (state.mcpStatus === 'loading') {
|
|
2113
|
+
hint.textContent = '正在读取 MCP…';
|
|
2114
|
+
} else if (state.mcpStatus === 'error') {
|
|
2115
|
+
hint.className = 'field-note err';
|
|
2116
|
+
hint.textContent = 'MCP 接口暂不可用' +
|
|
2117
|
+
(state.mcpServers.length ? '(显示上次结果)' : '') + ' · 点此重试';
|
|
2118
|
+
hint.title = state.mcpError || 'MCP 接口暂不可用';
|
|
2119
|
+
} else if (!totalTools) {
|
|
2120
|
+
hint.textContent = '还没配置 MCP 服务器' +
|
|
2121
|
+
(state.mcpServers.length ? '(已配置 ' + state.mcpServers.length + ' 个,但没有可用工具)' : '');
|
|
2122
|
+
} else {
|
|
2123
|
+
hint.textContent = (count ? '已勾选 ' + count + ' 个工具' : '未勾选工具') + ' · 勾选即保存';
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// 勾选 / 取消勾选:立即更新本地状态 + 防抖 PATCH,不做整页重渲染
|
|
2128
|
+
function onSkillsChange(e) {
|
|
2129
|
+
if (e && e.target && e.target.type !== 'checkbox') return;
|
|
2130
|
+
var id = state.selectedId;
|
|
2131
|
+
var node = id ? getNode(id) : null;
|
|
2132
|
+
if (!node) return;
|
|
2133
|
+
node.skills = checkedValues($('#skillList'));
|
|
2134
|
+
updateSkillsHint(node, $('#skillsHint'));
|
|
2135
|
+
queuePatch(id, { skills: node.skills });
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function onToolsChange(e) {
|
|
2139
|
+
if (e && e.target && e.target.type !== 'checkbox') return;
|
|
2140
|
+
var id = state.selectedId;
|
|
2141
|
+
var node = id ? getNode(id) : null;
|
|
2142
|
+
if (!node) return;
|
|
2143
|
+
node.tools = checkedValues($('#toolList'));
|
|
2144
|
+
updateToolsHint(node, $('#toolsHint'));
|
|
2145
|
+
queuePatch(id, { tools: node.tools });
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
/* ------------------------------ 结果判断 / 人工确认 ------------------------------ */
|
|
2149
|
+
function syncVerify(node) {
|
|
2150
|
+
var box = $('#verifyOptions');
|
|
2151
|
+
if (!box) return;
|
|
2152
|
+
var value = node.verify || '';
|
|
2153
|
+
var radios = box.querySelectorAll('input[name="verify"]');
|
|
2154
|
+
Array.prototype.forEach.call(radios, function (radio) {
|
|
2155
|
+
var on = radio.value === value;
|
|
2156
|
+
radio.checked = on;
|
|
2157
|
+
var opt = radio.closest('.verify-opt');
|
|
2158
|
+
if (opt) opt.classList.toggle('on', on);
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
function onVerifyChange(e) {
|
|
2163
|
+
var target = e && e.target;
|
|
2164
|
+
if (!target || target.name !== 'verify') return;
|
|
2165
|
+
var id = state.selectedId;
|
|
2166
|
+
var node = id ? getNode(id) : null;
|
|
2167
|
+
if (!node) return;
|
|
2168
|
+
node.verify = target.value;
|
|
2169
|
+
syncVerify(node);
|
|
2170
|
+
// 结果判断是单选,变更后立即 PATCH(不走输入框的 500ms 防抖)
|
|
2171
|
+
patchNodeNow(id, { verify: node.verify }, '保存结果判断');
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
async function patchNodeNow(id, patch, what) {
|
|
2175
|
+
if (DEMO) return; // 演示模式只改本地
|
|
2176
|
+
try {
|
|
2177
|
+
await api('/api/nodes/' + encodeURIComponent(id), { method: 'PATCH', body: patch });
|
|
2178
|
+
} catch (err) {
|
|
2179
|
+
toast((what || '保存') + '失败:' + err.message, 'error');
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// status === 'review' 时在面板底部显示「通过 / 打回重跑」
|
|
2184
|
+
function syncReviewActions(node) {
|
|
2185
|
+
var box = $('#verifyActions');
|
|
2186
|
+
if (!box) return;
|
|
2187
|
+
box.classList.toggle('hidden', !(node && node.status === 'review'));
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// 只把响应里真实出现的字段合并进本地节点,避免后端回部分字段时把别的内容冲掉
|
|
2191
|
+
function applyNodePatch(node, raw) {
|
|
2192
|
+
if (!node || !raw || typeof raw !== 'object') return;
|
|
2193
|
+
var fresh = normalizeNode(raw);
|
|
2194
|
+
if (!fresh) return;
|
|
2195
|
+
['label', 'type', 'x', 'y', 'model', 'fallback', 'task', 'criteria',
|
|
2196
|
+
'content', 'status', 'usage', 'error', 'usedModel',
|
|
2197
|
+
'skills', 'tools', 'verify', 'verdict'].forEach(function (key) {
|
|
2198
|
+
if (raw[key] !== undefined) node[key] = fresh[key];
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// 处理 POST /api/verify/:id 的返回:可能回 node、board 或只有 { ok: true }
|
|
2203
|
+
function applyVerifyResult(id, res) {
|
|
2204
|
+
if (!res || typeof res !== 'object') return false;
|
|
2205
|
+
var board = res.board && typeof res.board === 'object' ? res.board : null;
|
|
2206
|
+
if (!board && Array.isArray(res.nodes) && Array.isArray(res.edges)) board = res;
|
|
2207
|
+
if (board) {
|
|
2208
|
+
state.board = normalizeBoard(board);
|
|
2209
|
+
renderBoard();
|
|
2210
|
+
return true;
|
|
2211
|
+
}
|
|
2212
|
+
var rawNode = res.node && typeof res.node === 'object' ? res.node : null;
|
|
2213
|
+
if (!rawNode && res.id !== undefined && res.status !== undefined) rawNode = res;
|
|
2214
|
+
if (!rawNode) return false;
|
|
2215
|
+
var fresh = normalizeNode(rawNode);
|
|
2216
|
+
if (!fresh) return false;
|
|
2217
|
+
var node = getNode(fresh.id);
|
|
2218
|
+
if (node) {
|
|
2219
|
+
applyNodePatch(node, rawNode);
|
|
2220
|
+
updateNodeUI(node);
|
|
2221
|
+
if (state.selectedId === node.id) syncPanel(node);
|
|
2222
|
+
drawEdges();
|
|
2223
|
+
}
|
|
2224
|
+
return true;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
async function submitVerify(pass) {
|
|
2228
|
+
var id = state.selectedId;
|
|
2229
|
+
var node = id ? getNode(id) : null;
|
|
2230
|
+
if (!node) return;
|
|
2231
|
+
var passBtn = $('#btnVerifyPass');
|
|
2232
|
+
var failBtn = $('#btnVerifyFail');
|
|
2233
|
+
if (passBtn) passBtn.disabled = true;
|
|
2234
|
+
if (failBtn) failBtn.disabled = true;
|
|
2235
|
+
|
|
2236
|
+
try {
|
|
2237
|
+
if (DEMO) {
|
|
2238
|
+
node.status = pass ? 'done' : 'idle';
|
|
2239
|
+
node.verdict = {
|
|
2240
|
+
by: 'human', pass: !!pass,
|
|
2241
|
+
reason: pass ? '' : '人工打回',
|
|
2242
|
+
at: Date.now()
|
|
2243
|
+
};
|
|
2244
|
+
updateNodeUI(node);
|
|
2245
|
+
if (state.selectedId === id) syncPanel(node);
|
|
2246
|
+
toast(pass ? '已通过(demo 本地)' : '已打回(demo 本地)', 'warn');
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
var res = await api('/api/verify/' + encodeURIComponent(id), {
|
|
2251
|
+
method: 'POST',
|
|
2252
|
+
body: { pass: !!pass }
|
|
2253
|
+
});
|
|
2254
|
+
// 后端只回 { ok: true } 时重新拉一次画布,状态以服务端为准
|
|
2255
|
+
if (!applyVerifyResult(id, res)) await loadBoard();
|
|
2256
|
+
|
|
2257
|
+
var live = getNode(id);
|
|
2258
|
+
if (live) {
|
|
2259
|
+
updateNodeUI(live);
|
|
2260
|
+
if (state.selectedId === id) syncPanel(live);
|
|
2261
|
+
drawEdges();
|
|
2262
|
+
}
|
|
2263
|
+
toast(pass ? '已通过' : '已打回,等待重跑', 'ok');
|
|
2264
|
+
} catch (err) {
|
|
2265
|
+
toast('提交判定失败:' + err.message, 'error');
|
|
2266
|
+
} finally {
|
|
2267
|
+
if (passBtn) passBtn.disabled = false;
|
|
2268
|
+
if (failBtn) failBtn.disabled = false;
|
|
2269
|
+
// 请求期间用户可能切到了别的卡,别用旧节点状态去改新面板
|
|
2270
|
+
if (state.selectedId === id) syncReviewActions(getNode(id));
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/* ------------------------------ 设置弹窗里的 MCP 列表 ------------------------------ */
|
|
2275
|
+
function renderMcpList() {
|
|
2276
|
+
var box = $('#mcpList');
|
|
2277
|
+
var msg = $('#mcpMsg');
|
|
2278
|
+
if (!box || !msg) return;
|
|
2279
|
+
box.innerHTML = '';
|
|
2280
|
+
msg.className = 'mcp-msg';
|
|
2281
|
+
msg.title = '';
|
|
2282
|
+
var refreshBtn = $('#btnMcpRefresh');
|
|
2283
|
+
if (refreshBtn) refreshBtn.disabled = state.mcpStatus === 'loading';
|
|
2284
|
+
|
|
2285
|
+
if (state.mcpStatus === 'loading') {
|
|
2286
|
+
msg.textContent = '正在读取 MCP 服务器…';
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
if (state.mcpStatus === 'error') {
|
|
2291
|
+
msg.className = 'mcp-msg err';
|
|
2292
|
+
msg.textContent = 'MCP 接口暂不可用:' + truncateText(state.mcpError || '读取失败', 90) +
|
|
2293
|
+
(state.mcpServers.length ? '(下面是上次结果)' : '');
|
|
2294
|
+
msg.title = state.mcpError || '';
|
|
2295
|
+
if (!state.mcpServers.length) return;
|
|
2296
|
+
} else if (!state.mcpServers.length) {
|
|
2297
|
+
msg.textContent = '还没配置 MCP 服务器';
|
|
2298
|
+
return;
|
|
2299
|
+
} else {
|
|
2300
|
+
var okCount = 0;
|
|
2301
|
+
var toolCount = 0;
|
|
2302
|
+
state.mcpServers.forEach(function (srv) {
|
|
2303
|
+
if (srv.ok) okCount += 1;
|
|
2304
|
+
toolCount += srv.tools.length;
|
|
2305
|
+
});
|
|
2306
|
+
msg.textContent = state.mcpServers.length + ' 个服务器 · ' + toolCount + ' 个工具' +
|
|
2307
|
+
(okCount < state.mcpServers.length ? ' · ' + (state.mcpServers.length - okCount) + ' 个连接失败' : '');
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
state.mcpServers.forEach(function (server) {
|
|
2311
|
+
var row = make('div', { class: 'mcp-row' });
|
|
2312
|
+
var open = !!state.mcpOpen[server.name];
|
|
2313
|
+
var head = make('div', {
|
|
2314
|
+
class: 'mcp-row-head',
|
|
2315
|
+
role: 'button',
|
|
2316
|
+
tabindex: '0',
|
|
2317
|
+
title: server.error || (server.ok ? '连接正常' : '连接失败')
|
|
2318
|
+
});
|
|
2319
|
+
head.appendChild(make('span', { class: 'mcp-dot ' + (server.ok ? 'ok' : 'bad') }));
|
|
2320
|
+
head.appendChild(make('span', { class: 'mcp-name', text: server.name }));
|
|
2321
|
+
head.appendChild(make('span', {
|
|
2322
|
+
class: 'mcp-count',
|
|
2323
|
+
text: server.ok ? server.tools.length + ' 个工具' : '不可用'
|
|
2324
|
+
}));
|
|
2325
|
+
var caret = make('span', { class: 'mcp-caret' + (open ? ' open' : ''), text: '▾' });
|
|
2326
|
+
head.appendChild(caret);
|
|
2327
|
+
|
|
2328
|
+
var detail = make('div', { class: 'mcp-tools' + (open ? '' : ' hidden') });
|
|
2329
|
+
if (server.error) {
|
|
2330
|
+
detail.appendChild(make('div', { class: 'mcp-err', text: server.error }));
|
|
2331
|
+
}
|
|
2332
|
+
if (server.tools.length) {
|
|
2333
|
+
server.tools.forEach(function (tool) {
|
|
2334
|
+
var item = make('div', { class: 'mcp-tool' });
|
|
2335
|
+
item.appendChild(make('span', { class: 'mcp-tool-name', text: tool.name }));
|
|
2336
|
+
if (tool.description) {
|
|
2337
|
+
item.appendChild(make('span', { class: 'mcp-tool-desc', text: tool.description }));
|
|
2338
|
+
}
|
|
2339
|
+
detail.appendChild(item);
|
|
2340
|
+
});
|
|
2341
|
+
} else if (server.ok) {
|
|
2342
|
+
detail.appendChild(make('div', { class: 'mcp-msg', text: '没有列出工具' }));
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
var toggle = function () {
|
|
2346
|
+
var hidden = detail.classList.toggle('hidden');
|
|
2347
|
+
caret.classList.toggle('open', !hidden);
|
|
2348
|
+
state.mcpOpen[server.name] = !hidden;
|
|
2349
|
+
};
|
|
2350
|
+
head.addEventListener('click', toggle);
|
|
2351
|
+
head.addEventListener('keydown', function (e) {
|
|
2352
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
2353
|
+
e.preventDefault();
|
|
2354
|
+
toggle();
|
|
2355
|
+
}
|
|
2356
|
+
});
|
|
2357
|
+
|
|
2358
|
+
row.append(head, detail);
|
|
2359
|
+
box.appendChild(row);
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
// 添加 stdio server:PUT /api/config { mcpServers: { name: { command, args, env } } }
|
|
2364
|
+
async function addMcpServer() {
|
|
2365
|
+
var nameEl = $('#mcpName');
|
|
2366
|
+
var cmdEl = $('#mcpCommand');
|
|
2367
|
+
var argsEl = $('#mcpArgs');
|
|
2368
|
+
var hint = $('#mcpAddHint');
|
|
2369
|
+
if (!nameEl || !cmdEl || !argsEl) return;
|
|
2370
|
+
|
|
2371
|
+
var name = (nameEl.value || '').trim();
|
|
2372
|
+
if (!name) {
|
|
2373
|
+
toast('先填 MCP server 的名字', 'warn');
|
|
2374
|
+
nameEl.focus();
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
if (/[.\s]/.test(name)) {
|
|
2378
|
+
toast('server 名字不要包含空格或点号(工具 value 是 server.tool)', 'warn');
|
|
2379
|
+
nameEl.focus();
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
var command = (cmdEl.value || '').trim() || '/usr/local/bin/node';
|
|
2383
|
+
var args = (argsEl.value || '').split('\n')
|
|
2384
|
+
.map(function (s) { return s.trim(); })
|
|
2385
|
+
.filter(Boolean);
|
|
2386
|
+
var spec = { command: command, args: args, env: {} };
|
|
2387
|
+
|
|
2388
|
+
if (DEMO) {
|
|
2389
|
+
toast('demo 模式没有后端,MCP 配置无法保存', 'warn');
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
var btn = $('#btnMcpAdd');
|
|
2394
|
+
if (btn) { btn.disabled = true; btn.textContent = '添加中…'; }
|
|
2395
|
+
if (hint) hint.textContent = '正在提交到 /api/config…';
|
|
2396
|
+
try {
|
|
2397
|
+
var payload = { mcpServers: {} };
|
|
2398
|
+
payload.mcpServers[name] = spec;
|
|
2399
|
+
await api('/api/config', { method: 'PUT', body: payload });
|
|
2400
|
+
state.mcpSpecs[name] = spec;
|
|
2401
|
+
toast('已添加 MCP 服务器:' + name, 'ok');
|
|
2402
|
+
nameEl.value = '';
|
|
2403
|
+
argsEl.value = '';
|
|
2404
|
+
cmdEl.value = '/usr/local/bin/node';
|
|
2405
|
+
if (hint) hint.textContent = '已保存,正在重新读取 MCP 状态…';
|
|
2406
|
+
await loadMcp({ notify: true, retry: true });
|
|
2407
|
+
await loadConfig();
|
|
2408
|
+
if (hint) hint.textContent = '已添加;工具列表没出现时可点右上角刷新';
|
|
2409
|
+
} catch (err) {
|
|
2410
|
+
if (hint) hint.textContent = '添加失败:' + truncateText(err.message, 70);
|
|
2411
|
+
toast('添加 MCP 服务器失败:' + err.message, 'error');
|
|
2412
|
+
} finally {
|
|
2413
|
+
if (btn) { btn.disabled = false; btn.textContent = '添加'; }
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
/* ------------------------------ 卡片拖动 ------------------------------ */
|
|
2418
|
+
function startCardDrag(e, node, card) {
|
|
2419
|
+
e.preventDefault();
|
|
2420
|
+
var startX = e.clientX;
|
|
2421
|
+
var startY = e.clientY;
|
|
2422
|
+
var originX = node.x;
|
|
2423
|
+
var originY = node.y;
|
|
2424
|
+
var moved = false;
|
|
2425
|
+
|
|
2426
|
+
card.el.classList.add('dragging');
|
|
2427
|
+
$('#viewport').classList.add('panning');
|
|
2428
|
+
|
|
2429
|
+
var onMove = function (ev) {
|
|
2430
|
+
var dx = (ev.clientX - startX) / state.view.s;
|
|
2431
|
+
var dy = (ev.clientY - startY) / state.view.s;
|
|
2432
|
+
if (Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) > 3) moved = true;
|
|
2433
|
+
node.x = Math.round(originX + dx);
|
|
2434
|
+
node.y = Math.round(originY + dy);
|
|
2435
|
+
card.el.style.left = node.x + 'px';
|
|
2436
|
+
card.el.style.top = node.y + 'px';
|
|
2437
|
+
drawEdges();
|
|
2438
|
+
};
|
|
2439
|
+
|
|
2440
|
+
var onUp = function () {
|
|
2441
|
+
window.removeEventListener('pointermove', onMove);
|
|
2442
|
+
window.removeEventListener('pointerup', onUp);
|
|
2443
|
+
window.removeEventListener('pointercancel', onUp);
|
|
2444
|
+
card.el.classList.remove('dragging');
|
|
2445
|
+
$('#viewport').classList.remove('panning');
|
|
2446
|
+
if (moved) persistBoardSoon();
|
|
2447
|
+
};
|
|
2448
|
+
|
|
2449
|
+
window.addEventListener('pointermove', onMove);
|
|
2450
|
+
window.addEventListener('pointerup', onUp);
|
|
2451
|
+
window.addEventListener('pointercancel', onUp);
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
/* ------------------------------ 画布平移 / 缩放 ------------------------------ */
|
|
2455
|
+
function bindViewport() {
|
|
2456
|
+
var vp = $('#viewport');
|
|
2457
|
+
|
|
2458
|
+
// 拖空白平移;点空白取消选中
|
|
2459
|
+
vp.addEventListener('pointerdown', function (e) {
|
|
2460
|
+
if (e.button !== 0) return;
|
|
2461
|
+
if (e.target.closest('.card') || e.target.closest('.empty-state')) return;
|
|
2462
|
+
selectNode(null);
|
|
2463
|
+
var startX = e.clientX;
|
|
2464
|
+
var startY = e.clientY;
|
|
2465
|
+
var originX = state.view.x;
|
|
2466
|
+
var originY = state.view.y;
|
|
2467
|
+
vp.classList.add('panning');
|
|
2468
|
+
if (vp.setPointerCapture) {
|
|
2469
|
+
try { vp.setPointerCapture(e.pointerId); } catch (err) { /* 忽略 */ }
|
|
2470
|
+
}
|
|
2471
|
+
var onMove = function (ev) {
|
|
2472
|
+
state.view.x = originX + (ev.clientX - startX);
|
|
2473
|
+
state.view.y = originY + (ev.clientY - startY);
|
|
2474
|
+
applyView();
|
|
2475
|
+
};
|
|
2476
|
+
var onUp = function () {
|
|
2477
|
+
vp.classList.remove('panning');
|
|
2478
|
+
vp.removeEventListener('pointermove', onMove);
|
|
2479
|
+
vp.removeEventListener('pointerup', onUp);
|
|
2480
|
+
vp.removeEventListener('pointercancel', onUp);
|
|
2481
|
+
};
|
|
2482
|
+
vp.addEventListener('pointermove', onMove);
|
|
2483
|
+
vp.addEventListener('pointerup', onUp);
|
|
2484
|
+
vp.addEventListener('pointercancel', onUp);
|
|
2485
|
+
e.preventDefault();
|
|
2486
|
+
});
|
|
2487
|
+
|
|
2488
|
+
// 滚轮缩放,以光标为锚点;textarea 上让内容自己滚
|
|
2489
|
+
vp.addEventListener('wheel', function (e) {
|
|
2490
|
+
if (e.target.closest('textarea')) return;
|
|
2491
|
+
e.preventDefault();
|
|
2492
|
+
var rect = vp.getBoundingClientRect();
|
|
2493
|
+
var factor = Math.exp(-e.deltaY * 0.0015);
|
|
2494
|
+
setZoom(state.view.s * factor, e.clientX - rect.left, e.clientY - rect.top);
|
|
2495
|
+
}, { passive: false });
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
/* ------------------------------ 节点字段的防抖保存 ------------------------------ */
|
|
2499
|
+
function queuePatch(id, patch) {
|
|
2500
|
+
if (DEMO) return; // 演示模式只改本地
|
|
2501
|
+
var buf = state.patchBuffers.get(id);
|
|
2502
|
+
if (!buf) {
|
|
2503
|
+
buf = { data: {}, timer: null };
|
|
2504
|
+
state.patchBuffers.set(id, buf);
|
|
2505
|
+
}
|
|
2506
|
+
Object.assign(buf.data, patch);
|
|
2507
|
+
if (buf.timer) clearTimeout(buf.timer);
|
|
2508
|
+
buf.timer = setTimeout(function () { flushPatch(id); }, 500);
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
async function flushPatch(id) {
|
|
2512
|
+
var buf = state.patchBuffers.get(id);
|
|
2513
|
+
if (!buf) return;
|
|
2514
|
+
state.patchBuffers.delete(id);
|
|
2515
|
+
if (buf.timer) clearTimeout(buf.timer);
|
|
2516
|
+
if (!Object.keys(buf.data).length) return;
|
|
2517
|
+
try {
|
|
2518
|
+
await api('/api/nodes/' + encodeURIComponent(id), {
|
|
2519
|
+
method: 'PATCH',
|
|
2520
|
+
body: buf.data
|
|
2521
|
+
});
|
|
2522
|
+
} catch (err) {
|
|
2523
|
+
toast('保存卡片失败:' + err.message, 'error');
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
async function flushAllPatches() {
|
|
2528
|
+
var ids = Array.from(state.patchBuffers.keys());
|
|
2529
|
+
await Promise.all(ids.map(function (id) { return flushPatch(id); }));
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
// 拖动结束后持久化整个 board(按契约 PUT /api/board)
|
|
2533
|
+
function serializeBoard() {
|
|
2534
|
+
return {
|
|
2535
|
+
id: state.board.id,
|
|
2536
|
+
name: state.board.name,
|
|
2537
|
+
nodes: state.board.nodes.map(function (n) {
|
|
2538
|
+
return {
|
|
2539
|
+
id: n.id, label: n.label, type: n.type, x: n.x, y: n.y,
|
|
2540
|
+
model: n.model, fallback: n.fallback, task: n.task, criteria: n.criteria,
|
|
2541
|
+
skills: n.skills || [], tools: n.tools || [],
|
|
2542
|
+
verify: n.verify || '', verdict: n.verdict || null,
|
|
2543
|
+
content: n.content, status: n.status, usage: n.usage, error: n.error
|
|
2544
|
+
};
|
|
2545
|
+
}),
|
|
2546
|
+
edges: state.board.edges.map(function (e) { return { from: e.from, to: e.to }; })
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
async function persistBoard() {
|
|
2551
|
+
if (DEMO) return;
|
|
2552
|
+
try {
|
|
2553
|
+
await api('/api/board', { method: 'PUT', body: serializeBoard() });
|
|
2554
|
+
} catch (err) {
|
|
2555
|
+
toast('保存画布布局失败:' + err.message, 'error');
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
function persistBoardSoon() {
|
|
2560
|
+
if (DEMO) return;
|
|
2561
|
+
if (state.persistTimer) clearTimeout(state.persistTimer);
|
|
2562
|
+
state.persistTimer = setTimeout(function () { persistBoard(); }, 260);
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
/* ------------------------------ 新建 / 派生 / 删除 ------------------------------ */
|
|
2566
|
+
async function postNode(payload) {
|
|
2567
|
+
if (DEMO) {
|
|
2568
|
+
state.demoSeq += 1;
|
|
2569
|
+
var demoNode = normalizeNode({
|
|
2570
|
+
id: 'demo-' + state.demoSeq,
|
|
2571
|
+
label: payload.label,
|
|
2572
|
+
type: payload.type,
|
|
2573
|
+
x: payload.x,
|
|
2574
|
+
y: payload.y,
|
|
2575
|
+
model: payload.model,
|
|
2576
|
+
task: payload.task,
|
|
2577
|
+
content: '',
|
|
2578
|
+
status: 'idle'
|
|
2579
|
+
});
|
|
2580
|
+
state.board.nodes.push(demoNode);
|
|
2581
|
+
if (payload.from) state.board.edges.push({ from: payload.from, to: demoNode.id });
|
|
2582
|
+
if (payload.to) state.board.edges.push({ from: demoNode.id, to: payload.to });
|
|
2583
|
+
renderBoard();
|
|
2584
|
+
selectNode(demoNode.id);
|
|
2585
|
+
toast('demo 模式:改动只在当前页面,不落盘', 'warn');
|
|
2586
|
+
return demoNode;
|
|
2587
|
+
}
|
|
2588
|
+
var created = await api('/api/nodes', { method: 'POST', body: payload });
|
|
2589
|
+
var raw = created && created.node ? created.node : created;
|
|
2590
|
+
var node = normalizeNode(raw);
|
|
2591
|
+
if (!node) throw new Error('服务端没有返回新节点');
|
|
2592
|
+
if (!getNode(node.id)) state.board.nodes.push(node);
|
|
2593
|
+
if (payload.from) {
|
|
2594
|
+
var exists = state.board.edges.some(function (e) {
|
|
2595
|
+
return e.from === payload.from && e.to === node.id;
|
|
2596
|
+
});
|
|
2597
|
+
if (!exists) state.board.edges.push({ from: payload.from, to: node.id });
|
|
2598
|
+
}
|
|
2599
|
+
// 向左派生:新节点是上游,边方向相反
|
|
2600
|
+
if (payload.to) {
|
|
2601
|
+
var exists2 = state.board.edges.some(function (e) {
|
|
2602
|
+
return e.from === node.id && e.to === payload.to;
|
|
2603
|
+
});
|
|
2604
|
+
if (!exists2) state.board.edges.push({ from: node.id, to: payload.to });
|
|
2605
|
+
}
|
|
2606
|
+
renderBoard();
|
|
2607
|
+
selectNode(node.id);
|
|
2608
|
+
return node;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
async function deriveNode(parent, dir) {
|
|
2612
|
+
var forward = dir !== 'prev';
|
|
2613
|
+
var payload = {
|
|
2614
|
+
label: '新卡片',
|
|
2615
|
+
type: parent.type || 'llm',
|
|
2616
|
+
x: Math.round(forward ? parent.x + CARD_W + 72 : parent.x - CARD_W - 72),
|
|
2617
|
+
y: Math.round(parent.y),
|
|
2618
|
+
model: parent.model || state.config.models[0] || DEFAULT_MODELS[0],
|
|
2619
|
+
task: ''
|
|
2620
|
+
};
|
|
2621
|
+
// 向右派生:新卡是下游(parent → new);向左派生:新卡是上游(new → parent)
|
|
2622
|
+
if (forward) payload.from = parent.id;
|
|
2623
|
+
else payload.to = parent.id;
|
|
2624
|
+
try {
|
|
2625
|
+
await postNode(payload);
|
|
2626
|
+
toast(forward ? '已派生新卡片' : '已添加上游卡片', 'ok');
|
|
2627
|
+
} catch (err) {
|
|
2628
|
+
toast('新建卡片失败:' + err.message, 'error');
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
function createFirstNode() {
|
|
2633
|
+
var vp = $('#viewport');
|
|
2634
|
+
var rect = vp.getBoundingClientRect();
|
|
2635
|
+
var x = Math.round((rect.width / 2 - state.view.x) / state.view.s - CARD_W / 2);
|
|
2636
|
+
var y = Math.round((rect.height / 2 - state.view.y) / state.view.s - 90);
|
|
2637
|
+
postNode({
|
|
2638
|
+
label: '新卡片',
|
|
2639
|
+
type: 'llm',
|
|
2640
|
+
x: x,
|
|
2641
|
+
y: y,
|
|
2642
|
+
model: state.config.models[0] || DEFAULT_MODELS[0],
|
|
2643
|
+
task: ''
|
|
2644
|
+
}).catch(function (err) {
|
|
2645
|
+
toast('新建卡片失败:' + err.message, 'error');
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
async function deleteNode(id) {
|
|
2650
|
+
var node = getNode(id);
|
|
2651
|
+
if (!node) return;
|
|
2652
|
+
if (!window.confirm('删除卡片「' + (node.label || id) + '」?')) return;
|
|
2653
|
+
|
|
2654
|
+
if (DEMO) {
|
|
2655
|
+
state.board.nodes = state.board.nodes.filter(function (n) { return n.id !== id; });
|
|
2656
|
+
state.board.edges = state.board.edges.filter(function (e) {
|
|
2657
|
+
return e.from !== id && e.to !== id;
|
|
2658
|
+
});
|
|
2659
|
+
// 选中项被删掉时连面板一起收起(selectNode(null) 会负责隐藏)
|
|
2660
|
+
if (state.selectedId === id) selectNode(null);
|
|
2661
|
+
renderBoard();
|
|
2662
|
+
toast('已删除(demo 本地)', 'ok');
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
try {
|
|
2667
|
+
await api('/api/nodes/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
2668
|
+
state.board.nodes = state.board.nodes.filter(function (n) { return n.id !== id; });
|
|
2669
|
+
state.board.edges = state.board.edges.filter(function (e) {
|
|
2670
|
+
return e.from !== id && e.to !== id;
|
|
2671
|
+
});
|
|
2672
|
+
// 选中项被删掉时连面板一起收起(selectNode(null) 会负责隐藏)
|
|
2673
|
+
if (state.selectedId === id) selectNode(null);
|
|
2674
|
+
renderBoard();
|
|
2675
|
+
toast('已删除卡片', 'ok');
|
|
2676
|
+
} catch (err) {
|
|
2677
|
+
toast('删除失败:' + err.message, 'error');
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/* ------------------------------ 运行与 SSE ------------------------------ */
|
|
2682
|
+
function updateRunUI() {
|
|
2683
|
+
var btn = $('#btnRun');
|
|
2684
|
+
if (btn) {
|
|
2685
|
+
btn.disabled = state.running;
|
|
2686
|
+
btn.textContent = state.running ? '运行中…' : '▶ 跑全图';
|
|
2687
|
+
}
|
|
2688
|
+
var rerun = $('#btnRerun');
|
|
2689
|
+
var del = $('#btnDelete');
|
|
2690
|
+
if (rerun) rerun.disabled = state.running;
|
|
2691
|
+
if (del) del.disabled = state.running;
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
function updateNodeUI(node) {
|
|
2695
|
+
if (!node) return;
|
|
2696
|
+
var card = state.cards.get(node.id);
|
|
2697
|
+
if (card) updateCard(node, card);
|
|
2698
|
+
if (state.selectedId === node.id) {
|
|
2699
|
+
var st = $('#panelStatus');
|
|
2700
|
+
if (st) {
|
|
2701
|
+
st.textContent = STATUS_TEXT[node.status] || '空闲';
|
|
2702
|
+
st.dataset.status = node.status || 'idle';
|
|
2703
|
+
}
|
|
2704
|
+
var pid = $('#panelId');
|
|
2705
|
+
if (pid) pid.textContent = '#' + node.id;
|
|
2706
|
+
syncReviewActions(node);
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// 处理一条运行事件;返回 false 表示事件流可以停了
|
|
2711
|
+
function handleRunEvent(ev) {
|
|
2712
|
+
if (!ev || typeof ev !== 'object') return true;
|
|
2713
|
+
var node = ev.id !== null && ev.id !== undefined ? getNode(String(ev.id)) : null;
|
|
2714
|
+
|
|
2715
|
+
switch (ev.type) {
|
|
2716
|
+
case 'node:start':
|
|
2717
|
+
if (node) {
|
|
2718
|
+
node.status = 'running';
|
|
2719
|
+
node.error = '';
|
|
2720
|
+
node.usage = {};
|
|
2721
|
+
node.usedModel = '';
|
|
2722
|
+
node.verdict = null; // 重跑时先藏起上一轮的判定结果
|
|
2723
|
+
updateNodeUI(node);
|
|
2724
|
+
drawEdges();
|
|
2725
|
+
}
|
|
2726
|
+
break;
|
|
2727
|
+
|
|
2728
|
+
case 'node:done':
|
|
2729
|
+
if (node) {
|
|
2730
|
+
// 人工确认模式:引擎发的是 node:done + status:'review',不能当成普通完成
|
|
2731
|
+
node.status = ev.status === 'review' ? 'review' : 'done';
|
|
2732
|
+
if (typeof ev.content === 'string') node.content = ev.content;
|
|
2733
|
+
node.usedModel = ev.model || node.model;
|
|
2734
|
+
node.usage = ev.usage && typeof ev.usage === 'object' ? ev.usage : {};
|
|
2735
|
+
node.error = '';
|
|
2736
|
+
if (ev.verdict) node.verdict = normalizeVerdict(ev.verdict);
|
|
2737
|
+
else if (node.status === 'review') node.verdict = null;
|
|
2738
|
+
updateNodeUI(node);
|
|
2739
|
+
drawEdges();
|
|
2740
|
+
if (node.status === 'review') {
|
|
2741
|
+
toast('「' + (node.label || node.id) + '」等待人工确认', 'warn');
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
break;
|
|
2745
|
+
|
|
2746
|
+
case 'node:review':
|
|
2747
|
+
// 待人工确认:卡片橙色角标 + 面板出现「通过 / 打回」
|
|
2748
|
+
if (node) {
|
|
2749
|
+
node.status = 'review';
|
|
2750
|
+
node.error = '';
|
|
2751
|
+
if (ev.verdict) node.verdict = normalizeVerdict(ev.verdict);
|
|
2752
|
+
updateNodeUI(node);
|
|
2753
|
+
drawEdges();
|
|
2754
|
+
toast('「' + (node.label || node.id) + '」等待人工确认', 'warn');
|
|
2755
|
+
}
|
|
2756
|
+
break;
|
|
2757
|
+
|
|
2758
|
+
case 'node:retry':
|
|
2759
|
+
if (node) {
|
|
2760
|
+
node.error = (ev.model || '模型') + ' 调用失败:' + (ev.error || '未知错误');
|
|
2761
|
+
updateNodeUI(node);
|
|
2762
|
+
toast('「' + (node.label || node.id) + '」' + (ev.model || '') +
|
|
2763
|
+
' 调用失败,尝试备用模型…', 'warn');
|
|
2764
|
+
}
|
|
2765
|
+
break;
|
|
2766
|
+
|
|
2767
|
+
case 'node:failed':
|
|
2768
|
+
if (node) {
|
|
2769
|
+
node.status = 'failed';
|
|
2770
|
+
var tried = Array.isArray(ev.tried) ? ev.tried : [];
|
|
2771
|
+
node.error = tried.length
|
|
2772
|
+
? '全部模型失败:' + tried.map(function (t) {
|
|
2773
|
+
return (t.model || '?') + '(' + (t.error || '?') + ')';
|
|
2774
|
+
}).join(';')
|
|
2775
|
+
: '节点执行失败';
|
|
2776
|
+
updateNodeUI(node);
|
|
2777
|
+
drawEdges();
|
|
2778
|
+
}
|
|
2779
|
+
break;
|
|
2780
|
+
|
|
2781
|
+
case 'node:blocked':
|
|
2782
|
+
if (node) {
|
|
2783
|
+
node.status = 'blocked';
|
|
2784
|
+
node.error = '被阻塞:' + (ev.reason === 'upstream-failed' ? '上游节点失败' : (ev.reason || '未知原因'));
|
|
2785
|
+
updateNodeUI(node);
|
|
2786
|
+
drawEdges();
|
|
2787
|
+
}
|
|
2788
|
+
break;
|
|
2789
|
+
|
|
2790
|
+
case 'run:error':
|
|
2791
|
+
// 后端跑图中途抛错(不是节点失败,而是整轮运行失败)
|
|
2792
|
+
state.running = false;
|
|
2793
|
+
state.runEnded = true;
|
|
2794
|
+
updateRunUI();
|
|
2795
|
+
toast('运行出错:' + (ev.error || '未知错误'), 'error');
|
|
2796
|
+
return false;
|
|
2797
|
+
|
|
2798
|
+
case 'run:end':
|
|
2799
|
+
state.running = false;
|
|
2800
|
+
state.runEnded = true;
|
|
2801
|
+
updateRunUI();
|
|
2802
|
+
var summary = ev.summary || {};
|
|
2803
|
+
var doneN = Array.isArray(summary.done) ? summary.done.length : 0;
|
|
2804
|
+
var failN = Array.isArray(summary.failed) ? summary.failed.length : 0;
|
|
2805
|
+
var blockN = Array.isArray(summary.blocked) ? summary.blocked.length : 0;
|
|
2806
|
+
var reviewN = Array.isArray(summary.review) ? summary.review.length : 0;
|
|
2807
|
+
toast('运行结束:成功 ' + doneN + ' · 失败 ' + failN + ' · 阻塞 ' + blockN +
|
|
2808
|
+
(reviewN ? ' · 待确认 ' + reviewN : ''),
|
|
2809
|
+
failN || blockN ? 'warn' : 'ok');
|
|
2810
|
+
// 物料变长后卡片会变高,运行结束后统一整理一次位置,避免互相盖住
|
|
2811
|
+
setTimeout(resolveOverlaps, 60);
|
|
2812
|
+
return false;
|
|
2813
|
+
|
|
2814
|
+
default:
|
|
2815
|
+
break;
|
|
2816
|
+
}
|
|
2817
|
+
return true;
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
// 读取 POST 响应体里的 SSE 流(EventSource 只能 GET,POST 只能走 fetch 流)
|
|
2821
|
+
async function readSSE(res, handle) {
|
|
2822
|
+
if (!res.body) throw new Error('当前浏览器不支持流式响应读取');
|
|
2823
|
+
var reader = res.body.getReader();
|
|
2824
|
+
var decoder = new TextDecoder('utf-8');
|
|
2825
|
+
var buffer = '';
|
|
2826
|
+
var finished = false;
|
|
2827
|
+
try {
|
|
2828
|
+
while (!finished) {
|
|
2829
|
+
var chunk = await reader.read();
|
|
2830
|
+
if (chunk.done) break;
|
|
2831
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
2832
|
+
buffer = buffer.replace(/\r\n/g, '\n');
|
|
2833
|
+
var cut;
|
|
2834
|
+
while ((cut = buffer.indexOf('\n\n')) !== -1) {
|
|
2835
|
+
var frame = buffer.slice(0, cut);
|
|
2836
|
+
buffer = buffer.slice(cut + 2);
|
|
2837
|
+
var payload = frame.split('\n')
|
|
2838
|
+
.filter(function (line) { return line.indexOf('data:') === 0; })
|
|
2839
|
+
.map(function (line) { return line.slice(5).replace(/^ /, ''); })
|
|
2840
|
+
.join('\n')
|
|
2841
|
+
.trim();
|
|
2842
|
+
if (!payload) continue;
|
|
2843
|
+
var ev;
|
|
2844
|
+
try { ev = JSON.parse(payload); } catch (err) { continue; }
|
|
2845
|
+
if (handle(ev) === false) { finished = true; break; }
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
} finally {
|
|
2849
|
+
try { await reader.cancel(); } catch (err) { /* 忽略 */ }
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
// 兼容「POST 先返回 JSON,再用 GET 事件流」的后端
|
|
2854
|
+
function listenSSE(url, handle) {
|
|
2855
|
+
return new Promise(function (resolve, reject) {
|
|
2856
|
+
var source;
|
|
2857
|
+
try {
|
|
2858
|
+
source = new EventSource(url);
|
|
2859
|
+
} catch (err) {
|
|
2860
|
+
reject(err);
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
var settled = false;
|
|
2864
|
+
var finish = function (err) {
|
|
2865
|
+
if (settled) return;
|
|
2866
|
+
settled = true;
|
|
2867
|
+
source.close();
|
|
2868
|
+
if (err) reject(err); else resolve();
|
|
2869
|
+
};
|
|
2870
|
+
source.onmessage = function (event) {
|
|
2871
|
+
var data;
|
|
2872
|
+
try { data = JSON.parse(event.data); } catch (err) { return; }
|
|
2873
|
+
if (handle(data) === false) finish();
|
|
2874
|
+
};
|
|
2875
|
+
source.onerror = function () {
|
|
2876
|
+
finish(new Error('SSE 事件流连接失败或中断'));
|
|
2877
|
+
};
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
async function demoRun(targets) {
|
|
2882
|
+
var ids = targets && targets.length
|
|
2883
|
+
? targets.slice()
|
|
2884
|
+
: state.board.nodes.map(function (n) { return n.id; });
|
|
2885
|
+
for (var i = 0; i < ids.length; i++) {
|
|
2886
|
+
var id = ids[i];
|
|
2887
|
+
var node = getNode(id);
|
|
2888
|
+
if (!node) continue;
|
|
2889
|
+
node.status = 'running';
|
|
2890
|
+
node.error = '';
|
|
2891
|
+
node.usage = {};
|
|
2892
|
+
node.usedModel = '';
|
|
2893
|
+
updateNodeUI(node);
|
|
2894
|
+
drawEdges();
|
|
2895
|
+
await sleep(700);
|
|
2896
|
+
var live = getNode(id);
|
|
2897
|
+
if (!live || live.status !== 'running') continue;
|
|
2898
|
+
if (!live.content) live.content = '【演示输出】' + (live.task || '这是一段生成的物料') + '。';
|
|
2899
|
+
live.status = 'done';
|
|
2900
|
+
live.usedModel = live.model || 'demo-model';
|
|
2901
|
+
live.usage = {
|
|
2902
|
+
total_tokens: 600 + Math.round(Math.random() * 1200),
|
|
2903
|
+
ms: 700 + Math.round(Math.random() * 1500)
|
|
2904
|
+
};
|
|
2905
|
+
updateNodeUI(live);
|
|
2906
|
+
drawEdges();
|
|
2907
|
+
await sleep(250);
|
|
2908
|
+
}
|
|
2909
|
+
resolveOverlaps();
|
|
2910
|
+
toast('演示运行结束', 'ok');
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
async function startRun(targets) {
|
|
2914
|
+
if (state.running) {
|
|
2915
|
+
toast('已经有一轮在跑了,稍等一下~', 'warn');
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
if (!state.board.nodes.length) {
|
|
2919
|
+
toast('画布是空的,先新建一个节点吧', 'warn');
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
// 先把用户刚改的字段提交掉,避免运行读到旧内容
|
|
2924
|
+
try { await flushAllPatches(); } catch (err) { /* flushPatch 内部已提示 */ }
|
|
2925
|
+
|
|
2926
|
+
state.running = true;
|
|
2927
|
+
state.runEnded = false;
|
|
2928
|
+
updateRunUI();
|
|
2929
|
+
|
|
2930
|
+
// 目标节点先清成 idle,等待 SSE 事件驱动状态
|
|
2931
|
+
var targetIds = targets && targets.length
|
|
2932
|
+
? targets.slice()
|
|
2933
|
+
: state.board.nodes.map(function (n) { return n.id; });
|
|
2934
|
+
targetIds.forEach(function (id) {
|
|
2935
|
+
var node = getNode(id);
|
|
2936
|
+
if (!node) return;
|
|
2937
|
+
node.status = 'idle';
|
|
2938
|
+
node.error = '';
|
|
2939
|
+
node.usage = {};
|
|
2940
|
+
node.usedModel = '';
|
|
2941
|
+
node.verdict = null;
|
|
2942
|
+
updateNodeUI(node);
|
|
2943
|
+
});
|
|
2944
|
+
drawEdges();
|
|
2945
|
+
|
|
2946
|
+
if (DEMO) {
|
|
2947
|
+
try {
|
|
2948
|
+
await demoRun(targets);
|
|
2949
|
+
state.runEnded = true;
|
|
2950
|
+
} catch (err) {
|
|
2951
|
+
toast('演示运行失败:' + err.message, 'error');
|
|
2952
|
+
} finally {
|
|
2953
|
+
state.running = false;
|
|
2954
|
+
updateRunUI();
|
|
2955
|
+
}
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
try {
|
|
2960
|
+
var res = await fetch('/api/run', {
|
|
2961
|
+
method: 'POST',
|
|
2962
|
+
headers: { 'content-type': 'application/json' },
|
|
2963
|
+
body: JSON.stringify(targets && targets.length ? { targets: targets } : {})
|
|
2964
|
+
});
|
|
2965
|
+
if (!res.ok) {
|
|
2966
|
+
var detail = '';
|
|
2967
|
+
try { detail = (await res.text()).slice(0, 240); } catch (err) { detail = ''; }
|
|
2968
|
+
throw new Error('POST /api/run → ' + res.status + (detail ? ' · ' + detail : ''));
|
|
2969
|
+
}
|
|
2970
|
+
var ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
2971
|
+
if (ct.indexOf('text/event-stream') !== -1) {
|
|
2972
|
+
await readSSE(res, handleRunEvent);
|
|
2973
|
+
} else {
|
|
2974
|
+
var info = await res.json().catch(function () { return {}; });
|
|
2975
|
+
var streamUrl = (info && (info.events || info.stream)) || '/api/events';
|
|
2976
|
+
await listenSSE(streamUrl, handleRunEvent);
|
|
2977
|
+
}
|
|
2978
|
+
// 正常情况流以 run:end 收尾;没收到就在界面上提示,不静默
|
|
2979
|
+
if (!state.runEnded) {
|
|
2980
|
+
toast('事件流提前结束,部分卡片状态可能不准,刷新页面可确认结果', 'warn');
|
|
2981
|
+
}
|
|
2982
|
+
} catch (err) {
|
|
2983
|
+
toast('运行失败:' + err.message, 'error');
|
|
2984
|
+
// 收到了 HTTP 响应(比如 409 已有一轮在跑)说明服务还活着,只是拒绝执行;
|
|
2985
|
+
// 网络断开才算连接失败。两种情况都在界面上可见。
|
|
2986
|
+
var httpError = /POST \/api\/run → \d+/.test(err.message);
|
|
2987
|
+
if (httpError) {
|
|
2988
|
+
setConn(true, '已连接');
|
|
2989
|
+
// 服务端拒绝时,用服务端数据把状态恢复回来
|
|
2990
|
+
setTimeout(function () { if (!state.running) loadBoard(); }, 300);
|
|
2991
|
+
} else {
|
|
2992
|
+
setConn(false, err.message);
|
|
2993
|
+
// 事件流中断时,别让卡片永远转圈
|
|
2994
|
+
state.board.nodes.forEach(function (node) {
|
|
2995
|
+
if (node.status === 'running') {
|
|
2996
|
+
node.status = 'failed';
|
|
2997
|
+
node.error = '事件流中断:' + err.message;
|
|
2998
|
+
updateNodeUI(node);
|
|
2999
|
+
}
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
drawEdges();
|
|
3003
|
+
} finally {
|
|
3004
|
+
state.running = false;
|
|
3005
|
+
updateRunUI();
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
/* ------------------------------ 设置弹窗 ------------------------------ */
|
|
3010
|
+
// 实际后端的 provider 行:model / baseURL / apiKey 各一个输入框
|
|
3011
|
+
function makeProviderRow(name, provider) {
|
|
3012
|
+
var row = make('div', { class: 'provider-row', 'data-name': name });
|
|
3013
|
+
var head = make('div', { class: 'provider-head' });
|
|
3014
|
+
head.appendChild(make('span', { text: provider.label || name }));
|
|
3015
|
+
head.appendChild(make('span', {
|
|
3016
|
+
class: 'key-state',
|
|
3017
|
+
text: provider.hasKey ? '已保存 apiKey' : '未保存 apiKey'
|
|
3018
|
+
}));
|
|
3019
|
+
|
|
3020
|
+
var fields = make('div', { class: 'provider-fields' });
|
|
3021
|
+
var modelField = make('label', { class: 'field' }, [
|
|
3022
|
+
make('span', { text: 'model' }),
|
|
3023
|
+
make('input', {
|
|
3024
|
+
class: 'p-model', type: 'text',
|
|
3025
|
+
value: provider.model || '', placeholder: '例如 deepseek-chat'
|
|
3026
|
+
})
|
|
3027
|
+
]);
|
|
3028
|
+
var baseField = make('label', { class: 'field' }, [
|
|
3029
|
+
make('span', { text: 'baseURL' }),
|
|
3030
|
+
make('input', {
|
|
3031
|
+
class: 'p-base', type: 'text',
|
|
3032
|
+
value: provider.baseURL || '', placeholder: 'https://…'
|
|
3033
|
+
})
|
|
3034
|
+
]);
|
|
3035
|
+
var keyField = make('label', { class: 'field full' }, [
|
|
3036
|
+
make('span', { text: 'apiKey' }),
|
|
3037
|
+
make('input', {
|
|
3038
|
+
class: 'p-key', type: 'password',
|
|
3039
|
+
placeholder: '留空则保持不变', autocomplete: 'off'
|
|
3040
|
+
})
|
|
3041
|
+
]);
|
|
3042
|
+
fields.append(modelField, baseField, keyField);
|
|
3043
|
+
row.append(head, fields);
|
|
3044
|
+
return row;
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
function openSettings() {
|
|
3048
|
+
var mockBox = $('#cfgMock');
|
|
3049
|
+
if (mockBox) mockBox.checked = !!state.config.mock;
|
|
3050
|
+
|
|
3051
|
+
var providerBlock = $('#providerBlock');
|
|
3052
|
+
var simpleBlock = $('#simpleBlock');
|
|
3053
|
+
var rowsBox = $('#providerRows');
|
|
3054
|
+
var providers = state.config.providers;
|
|
3055
|
+
var useProviders = !!(providers && Object.keys(providers).length);
|
|
3056
|
+
|
|
3057
|
+
if (rowsBox) rowsBox.innerHTML = '';
|
|
3058
|
+
if (useProviders) {
|
|
3059
|
+
providerBlock.classList.remove('hidden');
|
|
3060
|
+
simpleBlock.classList.add('hidden');
|
|
3061
|
+
Object.keys(providers).forEach(function (name) {
|
|
3062
|
+
rowsBox.appendChild(makeProviderRow(name, providers[name]));
|
|
3063
|
+
});
|
|
3064
|
+
} else {
|
|
3065
|
+
providerBlock.classList.add('hidden');
|
|
3066
|
+
simpleBlock.classList.remove('hidden');
|
|
3067
|
+
$('#cfgBase').value = state.config.baseURL || '';
|
|
3068
|
+
$('#cfgKey').value = '';
|
|
3069
|
+
$('#cfgModels').value = (state.config.models.length ? state.config.models : DEFAULT_MODELS).join('\n');
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
var hint = $('#cfgMockHint');
|
|
3073
|
+
if (hint) {
|
|
3074
|
+
hint.textContent = state.config.mock
|
|
3075
|
+
? '当前后端为 mock 模式(不会发真实请求)'
|
|
3076
|
+
: '当前后端为 real 模式(会调用真实模型)';
|
|
3077
|
+
}
|
|
3078
|
+
// MCP 列表:先按本地状态渲染,再后台刷新一次;接口没就绪只降级显示,不阻塞弹窗
|
|
3079
|
+
renderMcpList();
|
|
3080
|
+
loadMcp({ notify: false, retry: false });
|
|
3081
|
+
$('#modal').classList.remove('hidden');
|
|
3082
|
+
setTimeout(function () {
|
|
3083
|
+
var first = useProviders ? rowsBox.querySelector('input') : $('#cfgBase');
|
|
3084
|
+
if (first) first.focus();
|
|
3085
|
+
}, 30);
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
function closeSettings() {
|
|
3089
|
+
$('#modal').classList.add('hidden');
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
async function saveSettings() {
|
|
3093
|
+
var mock = !!($('#cfgMock') && $('#cfgMock').checked);
|
|
3094
|
+
var useProviders = !!(state.config.providers && Object.keys(state.config.providers).length);
|
|
3095
|
+
var payload;
|
|
3096
|
+
|
|
3097
|
+
if (useProviders) {
|
|
3098
|
+
// 实际后端:{ mock, providers: { name: { model, baseURL, apiKey? } } }
|
|
3099
|
+
payload = { mock: mock, providers: {} };
|
|
3100
|
+
var rows = document.querySelectorAll('#providerRows .provider-row');
|
|
3101
|
+
Array.prototype.forEach.call(rows, function (row) {
|
|
3102
|
+
var name = row.getAttribute('data-name');
|
|
3103
|
+
var model = row.querySelector('.p-model').value.trim();
|
|
3104
|
+
var baseURL = row.querySelector('.p-base').value.trim();
|
|
3105
|
+
var apiKey = row.querySelector('.p-key').value.trim();
|
|
3106
|
+
var inc = { model: model, baseURL: baseURL };
|
|
3107
|
+
if (apiKey) inc.apiKey = apiKey; // 留空表示不修改已保存的 key
|
|
3108
|
+
payload.providers[name] = inc;
|
|
3109
|
+
});
|
|
3110
|
+
} else {
|
|
3111
|
+
// 契约形态:{ baseURL, models, apiKey?, mock }
|
|
3112
|
+
var baseURL = ($('#cfgBase').value || '').trim();
|
|
3113
|
+
var apiKey = ($('#cfgKey').value || '').trim();
|
|
3114
|
+
var models = ($('#cfgModels').value || '')
|
|
3115
|
+
.split(/[\s,,]+/)
|
|
3116
|
+
.map(function (s) { return s.trim(); })
|
|
3117
|
+
.filter(Boolean);
|
|
3118
|
+
payload = { baseURL: baseURL, models: models, mock: mock };
|
|
3119
|
+
if (apiKey) payload.apiKey = apiKey;
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
// mcpServers 和 providers 存在同一份配置里:把本地已知的带回去,避免保存时被覆盖
|
|
3123
|
+
if (state.mcpSpecs && Object.keys(state.mcpSpecs).length) {
|
|
3124
|
+
payload.mcpServers = state.mcpSpecs;
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
var btn = $('#btnSaveCfg');
|
|
3128
|
+
btn.disabled = true;
|
|
3129
|
+
btn.textContent = '保存中…';
|
|
3130
|
+
try {
|
|
3131
|
+
if (DEMO) {
|
|
3132
|
+
state.config.mock = mock;
|
|
3133
|
+
if (!useProviders) {
|
|
3134
|
+
state.config.baseURL = payload.baseURL;
|
|
3135
|
+
state.config.models = payload.models;
|
|
3136
|
+
}
|
|
3137
|
+
toast('demo 模式:配置只保存在当前页面', 'warn');
|
|
3138
|
+
} else {
|
|
3139
|
+
await api('/api/config', { method: 'PUT', body: payload });
|
|
3140
|
+
await loadConfig(); // 重新拉一次,保证 badge 和表单与后端一致
|
|
3141
|
+
toast('配置已保存', 'ok');
|
|
3142
|
+
}
|
|
3143
|
+
renderModeBadge();
|
|
3144
|
+
if (state.selectedId) {
|
|
3145
|
+
var node = getNode(state.selectedId);
|
|
3146
|
+
if (node) syncPanel(node);
|
|
3147
|
+
}
|
|
3148
|
+
closeSettings();
|
|
3149
|
+
} catch (err) {
|
|
3150
|
+
toast('保存配置失败:' + err.message, 'error');
|
|
3151
|
+
} finally {
|
|
3152
|
+
btn.disabled = false;
|
|
3153
|
+
btn.textContent = '保存';
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
/* ------------------------------ 事件绑定 ------------------------------ */
|
|
3158
|
+
function bindUI() {
|
|
3159
|
+
// 设置弹窗放在 form 里(密码输入需要 form 上下文),阻止回车提交刷新页面
|
|
3160
|
+
var settingsForm = $('#settingsForm');
|
|
3161
|
+
if (settingsForm) {
|
|
3162
|
+
settingsForm.addEventListener('submit', function (e) { e.preventDefault(); });
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
$('#btnRun').addEventListener('click', function () { startRun(); });
|
|
3166
|
+
$('#btnSettings').addEventListener('click', openSettings);
|
|
3167
|
+
$('#btnModalClose').addEventListener('click', closeSettings);
|
|
3168
|
+
$('#btnCancelCfg').addEventListener('click', closeSettings);
|
|
3169
|
+
$('#btnSaveCfg').addEventListener('click', saveSettings);
|
|
3170
|
+
$('#btnFirstNode').addEventListener('click', createFirstNode);
|
|
3171
|
+
$('#btnRetry').addEventListener('click', function () {
|
|
3172
|
+
loadConfig();
|
|
3173
|
+
loadBoard();
|
|
3174
|
+
});
|
|
3175
|
+
$('#btnPanelClose').addEventListener('click', function () { selectNode(null); });
|
|
3176
|
+
$('#btnRerun').addEventListener('click', function () {
|
|
3177
|
+
if (state.selectedId) startRun([state.selectedId]);
|
|
3178
|
+
});
|
|
3179
|
+
$('#btnDelete').addEventListener('click', function () {
|
|
3180
|
+
if (state.selectedId) deleteNode(state.selectedId);
|
|
3181
|
+
});
|
|
3182
|
+
|
|
3183
|
+
// 面板字段编辑
|
|
3184
|
+
$('#fLabel').addEventListener('input', function (e) {
|
|
3185
|
+
var node = getNode(state.selectedId);
|
|
3186
|
+
if (!node) return;
|
|
3187
|
+
node.label = e.target.value;
|
|
3188
|
+
updateNodeUI(node);
|
|
3189
|
+
queuePatch(node.id, { label: node.label });
|
|
3190
|
+
});
|
|
3191
|
+
$('#fModel').addEventListener('change', function (e) {
|
|
3192
|
+
var node = getNode(state.selectedId);
|
|
3193
|
+
if (!node) return;
|
|
3194
|
+
node.model = e.target.value;
|
|
3195
|
+
updateNodeUI(node);
|
|
3196
|
+
queuePatch(node.id, { model: node.model });
|
|
3197
|
+
});
|
|
3198
|
+
$('#fTask').addEventListener('input', function (e) {
|
|
3199
|
+
var node = getNode(state.selectedId);
|
|
3200
|
+
if (!node) return;
|
|
3201
|
+
node.task = e.target.value;
|
|
3202
|
+
queuePatch(node.id, { task: node.task });
|
|
3203
|
+
});
|
|
3204
|
+
$('#fCriteria').addEventListener('input', function (e) {
|
|
3205
|
+
var node = getNode(state.selectedId);
|
|
3206
|
+
if (!node) return;
|
|
3207
|
+
node.criteria = e.target.value;
|
|
3208
|
+
queuePatch(node.id, { criteria: node.criteria });
|
|
3209
|
+
});
|
|
3210
|
+
|
|
3211
|
+
// 改上游:重建所有 to == 本节点的边(from 方向不动)
|
|
3212
|
+
$('#fUp').addEventListener('change', function () {
|
|
3213
|
+
var id = state.selectedId;
|
|
3214
|
+
if (!id) return;
|
|
3215
|
+
var sel = $('#fUp');
|
|
3216
|
+
var picked = [];
|
|
3217
|
+
for (var i = 0; i < sel.options.length; i++) {
|
|
3218
|
+
if (sel.options[i].selected) picked.push(sel.options[i].value);
|
|
3219
|
+
}
|
|
3220
|
+
state.board.edges = state.board.edges.filter(function (e) { return e.to !== id; });
|
|
3221
|
+
picked.forEach(function (p) { state.board.edges.push({ from: p, to: id }); });
|
|
3222
|
+
drawEdges();
|
|
3223
|
+
persistBoardSoon();
|
|
3224
|
+
toast(picked.length ? '上游已更新:' + picked.length + ' 个' : '上游已清空', 'ok');
|
|
3225
|
+
});
|
|
3226
|
+
|
|
3227
|
+
// 技能 / 工具:复选框,勾选即挂载(事件委托,列表重建后不用重绑)
|
|
3228
|
+
$('#skillList').addEventListener('change', onSkillsChange);
|
|
3229
|
+
$('#toolList').addEventListener('change', onToolsChange);
|
|
3230
|
+
|
|
3231
|
+
// 结果判断:单选变更立即 PATCH
|
|
3232
|
+
$('#verifyOptions').addEventListener('change', onVerifyChange);
|
|
3233
|
+
|
|
3234
|
+
// 待人工确认:通过 / 打回重跑
|
|
3235
|
+
$('#btnVerifyPass').addEventListener('click', function () { submitVerify(true); });
|
|
3236
|
+
$('#btnVerifyFail').addEventListener('click', function () { submitVerify(false); });
|
|
3237
|
+
|
|
3238
|
+
// 接口没就绪时,提示文字本身可以点着重试
|
|
3239
|
+
$('#skillsHint').addEventListener('click', function () {
|
|
3240
|
+
if (state.skillsStatus === 'error') loadSkills({ notify: true, retry: true });
|
|
3241
|
+
});
|
|
3242
|
+
$('#toolsHint').addEventListener('click', function () {
|
|
3243
|
+
if (state.mcpStatus === 'error') loadMcp({ notify: true, retry: true });
|
|
3244
|
+
});
|
|
3245
|
+
|
|
3246
|
+
// 设置弹窗里的 MCP:刷新列表 / 添加 stdio server
|
|
3247
|
+
$('#btnMcpRefresh').addEventListener('click', function () {
|
|
3248
|
+
if (state.mcpStatus !== 'loading') loadMcp({ notify: true, retry: true });
|
|
3249
|
+
});
|
|
3250
|
+
$('#btnMcpAdd').addEventListener('click', addMcpServer);
|
|
3251
|
+
|
|
3252
|
+
// 点弹窗遮罩关闭
|
|
3253
|
+
$('#modal').addEventListener('click', function (e) {
|
|
3254
|
+
if (e.target === $('#modal')) closeSettings();
|
|
3255
|
+
});
|
|
3256
|
+
|
|
3257
|
+
// 缩放按钮
|
|
3258
|
+
$('#zoomIn').addEventListener('click', function () { setZoom(state.view.s * 1.2); });
|
|
3259
|
+
$('#zoomOut').addEventListener('click', function () { setZoom(state.view.s / 1.2); });
|
|
3260
|
+
$('#zoomReset').addEventListener('click', resetView);
|
|
3261
|
+
|
|
3262
|
+
// Esc:先关弹窗,再取消选中
|
|
3263
|
+
document.addEventListener('keydown', function (e) {
|
|
3264
|
+
if (e.key !== 'Escape') return;
|
|
3265
|
+
if (!$('#modal').classList.contains('hidden')) {
|
|
3266
|
+
closeSettings();
|
|
3267
|
+
} else if (state.selectedId) {
|
|
3268
|
+
selectNode(null);
|
|
3269
|
+
}
|
|
3270
|
+
});
|
|
3271
|
+
|
|
3272
|
+
bindViewport();
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
/* ------------------------------ 启动 ------------------------------ */
|
|
3276
|
+
async function boot() {
|
|
3277
|
+
bindUI();
|
|
3278
|
+
applyView();
|
|
3279
|
+
updateRunUI();
|
|
3280
|
+
if (DEMO) setConn(true, '演示模式');
|
|
3281
|
+
await loadConfig();
|
|
3282
|
+
await loadBoard();
|
|
3283
|
+
// 技能 / MCP 是增强信息:异步加载,接口没就绪也不能拖垮画布
|
|
3284
|
+
if (DEMO) {
|
|
3285
|
+
state.skillsStatus = 'ok';
|
|
3286
|
+
state.mcpStatus = 'ok';
|
|
3287
|
+
syncPanelMulti();
|
|
3288
|
+
} else {
|
|
3289
|
+
loadSkills({ notify: true, retry: true });
|
|
3290
|
+
loadMcp({ notify: true, retry: true });
|
|
3291
|
+
}
|
|
3292
|
+
drawEdges();
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
boot();
|
|
3296
|
+
})();
|
|
3297
|
+
</script>
|
|
3298
|
+
</body>
|
|
3299
|
+
</html>
|