@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,336 @@
1
+ /**
2
+ * 多智能体协同编排模型(Orchestration Flow Model)
3
+ *
4
+ * 将 CoreLoop 编排六件套(agent_spawn / agent_wait / agent_send / agent_interrupt / agent_close / agent_list)
5
+ * 以及 SSE agent.child 生命周期事件聚合成面向用户的 DAG 流程图与 Todo 式状态模型。
6
+ */
7
+
8
+ import type { ChildInfo } from './orchestration-children-model';
9
+ import { parseToolEnvelope } from './executed-actions-model';
10
+
11
+ export const ORCHESTRATION_TOOL_NAMES = [
12
+ 'agent_spawn',
13
+ 'agent_wait',
14
+ 'agent_send',
15
+ 'agent_interrupt',
16
+ 'agent_close',
17
+ 'agent_list',
18
+ ] as const;
19
+
20
+ export type OrchestrationToolName = (typeof ORCHESTRATION_TOOL_NAMES)[number];
21
+
22
+ export function isOrchestrationTool(toolName: string): toolName is OrchestrationToolName {
23
+ return (ORCHESTRATION_TOOL_NAMES as readonly string[]).includes(toolName);
24
+ }
25
+
26
+ export type OrchestrationNodeStatus =
27
+ | 'pending'
28
+ | 'running'
29
+ | 'completed'
30
+ | 'failed'
31
+ | 'interrupted'
32
+ | 'closed';
33
+
34
+ export interface OrchestrationSteer {
35
+ message: string;
36
+ delivery?: string;
37
+ }
38
+
39
+ export interface OrchestrationChildNode {
40
+ childId: string;
41
+ task: string;
42
+ toolFilter?: string[];
43
+ status: OrchestrationNodeStatus;
44
+ answer?: string;
45
+ steers: OrchestrationSteer[];
46
+ interrupted?: boolean;
47
+ closed?: boolean;
48
+ recordId?: string;
49
+ error?: string;
50
+ spawnActionIndex?: number;
51
+ waitActionIndex?: number;
52
+ }
53
+
54
+ export interface OrchestrationFlowData {
55
+ nodes: OrchestrationChildNode[];
56
+ totalCount: number;
57
+ completedCount: number;
58
+ runningCount: number;
59
+ failedCount: number;
60
+ interruptedCount: number;
61
+ closedCount: number;
62
+ isAllCompleted: boolean;
63
+ hasActive: boolean;
64
+ summaryCopy: string;
65
+ }
66
+
67
+ interface ActionLike {
68
+ tool: string;
69
+ arguments?: unknown;
70
+ result?: unknown;
71
+ }
72
+
73
+ function safeParseJson(value: unknown): Record<string, unknown> | null {
74
+ if (!value) return null;
75
+ if (typeof value === 'object' && !Array.isArray(value)) {
76
+ return value as Record<string, unknown>;
77
+ }
78
+ if (typeof value === 'string') {
79
+ const trimmed = value.trim();
80
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
81
+ try {
82
+ const parsed = JSON.parse(trimmed);
83
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
84
+ return parsed as Record<string, unknown>;
85
+ }
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ export function extractOrchestrationFlow(
95
+ actions?: readonly ActionLike[] | null,
96
+ children?: readonly ChildInfo[] | null,
97
+ ): OrchestrationFlowData | null {
98
+ const nodeMap = new Map<string, OrchestrationChildNode>();
99
+
100
+ // 1. 先用 SSE 累积的 ChildInfo 占位(实时性最高,包含 live 状态与 recordId)
101
+ if (children && children.length > 0) {
102
+ for (const c of children) {
103
+ if (!c.childId) continue;
104
+ nodeMap.set(c.childId, {
105
+ childId: c.childId,
106
+ task: c.task || '',
107
+ status: (c.status as OrchestrationNodeStatus) || 'running',
108
+ recordId: c.recordId,
109
+ steers: [],
110
+ });
111
+ }
112
+ }
113
+
114
+ // 2. 扫描 actions 里的编排六件套
115
+ const actionList = actions ?? [];
116
+ let hasOrchAction = false;
117
+
118
+ actionList.forEach((action, actionIdx) => {
119
+ if (!isOrchestrationTool(action.tool)) return;
120
+ hasOrchAction = true;
121
+
122
+ const rawArgs = safeParseJson(action.arguments) ?? {};
123
+ const envelope = parseToolEnvelope(action.result);
124
+ const innerMsg = safeParseJson(envelope.message);
125
+ const innerData = safeParseJson(envelope.fields?.data);
126
+
127
+ if (action.tool === 'agent_spawn') {
128
+ const task = typeof rawArgs.task === 'string' ? rawArgs.task.trim() : '';
129
+ const rawFilter = Array.isArray(rawArgs.toolFilter) ? rawArgs.toolFilter.map(String) : undefined;
130
+
131
+ // childId 优先从结果里提取
132
+ const childId =
133
+ (typeof envelope.fields.childId === 'string' ? envelope.fields.childId : null) ??
134
+ (typeof innerData?.childId === 'string' ? innerData.childId : null) ??
135
+ (typeof innerMsg?.childId === 'string' ? innerMsg.childId : null) ??
136
+ `spawn-${actionIdx}`;
137
+
138
+ let node = nodeMap.get(childId);
139
+ if (!node) {
140
+ node = {
141
+ childId,
142
+ task,
143
+ toolFilter: rawFilter,
144
+ status: 'running',
145
+ steers: [],
146
+ spawnActionIndex: actionIdx,
147
+ };
148
+ nodeMap.set(childId, node);
149
+ } else {
150
+ if (!node.task && task) node.task = task;
151
+ if (!node.toolFilter && rawFilter) node.toolFilter = rawFilter;
152
+ if (node.spawnActionIndex === undefined) node.spawnActionIndex = actionIdx;
153
+ }
154
+
155
+ if (envelope.success === false || envelope.error) {
156
+ node.status = 'failed';
157
+ node.error = envelope.error || '派发失败';
158
+ }
159
+ } else if (action.tool === 'agent_wait') {
160
+ const childId = typeof rawArgs.childId === 'string' ? rawArgs.childId.trim() : '';
161
+ if (childId) {
162
+ let node = nodeMap.get(childId);
163
+ if (!node) {
164
+ node = {
165
+ childId,
166
+ task: '',
167
+ status: 'running',
168
+ steers: [],
169
+ waitActionIndex: actionIdx,
170
+ };
171
+ nodeMap.set(childId, node);
172
+ } else {
173
+ node.waitActionIndex = actionIdx;
174
+ }
175
+
176
+ const waitStatus =
177
+ (typeof innerData?.status === 'string' ? innerData.status : null) ??
178
+ (typeof innerMsg?.status === 'string' ? innerMsg.status : null);
179
+ const answer =
180
+ (typeof innerMsg?.answer === 'string' ? innerMsg.answer : null) ??
181
+ (typeof innerData?.answer === 'string' ? innerData.answer : null);
182
+
183
+ if (answer) {
184
+ node.answer = answer;
185
+ }
186
+
187
+ if (waitStatus === 'completed') {
188
+ node.status = 'completed';
189
+ } else if (waitStatus === 'failed' || waitStatus === 'error' || envelope.success === false) {
190
+ node.status = 'failed';
191
+ node.error = envelope.error || '执行失败';
192
+ } else if (waitStatus === 'running') {
193
+ node.status = 'running';
194
+ }
195
+ }
196
+ } else if (action.tool === 'agent_send') {
197
+ const childId = typeof rawArgs.childId === 'string' ? rawArgs.childId.trim() : '';
198
+ const message = typeof rawArgs.message === 'string' ? rawArgs.message.trim() : '';
199
+ if (childId && message) {
200
+ const node = nodeMap.get(childId);
201
+ if (node) {
202
+ const delivery =
203
+ (typeof innerData?.delivery === 'string' ? innerData.delivery : null) ??
204
+ (typeof innerMsg?.delivery === 'string' ? innerMsg.delivery : null);
205
+ node.steers.push({ message, delivery: delivery ?? undefined });
206
+ }
207
+ }
208
+ } else if (action.tool === 'agent_interrupt') {
209
+ const childId = typeof rawArgs.childId === 'string' ? rawArgs.childId.trim() : '';
210
+ if (childId) {
211
+ const node = nodeMap.get(childId);
212
+ if (node) {
213
+ node.interrupted = true;
214
+ node.status = 'interrupted';
215
+ }
216
+ }
217
+ } else if (action.tool === 'agent_close') {
218
+ const childId = typeof rawArgs.childId === 'string' ? rawArgs.childId.trim() : '';
219
+ if (childId) {
220
+ const node = nodeMap.get(childId);
221
+ if (node) {
222
+ node.closed = true;
223
+ if (node.status !== 'completed') {
224
+ node.status = 'closed';
225
+ }
226
+ }
227
+ }
228
+ } else if (action.tool === 'agent_list') {
229
+ const childrenArr = Array.isArray(innerMsg?.children) ? innerMsg.children : null;
230
+ if (childrenArr) {
231
+ for (const rawChild of childrenArr) {
232
+ if (rawChild && typeof rawChild === 'object') {
233
+ const cid = typeof rawChild.childId === 'string' ? rawChild.childId : '';
234
+ if (!cid) continue;
235
+ let node = nodeMap.get(cid);
236
+ if (!node) {
237
+ node = {
238
+ childId: cid,
239
+ task: typeof rawChild.task === 'string' ? rawChild.task : '',
240
+ status: (rawChild.status as OrchestrationNodeStatus) || 'running',
241
+ answer: typeof rawChild.answer === 'string' ? rawChild.answer : undefined,
242
+ steers: [],
243
+ };
244
+ nodeMap.set(cid, node);
245
+ } else {
246
+ if (!node.task && rawChild.task) node.task = String(rawChild.task);
247
+ if (!node.answer && rawChild.answer) node.answer = String(rawChild.answer);
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+ });
254
+
255
+ if (!hasOrchAction && (!children || children.length === 0)) {
256
+ return null;
257
+ }
258
+
259
+ const nodes = Array.from(nodeMap.values());
260
+ if (nodes.length === 0) return null;
261
+
262
+ const totalCount = nodes.length;
263
+ const completedCount = nodes.filter((n) => n.status === 'completed').length;
264
+ const runningCount = nodes.filter((n) => n.status === 'running' || n.status === 'pending').length;
265
+ const failedCount = nodes.filter((n) => n.status === 'failed').length;
266
+ const interruptedCount = nodes.filter((n) => n.status === 'interrupted').length;
267
+ const closedCount = nodes.filter((n) => n.status === 'closed').length;
268
+ const isAllCompleted = totalCount > 0 && completedCount === totalCount;
269
+ const hasActive = runningCount > 0;
270
+
271
+ let summaryCopy = '';
272
+ if (isAllCompleted) {
273
+ summaryCopy = `${completedCount}/${totalCount} 全部完成`;
274
+ } else if (hasActive) {
275
+ summaryCopy = `协同执行中 (${completedCount}/${totalCount} 完成)...`;
276
+ } else {
277
+ summaryCopy = `${completedCount} 完成 · ${totalCount - completedCount} 结束 (${totalCount} 个子任务)`;
278
+ }
279
+
280
+ return {
281
+ nodes,
282
+ totalCount,
283
+ completedCount,
284
+ runningCount,
285
+ failedCount,
286
+ interruptedCount,
287
+ closedCount,
288
+ isAllCompleted,
289
+ hasActive,
290
+ summaryCopy,
291
+ };
292
+ }
293
+
294
+ function actionsFromTimeline(blocks?: ReadonlyArray<{ type: string; actions?: unknown[] }>): ActionLike[] {
295
+ if (!blocks) return [];
296
+ const actions: ActionLike[] = [];
297
+ for (const block of blocks) {
298
+ if (block.type === 'tools' && Array.isArray(block.actions)) {
299
+ actions.push(...(block.actions as ActionLike[]));
300
+ }
301
+ }
302
+ return actions;
303
+ }
304
+
305
+ /**
306
+ * 提取当前打开对话中最新一轮的编排流程。
307
+ * 正在流式/执行中的回合优先;历史回合倒序寻找最新的一次编排。
308
+ */
309
+ export function resolveLatestSessionOrchestrationFlow(input: {
310
+ messages: Array<{ id: string }>;
311
+ executedActionsByMessageId?: Record<string, ActionLike[]>;
312
+ currentTurnActions?: ActionLike[];
313
+ currentTurnChildren?: ChildInfo[];
314
+ orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
315
+ timelineByMessageId?: Record<string, Array<{ type: string; actions?: unknown[] }>>;
316
+ currentTurnTimeline?: Array<{ type: string; actions?: unknown[] }>;
317
+ }): OrchestrationFlowData | null {
318
+ // 1. 优先实时回合
319
+ const liveActions =
320
+ input.currentTurnActions ?? actionsFromTimeline(input.currentTurnTimeline);
321
+ const liveFlow = extractOrchestrationFlow(liveActions, input.currentTurnChildren);
322
+ if (liveFlow && liveFlow.totalCount > 0) return liveFlow;
323
+
324
+ // 2. 倒序历史回合
325
+ for (let i = input.messages.length - 1; i >= 0; i -= 1) {
326
+ const id = input.messages[i].id;
327
+ const actions =
328
+ input.executedActionsByMessageId?.[id] ??
329
+ actionsFromTimeline(input.timelineByMessageId?.[id]);
330
+ const children = input.orchestrationChildrenByMessageId?.[id];
331
+ const flow = extractOrchestrationFlow(actions, children);
332
+ if (flow && flow.totalCount > 0) return flow;
333
+ }
334
+
335
+ return null;
336
+ }
@@ -0,0 +1,67 @@
1
+ import { LuGitFork } from 'react-icons/lu';
2
+ import { useOrchestrationSetting } from '@/lib/orchestration-settings';
3
+
4
+ /**
5
+ * OrchestrationSettingsPanel — 多智能体协同编排设置面板。
6
+ * 默认开启。用户可手动关闭或再次开启,变更实时持久化并影响后续对话请求。
7
+ */
8
+ export function OrchestrationSettingsPanel() {
9
+ const [enabled, setEnabled] = useOrchestrationSetting();
10
+
11
+ return (
12
+ <div
13
+ className="space-y-2.5 rounded-agent-md border border-agent-border bg-agent-card p-3"
14
+ data-testid="orchestration-settings-panel"
15
+ >
16
+ <div className="flex items-start justify-between gap-3">
17
+ <div className="space-y-1">
18
+ <div className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
19
+ <LuGitFork className="h-3.5 w-3.5 text-violet-600 dark:text-violet-400" />
20
+ <span>多智能体协同编排 (Fork-Join)</span>
21
+ </div>
22
+ <p className="text-xs leading-relaxed text-agent-muted-foreground">
23
+ 允许主智能体通过派生子任务(agent_spawn)并行推理计算,并在输入框上方呈现流程图与实时协同进度看板。
24
+ </p>
25
+ </div>
26
+
27
+ {/* 开关 Radio Group / Toggle */}
28
+ <div
29
+ className="flex shrink-0 rounded-full border border-agent-border bg-agent-canvas p-0.5"
30
+ role="radiogroup"
31
+ aria-label="多智能体协同编排功能开关"
32
+ >
33
+ <button
34
+ type="button"
35
+ role="radio"
36
+ aria-checked={enabled}
37
+ onClick={() => setEnabled(true)}
38
+ className={[
39
+ 'h-6 rounded-full px-2.5 text-[11px] font-medium transition-colors',
40
+ enabled
41
+ ? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 shadow-2xs'
42
+ : 'text-agent-muted-foreground hover:text-agent-foreground',
43
+ ].join(' ')}
44
+ >
45
+ 开启
46
+ </button>
47
+ <button
48
+ type="button"
49
+ role="radio"
50
+ aria-checked={!enabled}
51
+ onClick={() => setEnabled(false)}
52
+ className={[
53
+ 'h-6 rounded-full px-2.5 text-[11px] font-medium transition-colors',
54
+ !enabled
55
+ ? 'bg-agent-foreground/10 text-agent-foreground shadow-2xs'
56
+ : 'text-agent-muted-foreground hover:text-agent-foreground',
57
+ ].join(' ')}
58
+ >
59
+ 关闭
60
+ </button>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ export default OrchestrationSettingsPanel;
@@ -0,0 +1,55 @@
1
+ import React from 'react';
2
+
3
+ export interface SettingsNavItem {
4
+ id: string;
5
+ label: string;
6
+ Icon: React.ComponentType<{ className?: string }>;
7
+ }
8
+
9
+ export function SettingsNavMenu({
10
+ items,
11
+ activeId,
12
+ onSelect,
13
+ }: {
14
+ items: SettingsNavItem[];
15
+ activeId: string | null;
16
+ onSelect: (id: string) => void;
17
+ }) {
18
+ if (items.length === 0) return null;
19
+
20
+ return (
21
+ <aside
22
+ className="sticky top-0 h-fit w-44 shrink-0 py-1 select-none"
23
+ data-testid="settings-side-nav"
24
+ aria-label="设置目录导航"
25
+ >
26
+ <div className="mb-2 px-2.5 text-[11px] font-semibold tracking-wider text-agent-muted-foreground uppercase">
27
+ 设置导航
28
+ </div>
29
+ <nav className="space-y-0.5">
30
+ {items.map((item) => {
31
+ const isActive = activeId === item.id;
32
+ return (
33
+ <button
34
+ key={item.id}
35
+ type="button"
36
+ data-testid={`settings-nav-item-${item.id}`}
37
+ onClick={() => onSelect(item.id)}
38
+ className={[
39
+ 'flex w-full items-center gap-2 rounded-agent-sm px-2.5 py-1.5 text-left text-xs transition-colors',
40
+ isActive
41
+ ? 'bg-agent-foreground/10 font-semibold text-agent-foreground'
42
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
43
+ ].join(' ')}
44
+ >
45
+ <item.Icon className={`h-3.5 w-3.5 shrink-0 ${isActive ? 'text-agent-foreground' : 'text-agent-muted-foreground'}`} />
46
+ <span className="truncate">{item.label}</span>
47
+ </button>
48
+ );
49
+ })}
50
+ </nav>
51
+ </aside>
52
+ );
53
+ }
54
+
55
+ export default SettingsNavMenu;
@@ -0,0 +1,58 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ export const ORCHESTRATION_SETTING_STORAGE_KEY = 'steerable.orchestration.enabled';
4
+
5
+ const CHANGE_EVENT = 'steerable:orchestration-setting-changed';
6
+
7
+ /**
8
+ * 读取多智能体协同编排功能开关,默认开启 (true)。
9
+ */
10
+ export function isOrchestrationSettingEnabled(): boolean {
11
+ if (typeof localStorage === 'undefined') return true;
12
+ const stored = localStorage.getItem(ORCHESTRATION_SETTING_STORAGE_KEY);
13
+ if (stored === null) return true; // 默认开启
14
+ return stored !== 'false' && stored !== '0';
15
+ }
16
+
17
+ /**
18
+ * 持久化多智能体协同编排开关到 localStorage 并广播变更。
19
+ */
20
+ export function persistOrchestrationSetting(enabled: boolean): void {
21
+ if (typeof localStorage === 'undefined') return;
22
+ localStorage.setItem(ORCHESTRATION_SETTING_STORAGE_KEY, enabled ? 'true' : 'false');
23
+ if (typeof window !== 'undefined') {
24
+ window.dispatchEvent(new CustomEvent(CHANGE_EVENT, { detail: enabled }));
25
+ }
26
+ }
27
+
28
+ /**
29
+ * 响应式 hook:订阅并切换编排功能开关。
30
+ */
31
+ export function useOrchestrationSetting(): [boolean, (enabled: boolean) => void] {
32
+ const [enabled, setEnabled] = useState<boolean>(() => isOrchestrationSettingEnabled());
33
+
34
+ useEffect(() => {
35
+ if (typeof window === 'undefined') return;
36
+ const handler = (e: Event) => {
37
+ const custom = e as CustomEvent<boolean>;
38
+ if (typeof custom.detail === 'boolean') {
39
+ setEnabled(custom.detail);
40
+ } else {
41
+ setEnabled(isOrchestrationSettingEnabled());
42
+ }
43
+ };
44
+ window.addEventListener(CHANGE_EVENT, handler);
45
+ window.addEventListener('storage', handler);
46
+ return () => {
47
+ window.removeEventListener(CHANGE_EVENT, handler);
48
+ window.removeEventListener('storage', handler);
49
+ };
50
+ }, []);
51
+
52
+ const update = (next: boolean) => {
53
+ setEnabled(next);
54
+ persistOrchestrationSetting(next);
55
+ };
56
+
57
+ return [enabled, update];
58
+ }
@@ -61,6 +61,7 @@ import {
61
61
  setPendingFirstMessage,
62
62
  takePendingFirstMessage,
63
63
  } from "@/lib/pending-first-message";
64
+ import { isOrchestrationSettingEnabled } from "@/lib/orchestration-settings";
64
65
  import { BRAND_NAME, pickDefaultAgentId } from "@/brand";
65
66
  import {
66
67
  appendAttachmentRefs,
@@ -869,6 +870,7 @@ function AgentChatView({
869
870
  ...(modelOverride ? { model: modelOverride } : {}),
870
871
  ...(effortOverride ? { reasoningEffort: effortOverride } : {}),
871
872
  ...(execPolicy === "full" ? { execPolicy: "full" } : {}),
873
+ orchestration: isOrchestrationSettingEnabled(),
872
874
  };
873
875
  return sendUserMessage({
874
876
  ...input,