@steerable/agent-shell-web 0.6.50 → 0.6.51

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.
@@ -0,0 +1,526 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ LuCircle,
4
+ LuCircleCheck,
5
+ LuCircleDot,
6
+ LuCirclePause,
7
+ LuCircleX,
8
+ LuChevronDown,
9
+ LuGitFork,
10
+ LuLoaderCircle,
11
+ LuBot,
12
+ LuCheck,
13
+ LuMessageSquare,
14
+ LuExternalLink,
15
+ LuX,
16
+ } from 'react-icons/lu';
17
+ import {
18
+ type OrchestrationChildNode,
19
+ type OrchestrationFlowData,
20
+ type OrchestrationNodeStatus,
21
+ } from './orchestration-flow-model';
22
+ import type { InspectTaskInput } from './executed-actions-model';
23
+
24
+ function FlowNodeStatusIcon({
25
+ status,
26
+ className = '',
27
+ }: {
28
+ status: OrchestrationNodeStatus;
29
+ className?: string;
30
+ }) {
31
+ const cls = `h-3.5 w-3.5 shrink-0 ${className}`.trim();
32
+ switch (status) {
33
+ case 'completed':
34
+ return <LuCircleCheck className={`${cls} text-emerald-600 dark:text-emerald-400`} />;
35
+ case 'running':
36
+ return <LuCircleDot className={`${cls} animate-pulse text-blue-600 dark:text-blue-400`} />;
37
+ case 'interrupted':
38
+ return <LuCirclePause className={`${cls} text-amber-600 dark:text-amber-400`} />;
39
+ case 'closed':
40
+ case 'failed':
41
+ return <LuCircleX className={`${cls} text-agent-destructive`} />;
42
+ case 'pending':
43
+ default:
44
+ return <LuCircle className={`${cls} text-agent-muted-foreground/60`} />;
45
+ }
46
+ }
47
+
48
+ export function currentActiveNode(nodes: OrchestrationChildNode[]): OrchestrationChildNode | null {
49
+ return (
50
+ nodes.find((n) => n.status === 'running' || n.status === 'pending') ??
51
+ nodes[nodes.length - 1] ??
52
+ null
53
+ );
54
+ }
55
+
56
+ /**
57
+ * 流程图分叉连线 (Fork Connector with directional arrows)
58
+ *
59
+ * 使用独立 SVG 容器与绝对定位,避免 viewBox 坐标在 stretch 时被水平拉伸成巨型扁箭头。
60
+ */
61
+ function ForkConnector({ count }: { count: number }) {
62
+ if (count <= 1) {
63
+ return (
64
+ <div className="flex h-5 w-full items-center justify-center">
65
+ <svg className="h-5 w-4 text-violet-500/70" viewBox="0 0 16 20" fill="none">
66
+ <line x1="8" y1="0" x2="8" y2="14" stroke="currentColor" strokeWidth="1.5" />
67
+ <polygon points="5,13 8,19 11,13" fill="currentColor" />
68
+ </svg>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ if (count === 2) {
74
+ return (
75
+ <div className="relative h-6 w-full">
76
+ {/* 精确的连线条 */}
77
+ <svg
78
+ className="absolute inset-0 h-full w-full text-violet-500/70"
79
+ viewBox="0 0 100 24"
80
+ preserveAspectRatio="none"
81
+ fill="none"
82
+ >
83
+ <path
84
+ d="M 50 0 L 50 12 M 25 12 L 75 12 M 25 12 L 25 18 M 75 12 L 75 18"
85
+ stroke="currentColor"
86
+ strokeWidth="1.5"
87
+ strokeLinecap="round"
88
+ strokeLinejoin="round"
89
+ vectorEffect="non-scaling-stroke"
90
+ />
91
+ </svg>
92
+ {/* 左分支精致小箭头 */}
93
+ <div className="absolute top-[17px] left-[25%] -translate-x-1/2">
94
+ <svg className="h-2 w-2.5 text-violet-500/70" viewBox="0 0 10 8" fill="none">
95
+ <polygon points="1,1 5,7 9,1" fill="currentColor" />
96
+ </svg>
97
+ </div>
98
+ {/* 右分支精致小箭头 */}
99
+ <div className="absolute top-[17px] left-[75%] -translate-x-1/2">
100
+ <svg className="h-2 w-2.5 text-violet-500/70" viewBox="0 0 10 8" fill="none">
101
+ <polygon points="1,1 5,7 9,1" fill="currentColor" />
102
+ </svg>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ if (count === 3) {
109
+ return (
110
+ <div className="relative h-6 w-full">
111
+ <svg
112
+ className="absolute inset-0 h-full w-full text-violet-500/70"
113
+ viewBox="0 0 100 24"
114
+ preserveAspectRatio="none"
115
+ fill="none"
116
+ >
117
+ <path
118
+ d="M 50 0 L 50 18 M 17 12 L 83 12 M 17 12 L 17 18 M 83 12 L 83 18"
119
+ stroke="currentColor"
120
+ strokeWidth="1.5"
121
+ strokeLinecap="round"
122
+ strokeLinejoin="round"
123
+ vectorEffect="non-scaling-stroke"
124
+ />
125
+ </svg>
126
+ <div className="absolute top-[17px] left-[17%] -translate-x-1/2">
127
+ <svg className="h-2 w-2.5 text-violet-500/70" viewBox="0 0 10 8" fill="none">
128
+ <polygon points="1,1 5,7 9,1" fill="currentColor" />
129
+ </svg>
130
+ </div>
131
+ <div className="absolute top-[17px] left-[50%] -translate-x-1/2">
132
+ <svg className="h-2 w-2.5 text-violet-500/70" viewBox="0 0 10 8" fill="none">
133
+ <polygon points="1,1 5,7 9,1" fill="currentColor" />
134
+ </svg>
135
+ </div>
136
+ <div className="absolute top-[17px] left-[83%] -translate-x-1/2">
137
+ <svg className="h-2 w-2.5 text-violet-500/70" viewBox="0 0 10 8" fill="none">
138
+ <polygon points="1,1 5,7 9,1" fill="currentColor" />
139
+ </svg>
140
+ </div>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ // 多分支(4+)
146
+ return (
147
+ <div className="flex h-5 w-full items-center justify-center">
148
+ <svg className="h-5 w-4 text-violet-500/70" viewBox="0 0 16 20" fill="none">
149
+ <line x1="8" y1="0" x2="8" y2="14" stroke="currentColor" strokeWidth="1.5" />
150
+ <polygon points="5,13 8,19 11,13" fill="currentColor" />
151
+ </svg>
152
+ </div>
153
+ );
154
+ }
155
+
156
+ /**
157
+ * 流程图汇聚连线 (Join Connector with directional arrow)
158
+ */
159
+ function JoinConnector({ count }: { count: number }) {
160
+ if (count <= 1) {
161
+ return (
162
+ <div className="flex h-5 w-full items-center justify-center">
163
+ <svg className="h-5 w-4 text-violet-500/70" viewBox="0 0 16 20" fill="none">
164
+ <line x1="8" y1="0" x2="8" y2="14" stroke="currentColor" strokeWidth="1.5" />
165
+ <polygon points="5,13 8,19 11,13" fill="currentColor" />
166
+ </svg>
167
+ </div>
168
+ );
169
+ }
170
+
171
+ if (count === 2) {
172
+ return (
173
+ <div className="relative h-6 w-full">
174
+ <svg
175
+ className="absolute inset-0 h-full w-full text-violet-500/70"
176
+ viewBox="0 0 100 24"
177
+ preserveAspectRatio="none"
178
+ fill="none"
179
+ >
180
+ <path
181
+ d="M 25 0 L 25 12 M 75 0 L 75 12 M 25 12 L 75 12 M 50 12 L 50 18"
182
+ stroke="currentColor"
183
+ strokeWidth="1.5"
184
+ strokeLinecap="round"
185
+ strokeLinejoin="round"
186
+ vectorEffect="non-scaling-stroke"
187
+ />
188
+ </svg>
189
+ <div className="absolute top-[17px] left-[50%] -translate-x-1/2">
190
+ <svg className="h-2 w-2.5 text-violet-500/70" viewBox="0 0 10 8" fill="none">
191
+ <polygon points="1,1 5,7 9,1" fill="currentColor" />
192
+ </svg>
193
+ </div>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ if (count === 3) {
199
+ return (
200
+ <div className="relative h-6 w-full">
201
+ <svg
202
+ className="absolute inset-0 h-full w-full text-violet-500/70"
203
+ viewBox="0 0 100 24"
204
+ preserveAspectRatio="none"
205
+ fill="none"
206
+ >
207
+ <path
208
+ d="M 17 0 L 17 12 M 50 0 L 50 18 M 83 0 L 83 12 M 17 12 L 83 12 M 50 12 L 50 18"
209
+ stroke="currentColor"
210
+ strokeWidth="1.5"
211
+ strokeLinecap="round"
212
+ strokeLinejoin="round"
213
+ vectorEffect="non-scaling-stroke"
214
+ />
215
+ </svg>
216
+ <div className="absolute top-[17px] left-[50%] -translate-x-1/2">
217
+ <svg className="h-2 w-2.5 text-violet-500/70" viewBox="0 0 10 8" fill="none">
218
+ <polygon points="1,1 5,7 9,1" fill="currentColor" />
219
+ </svg>
220
+ </div>
221
+ </div>
222
+ );
223
+ }
224
+
225
+ return (
226
+ <div className="flex h-5 w-full items-center justify-center">
227
+ <svg className="h-5 w-4 text-violet-500/70" viewBox="0 0 16 20" fill="none">
228
+ <line x1="8" y1="0" x2="8" y2="14" stroke="currentColor" strokeWidth="1.5" />
229
+ <polygon points="5,13 8,19 11,13" fill="currentColor" />
230
+ </svg>
231
+ </div>
232
+ );
233
+ }
234
+
235
+ export function SessionOrchestrationFlow({
236
+ flow,
237
+ chatId,
238
+ onInspectTask,
239
+ }: {
240
+ flow: OrchestrationFlowData;
241
+ chatId?: string | null;
242
+ onInspectTask?: (task: InspectTaskInput) => void;
243
+ }) {
244
+ const containerRef = useRef<HTMLDivElement>(null);
245
+ const { nodes, isAllCompleted, hasActive, summaryCopy } = flow;
246
+ const activeNode = useMemo(() => currentActiveNode(nodes), [nodes]);
247
+
248
+ // 执行中默认展开,全部完成默认收起为胶囊;支持用户手动点击切换展开状态
249
+ const [userToggled, setUserToggled] = useState<boolean | null>(null);
250
+ const expanded = userToggled ?? hasActive;
251
+
252
+ // 任务全部执行完成时自动收拢为胶囊
253
+ useEffect(() => {
254
+ if (isAllCompleted) {
255
+ setUserToggled(false);
256
+ }
257
+ }, [isAllCompleted]);
258
+
259
+ // 新出现执行中的任务时,恢复默认展开
260
+ useEffect(() => {
261
+ if (hasActive) {
262
+ setUserToggled(null);
263
+ }
264
+ }, [hasActive]);
265
+
266
+ // 全部完成后,如果用户手动展开了浮层,点击外部区域时自动收起
267
+ useEffect(() => {
268
+ if (!expanded || !isAllCompleted) return;
269
+ const handlePointerDown = (event: PointerEvent) => {
270
+ if (
271
+ containerRef.current &&
272
+ !containerRef.current.contains(event.target as Node)
273
+ ) {
274
+ setUserToggled(false);
275
+ }
276
+ };
277
+ document.addEventListener('pointerdown', handlePointerDown);
278
+ return () => document.removeEventListener('pointerdown', handlePointerDown);
279
+ }, [expanded, isAllCompleted]);
280
+
281
+ const gridColsClass =
282
+ nodes.length === 2
283
+ ? 'grid-cols-2'
284
+ : nodes.length === 3
285
+ ? 'grid-cols-3'
286
+ : 'grid-cols-1 sm:grid-cols-2';
287
+
288
+ return (
289
+ <div
290
+ ref={containerRef}
291
+ className="relative inline-flex items-center"
292
+ data-testid="session-orchestration-flow"
293
+ >
294
+ {/* 输入框上方的胶囊触发器按钮 (类似 SessionTodoList) */}
295
+ <button
296
+ type="button"
297
+ onClick={() => setUserToggled(!expanded)}
298
+ className={[
299
+ 'flex h-6 max-w-[260px] sm:max-w-[340px] items-center gap-1.5 rounded-full px-2 text-left text-[11px] transition-all duration-200 select-none shadow-2xs',
300
+ isAllCompleted
301
+ ? 'border border-emerald-500/25 bg-emerald-500/[0.04] text-emerald-700 dark:text-emerald-300 hover:bg-emerald-500/[0.08]'
302
+ : hasActive
303
+ ? 'border border-violet-500/30 bg-violet-500/[0.06] text-violet-700 dark:text-violet-300 hover:bg-violet-500/10'
304
+ : 'border border-agent-border/80 bg-agent-canvas text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
305
+ ].join(' ')}
306
+ aria-expanded={expanded}
307
+ >
308
+ <div className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
309
+ {isAllCompleted ? (
310
+ <LuCircleCheck className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
311
+ ) : hasActive ? (
312
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin text-violet-600 dark:text-violet-400" />
313
+ ) : (
314
+ <LuGitFork className="h-3.5 w-3.5 text-violet-600 dark:text-violet-400" />
315
+ )}
316
+ </div>
317
+
318
+ <span className="min-w-0 flex-1 truncate font-medium">
319
+ {isAllCompleted
320
+ ? '协同编排已就绪'
321
+ : (activeNode?.task ? activeNode.task : '协同编排流程')}
322
+ </span>
323
+
324
+ {summaryCopy ? (
325
+ <span
326
+ className={`shrink-0 rounded-full px-1.5 py-0.2 text-[10px] font-medium ${
327
+ isAllCompleted
328
+ ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
329
+ : hasActive
330
+ ? 'bg-violet-500/15 text-violet-700 dark:text-violet-300'
331
+ : 'bg-agent-muted text-agent-muted-foreground'
332
+ }`}
333
+ >
334
+ {summaryCopy}
335
+ </span>
336
+ ) : null}
337
+
338
+ <LuChevronDown
339
+ className={`h-3 w-3 shrink-0 text-current transition-transform duration-200 ${
340
+ expanded ? 'rotate-180' : ''
341
+ }`}
342
+ />
343
+ </button>
344
+
345
+ {/* 展开浮层:呈现真正的多智能体 Fork-Join 流程图 (DAG Flowchart) */}
346
+ {expanded && (
347
+ <div
348
+ className="absolute bottom-full right-0 mb-1.5 w-[390px] sm:w-[480px] overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas/98 backdrop-blur-md shadow-xl z-40 animate-in fade-in slide-in-from-bottom-1 duration-150"
349
+ >
350
+ {/* Header */}
351
+ <div className="flex items-center justify-between border-b border-agent-border/60 bg-agent-muted/30 px-3 py-2">
352
+ <div className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
353
+ <LuGitFork className="h-3.5 w-3.5 text-violet-600 dark:text-violet-400" />
354
+ <span>多智能体协同流程</span>
355
+ <span className="rounded-full bg-violet-500/10 px-1.5 py-0.2 text-[10px] font-medium text-violet-700 dark:text-violet-300">
356
+ Fork-Join
357
+ </span>
358
+ </div>
359
+ <div className="flex items-center gap-1.5">
360
+ <span
361
+ className={`rounded-full px-1.5 py-0.2 text-[10px] font-medium ${
362
+ isAllCompleted
363
+ ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
364
+ : 'bg-violet-500/10 text-violet-600 dark:text-violet-400'
365
+ }`}
366
+ >
367
+ {summaryCopy}
368
+ </span>
369
+ <button
370
+ type="button"
371
+ onClick={() => setUserToggled(false)}
372
+ className="rounded p-0.5 text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground"
373
+ aria-label="收起编排流程"
374
+ >
375
+ <LuX className="h-3 w-3" />
376
+ </button>
377
+ </div>
378
+ </div>
379
+
380
+ {/* 流程图图形主体 (DAG Flowchart) */}
381
+ <div className="max-h-88 overflow-y-auto p-3">
382
+ <div className="flex flex-col items-center">
383
+ {/* 1. 起点:目标分发 (Fork) 居中节点 */}
384
+ <div className="inline-flex items-center gap-1.5 rounded-full border border-violet-500/30 bg-violet-500/10 px-3.5 py-1 text-xs font-semibold text-violet-800 dark:text-violet-200 shadow-2xs">
385
+ <LuBot className="h-3.5 w-3.5 text-violet-600 dark:text-violet-400" />
386
+ <span>目标分发 (Fork)</span>
387
+ <span className="rounded-full bg-violet-500/20 px-1.5 py-0.2 text-[10px] font-medium text-violet-700 dark:text-violet-300">
388
+ 并发 {nodes.length} 分支
389
+ </span>
390
+ </div>
391
+
392
+ {/* 2. 向下的分叉流线 (带箭头指向每个并行分支) */}
393
+ <ForkConnector count={nodes.length} />
394
+
395
+ {/* 3. 并行子任务节点网格 (类似 Todo 的紧凑卡片) */}
396
+ <div className={`grid w-full gap-2.5 ${gridColsClass}`}>
397
+ {nodes.map((node, index) => {
398
+ const done = node.status === 'completed';
399
+ const active = node.status === 'running' || node.status === 'pending';
400
+ const canInspect = Boolean(chatId && onInspectTask && node.recordId);
401
+
402
+ return (
403
+ <div
404
+ key={node.childId || `node-${index}`}
405
+ className="flex flex-col justify-between rounded-agent-md border border-agent-border/80 bg-agent-muted/20 p-2.5 text-xs shadow-2xs transition-all hover:border-agent-border hover:bg-agent-muted/30"
406
+ >
407
+ <div>
408
+ {/* 节点顶栏:Todo 式勾选/圆点 + 分支编号 + 状态 */}
409
+ <div className="flex items-center justify-between gap-1 border-b border-agent-border/40 pb-1.5">
410
+ <div className="flex items-center gap-1.5 min-w-0">
411
+ <FlowNodeStatusIcon status={node.status} />
412
+ <span className="font-semibold text-agent-foreground text-[11px] truncate">
413
+ 分支 #{index + 1}
414
+ </span>
415
+ </div>
416
+
417
+ <div className="flex items-center gap-1">
418
+ <span
419
+ className={`rounded-full border px-1.5 py-0.2 text-[10px] font-medium ${
420
+ done
421
+ ? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
422
+ : active
423
+ ? 'border-blue-500/20 bg-blue-500/10 text-blue-700 dark:text-blue-300'
424
+ : 'border-agent-border bg-agent-muted text-agent-muted-foreground'
425
+ }`}
426
+ >
427
+ {done ? '已完成' : active ? '执行中' : '等待中'}
428
+ </span>
429
+ {canInspect && chatId ? (
430
+ <button
431
+ type="button"
432
+ onClick={() =>
433
+ onInspectTask?.({
434
+ id: node.childId,
435
+ chatId,
436
+ recordId: node.recordId,
437
+ live: node.status === 'running',
438
+ title: node.task || '子代理执行过程',
439
+ })
440
+ }
441
+ className="inline-flex shrink-0 items-center gap-0.5 rounded px-1 text-[10px] text-agent-muted-foreground hover:bg-agent-muted hover:text-agent-foreground"
442
+ title="查看过程"
443
+ >
444
+ <LuExternalLink className="h-2.5 w-2.5" />
445
+ <span>过程</span>
446
+ </button>
447
+ ) : null}
448
+ </div>
449
+ </div>
450
+
451
+ {/* 任务内容 (Todo 项文本) */}
452
+ <div className="mt-1.5 leading-relaxed">
453
+ <p
454
+ className={`line-clamp-3 text-[11px] ${
455
+ done
456
+ ? 'text-agent-foreground/90 font-medium'
457
+ : active
458
+ ? 'text-agent-foreground font-semibold'
459
+ : 'text-agent-muted-foreground'
460
+ }`}
461
+ title={node.task}
462
+ >
463
+ {node.task || `子任务 (${node.childId})`}
464
+ </p>
465
+ </div>
466
+
467
+ {/* 追加指令 (Steer) */}
468
+ {node.steers.length > 0 ? (
469
+ <div className="mt-1.5 space-y-0.5 rounded bg-amber-500/5 p-1 text-[10px] text-amber-800 dark:text-amber-200">
470
+ {node.steers.map((s, sIdx) => (
471
+ <div key={sIdx} className="flex items-start gap-1">
472
+ <LuMessageSquare className="mt-0.5 h-2.5 w-2.5 shrink-0 text-amber-600" />
473
+ <span className="truncate">{s.message}</span>
474
+ </div>
475
+ ))}
476
+ </div>
477
+ ) : null}
478
+ </div>
479
+
480
+ {/* 底部产出结果 (Answer 绿色结果徽章) */}
481
+ {node.answer ? (
482
+ <div className="mt-2 rounded bg-emerald-500/10 border border-emerald-500/20 px-2 py-1 text-[11px]">
483
+ <div className="flex items-center gap-1 font-semibold text-emerald-800 dark:text-emerald-200 text-[10px]">
484
+ <LuCheck className="h-3 w-3" />
485
+ <span>结论:</span>
486
+ <span className="font-mono text-emerald-950 dark:text-emerald-50 truncate">
487
+ {node.answer}
488
+ </span>
489
+ </div>
490
+ </div>
491
+ ) : node.error ? (
492
+ <div className="mt-2 rounded bg-agent-destructive/10 px-1.5 py-1 text-[10px] text-agent-destructive truncate">
493
+ 失败: {node.error}
494
+ </div>
495
+ ) : null}
496
+ </div>
497
+ );
498
+ })}
499
+ </div>
500
+
501
+ {/* 4. 向下的汇聚流线 (两路分支引出,汇入 Join 节点) */}
502
+ <JoinConnector count={nodes.length} />
503
+
504
+ {/* 5. 终点:结果汇聚 (Join) 居中节点 */}
505
+ <div
506
+ className={`inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1 text-xs font-semibold shadow-2xs ${
507
+ isAllCompleted
508
+ ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200'
509
+ : 'border-agent-border bg-agent-muted text-agent-muted-foreground'
510
+ }`}
511
+ >
512
+ <LuCircleCheck className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
513
+ <span>结果汇聚 (Join)</span>
514
+ <span className="text-[10px] opacity-80">
515
+ {isAllCompleted ? '所有分支已汇聚并完成回答' : '等待分支就绪...'}
516
+ </span>
517
+ </div>
518
+ </div>
519
+ </div>
520
+ </div>
521
+ )}
522
+ </div>
523
+ );
524
+ }
525
+
526
+ export default SessionOrchestrationFlow;