@steerable/agent-shell-web 0.6.50 → 0.6.52
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/package.json +4 -4
- package/src/brand.ts +7 -0
- package/src/components/AgentSidebar.tsx +284 -188
- package/src/components/ConfirmDialog.tsx +90 -0
- package/src/components/CreateProjectModal.tsx +32 -16
- package/src/components/chat/ApprovalModal.tsx +2 -5
- package/src/components/chat/ApprovalPromptProvider.tsx +38 -22
- package/src/components/chat/AskUserPromptProvider.tsx +1 -32
- package/src/components/chat/ChatInput.tsx +47 -57
- package/src/components/chat/ChatProjectBadge.tsx +1 -1
- package/src/components/chat/EmptyChatHero.tsx +22 -0
- package/src/components/chat/ExecPolicyPicker.tsx +2 -2
- package/src/components/chat/ExecutedActionsCard.tsx +23 -13
- package/src/components/chat/LocalChatPanel.tsx +61 -18
- package/src/components/chat/MessageList.tsx +77 -5
- package/src/components/chat/ModelPicker.tsx +21 -1
- package/src/components/chat/OrchestrationFlowCard.tsx +484 -0
- package/src/components/chat/SessionOrchestrationFlow.tsx +573 -0
- package/src/components/chat/SessionTodoList.tsx +12 -3
- package/src/components/chat/UserMessage.tsx +116 -25
- package/src/components/chat/composer-status-stack.tsx +79 -0
- package/src/components/chat/orchestration-flow-model.ts +401 -0
- package/src/components/chat/prompt-chat-scope.ts +33 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +33 -22
- package/src/components/settings/McpSettingsPanel.tsx +1 -1
- package/src/components/settings/OrchestrationSettingsPanel.tsx +67 -0
- package/src/components/settings/SettingsNavMenu.tsx +55 -0
- package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
- package/src/hooks/useProjects.ts +82 -0
- package/src/lib/electron-bridge.ts +1 -0
- package/src/lib/exec-policy.ts +1 -1
- package/src/lib/local-api.ts +1 -1
- package/src/lib/orchestration-settings.ts +58 -0
- package/src/pages/AgentPage.tsx +116 -64
- package/src/pages/SettingsPage.tsx +338 -55
- package/src/vite-env.d.ts +2 -0
- package/vite.base.js +1 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import { motion } from 'framer-motion';
|
|
3
|
-
import { LuChevronDown, LuChevronUp, LuCheck, LuCopy } from 'react-icons/lu';
|
|
3
|
+
import { LuChevronDown, LuChevronUp, LuCheck, LuCopy, LuCornerDownRight } from 'react-icons/lu';
|
|
4
4
|
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
5
5
|
import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
|
|
6
6
|
import { useSlashSources } from '@/lib/slash-sources';
|
|
@@ -12,38 +12,32 @@ import { useCopy } from './useCopy';
|
|
|
12
12
|
* UserMessage — port of `deeppath`'s user-bubble component, trimmed to the
|
|
13
13
|
* agent-only surface.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
* phase 2c.
|
|
23
|
-
* - **No inline `ref://` / `tool://` / `agent://` parsing.** Pinned-ref
|
|
24
|
-
* shortcuts stay cloud-only. `@智能体名` is a Markdown chip painted from
|
|
25
|
-
* the agent list + message text (refresh restores from content).
|
|
26
|
-
*
|
|
27
|
-
* What we KEEP from the old version (and why it matters for parity):
|
|
28
|
-
* - Left-aligned bubble (not right-aligned). The product chat layout stacks
|
|
29
|
-
* both user + assistant bubbles flush-left in a vertical column — this is
|
|
30
|
-
* the most visible "feel" difference vs a typical chat UI.
|
|
31
|
-
* - Bubble background uses the secondary muted token so it sits a notch
|
|
32
|
-
* above the canvas without competing with assistant bubbles.
|
|
33
|
-
* - 4.5em fold + gradient mask + 「展开全部 / 收起」 toggle so a wall-of-text
|
|
34
|
-
* user prompt doesn't steal the whole viewport before the assistant
|
|
35
|
-
* replies.
|
|
36
|
-
* - Friendly timestamp at bottom-right.
|
|
37
|
-
* - `framer-motion` fade/slide-in on mount.
|
|
15
|
+
* Grouping (方案 A):
|
|
16
|
+
* When multiple user messages occur consecutively without an assistant reply
|
|
17
|
+
* in between, MessageList renders them as a single connected card:
|
|
18
|
+
* - The first message has `isGroupStart = true`
|
|
19
|
+
* - Mid-turn / follow-up steered additions have `isAppended = true`
|
|
20
|
+
* - The outer container groups them with a subtle dashed divider and a
|
|
21
|
+
* 「↳ 追加」 badge.
|
|
38
22
|
*/
|
|
39
23
|
|
|
40
24
|
interface UserMessageProps {
|
|
41
25
|
message: ChatMessage;
|
|
42
26
|
agents?: LocalChatAgent[];
|
|
43
27
|
chats?: LocalChat[];
|
|
28
|
+
/** Whether this message is part of a consecutive user message group. */
|
|
29
|
+
isGrouped?: boolean;
|
|
30
|
+
/** Whether this message is an appended follow-up inside the group (not the first one). */
|
|
31
|
+
isAppended?: boolean;
|
|
44
32
|
}
|
|
45
33
|
|
|
46
|
-
export function UserMessage({
|
|
34
|
+
export function UserMessage({
|
|
35
|
+
message,
|
|
36
|
+
agents = [],
|
|
37
|
+
chats = [],
|
|
38
|
+
isGrouped = false,
|
|
39
|
+
isAppended = false,
|
|
40
|
+
}: UserMessageProps) {
|
|
47
41
|
const content = message.content || '';
|
|
48
42
|
// 发出去的消息里 `/技能` `/mcp__srv__tool` 要和输入框里同样渲染成工具卡片。
|
|
49
43
|
// 目录来自共享缓存,整屏消息只拉一次。
|
|
@@ -60,6 +54,103 @@ export function UserMessage({ message, agents = [], chats = [] }: UserMessagePro
|
|
|
60
54
|
setIsOverflowing(el.scrollHeight > el.clientHeight + 1);
|
|
61
55
|
}, [content, isExpanded]);
|
|
62
56
|
|
|
57
|
+
// When rendered as an item inside a group, the parent handles the outer card.
|
|
58
|
+
if (isGrouped) {
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
className={`group/message relative px-3 py-2 text-xs text-agent-foreground ${
|
|
62
|
+
isAppended ? 'border-t border-dashed border-agent-border/60 bg-agent-muted/30' : ''
|
|
63
|
+
}`}
|
|
64
|
+
data-message-role="user"
|
|
65
|
+
data-message-id={message.id}
|
|
66
|
+
>
|
|
67
|
+
{isAppended && (
|
|
68
|
+
<div className="mb-1 flex items-center gap-1 text-[11px] font-medium text-agent-muted-foreground select-none">
|
|
69
|
+
<LuCornerDownRight className="h-3 w-3 text-agent-primary/80" />
|
|
70
|
+
<span className="rounded bg-agent-primary/10 px-1 py-0.5 text-[10px] text-agent-primary font-medium">
|
|
71
|
+
追加
|
|
72
|
+
</span>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
<div className="relative">
|
|
76
|
+
<div
|
|
77
|
+
ref={contentRef}
|
|
78
|
+
className={`markdown-content pl-1 ${
|
|
79
|
+
!isExpanded ? 'overflow-hidden' : ''
|
|
80
|
+
}`}
|
|
81
|
+
style={!isExpanded ? { maxHeight: '4.5em' } : undefined}
|
|
82
|
+
>
|
|
83
|
+
{content ? (
|
|
84
|
+
<Markdown
|
|
85
|
+
inlineParagraph
|
|
86
|
+
agents={agents}
|
|
87
|
+
chats={chats}
|
|
88
|
+
skills={skills}
|
|
89
|
+
mcpTools={mcpTools}
|
|
90
|
+
>
|
|
91
|
+
{content}
|
|
92
|
+
</Markdown>
|
|
93
|
+
) : (
|
|
94
|
+
<div className="h-4" />
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
{!isExpanded && isOverflowing && (
|
|
98
|
+
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-agent-muted to-transparent" />
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{isOverflowing && (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={(event) => {
|
|
106
|
+
event.stopPropagation();
|
|
107
|
+
setIsExpanded((prev) => !prev);
|
|
108
|
+
}}
|
|
109
|
+
className="mt-1 inline-flex items-center gap-0.5 text-xs text-agent-muted-foreground transition-colors hover:text-agent-foreground"
|
|
110
|
+
>
|
|
111
|
+
{isExpanded ? (
|
|
112
|
+
<>
|
|
113
|
+
收起
|
|
114
|
+
<LuChevronUp className="h-3 w-3" />
|
|
115
|
+
</>
|
|
116
|
+
) : (
|
|
117
|
+
<>
|
|
118
|
+
展开全部
|
|
119
|
+
<LuChevronDown className="h-3 w-3" />
|
|
120
|
+
</>
|
|
121
|
+
)}
|
|
122
|
+
</button>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
<div className="mt-1 flex items-center justify-between">
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={() => void copy()}
|
|
129
|
+
className="inline-flex items-center gap-0.5 rounded text-xs text-agent-muted-foreground opacity-0 transition-all duration-200 hover:text-agent-foreground focus:opacity-100 group-hover/message:opacity-100"
|
|
130
|
+
title={copied ? '已复制' : '复制消息'}
|
|
131
|
+
aria-label={copied ? '已复制' : '复制消息'}
|
|
132
|
+
>
|
|
133
|
+
{copied ? (
|
|
134
|
+
<>
|
|
135
|
+
<LuCheck className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
|
|
136
|
+
<span className="text-emerald-600 dark:text-emerald-400">
|
|
137
|
+
已复制
|
|
138
|
+
</span>
|
|
139
|
+
</>
|
|
140
|
+
) : (
|
|
141
|
+
<LuCopy className="h-3 w-3" />
|
|
142
|
+
)}
|
|
143
|
+
</button>
|
|
144
|
+
<div className="text-[11px] text-agent-muted-foreground">
|
|
145
|
+
{message.createdAt
|
|
146
|
+
? getFriendlyDate(new Date(message.createdAt))
|
|
147
|
+
: getFriendlyDate(new Date())}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
63
154
|
return (
|
|
64
155
|
<motion.div
|
|
65
156
|
className="group/message"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
|
|
12
|
+
export type ComposerStatusPanelId = 'orchestration' | 'todos';
|
|
13
|
+
|
|
14
|
+
type ComposerStatusContextValue = {
|
|
15
|
+
activeId: ComposerStatusPanelId | null;
|
|
16
|
+
claim: (id: ComposerStatusPanelId, force: boolean) => void;
|
|
17
|
+
release: (id: ComposerStatusPanelId) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ComposerStatusContext = createContext<ComposerStatusContextValue | null>(null);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Right-aligned column for the composer status pills (orchestration + todos).
|
|
24
|
+
* At most one panel is expanded; a user click steals the open slot, while an
|
|
25
|
+
* automatic open only claims it when nothing else is showing.
|
|
26
|
+
*/
|
|
27
|
+
export function ComposerStatusStack({ children }: { children: ReactNode }) {
|
|
28
|
+
const [activeId, setActiveId] = useState<ComposerStatusPanelId | null>(null);
|
|
29
|
+
const claim = useCallback((id: ComposerStatusPanelId, force: boolean) => {
|
|
30
|
+
setActiveId((current) => {
|
|
31
|
+
if (force || current == null || current === id) return id;
|
|
32
|
+
return current;
|
|
33
|
+
});
|
|
34
|
+
}, []);
|
|
35
|
+
const release = useCallback((id: ComposerStatusPanelId) => {
|
|
36
|
+
setActiveId((current) => (current === id ? null : current));
|
|
37
|
+
}, []);
|
|
38
|
+
const value = useMemo(
|
|
39
|
+
() => ({ activeId, claim, release }),
|
|
40
|
+
[activeId, claim, release],
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<ComposerStatusContext.Provider value={value}>
|
|
45
|
+
<div
|
|
46
|
+
className="flex min-w-0 flex-col items-end gap-1.5"
|
|
47
|
+
data-testid="composer-status-stack"
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
</div>
|
|
51
|
+
</ComposerStatusContext.Provider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fold a panel's own open preference into the stack.
|
|
57
|
+
* Outside a stack, `expanded` follows `desired` directly.
|
|
58
|
+
*/
|
|
59
|
+
export function useExclusiveExpand(
|
|
60
|
+
id: ComposerStatusPanelId,
|
|
61
|
+
desired: boolean,
|
|
62
|
+
): { expanded: boolean; requestOpen: () => void } {
|
|
63
|
+
const ctx = useContext(ComposerStatusContext);
|
|
64
|
+
const wasDesired = useRef(false);
|
|
65
|
+
|
|
66
|
+
useLayoutEffect(() => {
|
|
67
|
+
if (!ctx) return;
|
|
68
|
+
if (desired && !wasDesired.current) ctx.claim(id, false);
|
|
69
|
+
if (!desired && wasDesired.current) ctx.release(id);
|
|
70
|
+
wasDesired.current = desired;
|
|
71
|
+
}, [ctx, desired, id]);
|
|
72
|
+
|
|
73
|
+
const requestOpen = useCallback(() => {
|
|
74
|
+
ctx?.claim(id, true);
|
|
75
|
+
}, [ctx, id]);
|
|
76
|
+
|
|
77
|
+
const expanded = ctx ? desired && ctx.activeId === id : desired;
|
|
78
|
+
return { expanded, requestOpen };
|
|
79
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
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
|
+
| 'cancelled';
|
|
34
|
+
|
|
35
|
+
export interface OrchestrationSteer {
|
|
36
|
+
message: string;
|
|
37
|
+
delivery?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface OrchestrationChildNode {
|
|
41
|
+
childId: string;
|
|
42
|
+
task: string;
|
|
43
|
+
toolFilter?: string[];
|
|
44
|
+
status: OrchestrationNodeStatus;
|
|
45
|
+
answer?: string;
|
|
46
|
+
steers: OrchestrationSteer[];
|
|
47
|
+
interrupted?: boolean;
|
|
48
|
+
closed?: boolean;
|
|
49
|
+
recordId?: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
spawnActionIndex?: number;
|
|
52
|
+
waitActionIndex?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface OrchestrationFlowData {
|
|
56
|
+
nodes: OrchestrationChildNode[];
|
|
57
|
+
totalCount: number;
|
|
58
|
+
completedCount: number;
|
|
59
|
+
runningCount: number;
|
|
60
|
+
failedCount: number;
|
|
61
|
+
interruptedCount: number;
|
|
62
|
+
closedCount: number;
|
|
63
|
+
isAllCompleted: boolean;
|
|
64
|
+
hasActive: boolean;
|
|
65
|
+
summaryCopy: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ActionLike {
|
|
69
|
+
tool: string;
|
|
70
|
+
arguments?: unknown;
|
|
71
|
+
result?: unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toNodeStatus(raw: string | undefined): OrchestrationNodeStatus {
|
|
75
|
+
switch (raw) {
|
|
76
|
+
case 'pending':
|
|
77
|
+
case 'running':
|
|
78
|
+
case 'completed':
|
|
79
|
+
case 'failed':
|
|
80
|
+
case 'interrupted':
|
|
81
|
+
case 'closed':
|
|
82
|
+
case 'cancelled':
|
|
83
|
+
return raw;
|
|
84
|
+
case 'error':
|
|
85
|
+
return 'failed';
|
|
86
|
+
default:
|
|
87
|
+
return 'running';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildOrchestrationFlow(nodes: OrchestrationChildNode[]): OrchestrationFlowData | null {
|
|
92
|
+
if (nodes.length === 0) return null;
|
|
93
|
+
|
|
94
|
+
const totalCount = nodes.length;
|
|
95
|
+
const completedCount = nodes.filter((n) => n.status === 'completed').length;
|
|
96
|
+
const runningCount = nodes.filter((n) => n.status === 'running' || n.status === 'pending').length;
|
|
97
|
+
const failedCount = nodes.filter((n) => n.status === 'failed').length;
|
|
98
|
+
const interruptedCount = nodes.filter((n) => n.status === 'interrupted').length;
|
|
99
|
+
const closedCount = nodes.filter((n) => n.status === 'closed' || n.status === 'cancelled').length;
|
|
100
|
+
const isAllCompleted = totalCount > 0 && completedCount === totalCount;
|
|
101
|
+
const hasActive = runningCount > 0;
|
|
102
|
+
|
|
103
|
+
let summaryCopy = '';
|
|
104
|
+
if (isAllCompleted) {
|
|
105
|
+
summaryCopy = `${completedCount}/${totalCount} 全部完成`;
|
|
106
|
+
} else if (hasActive) {
|
|
107
|
+
summaryCopy = `协同执行中 (${completedCount}/${totalCount} 完成)...`;
|
|
108
|
+
} else {
|
|
109
|
+
summaryCopy = `${completedCount} 完成 · ${totalCount - completedCount} 结束 (${totalCount} 个子任务)`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
nodes,
|
|
114
|
+
totalCount,
|
|
115
|
+
completedCount,
|
|
116
|
+
runningCount,
|
|
117
|
+
failedCount,
|
|
118
|
+
interruptedCount,
|
|
119
|
+
closedCount,
|
|
120
|
+
isAllCompleted,
|
|
121
|
+
hasActive,
|
|
122
|
+
summaryCopy,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 父回合已经结束时,还标着 running/pending 的分支不会再收到子代理终态
|
|
128
|
+
* (停止发生在流关闭之后)。把它们收成 cancelled,避免胶囊一直停在「协同执行中」。
|
|
129
|
+
*/
|
|
130
|
+
export function settleStoppedOrchestration(flow: OrchestrationFlowData): OrchestrationFlowData {
|
|
131
|
+
if (!flow.nodes.some((node) => node.status === 'running' || node.status === 'pending')) {
|
|
132
|
+
return flow;
|
|
133
|
+
}
|
|
134
|
+
return (
|
|
135
|
+
buildOrchestrationFlow(
|
|
136
|
+
flow.nodes.map((node) =>
|
|
137
|
+
node.status === 'running' || node.status === 'pending'
|
|
138
|
+
? { ...node, status: 'cancelled' }
|
|
139
|
+
: node,
|
|
140
|
+
),
|
|
141
|
+
) ?? flow
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function safeParseJson(value: unknown): Record<string, unknown> | null {
|
|
146
|
+
if (!value) return null;
|
|
147
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
148
|
+
return value as Record<string, unknown>;
|
|
149
|
+
}
|
|
150
|
+
if (typeof value === 'string') {
|
|
151
|
+
const trimmed = value.trim();
|
|
152
|
+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
|
153
|
+
try {
|
|
154
|
+
const parsed = JSON.parse(trimmed);
|
|
155
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
156
|
+
return parsed as Record<string, unknown>;
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function extractOrchestrationFlow(
|
|
167
|
+
actions?: readonly ActionLike[] | null,
|
|
168
|
+
children?: readonly ChildInfo[] | null,
|
|
169
|
+
): OrchestrationFlowData | null {
|
|
170
|
+
const nodeMap = new Map<string, OrchestrationChildNode>();
|
|
171
|
+
|
|
172
|
+
// 1. 先用 SSE 累积的 ChildInfo 占位(实时性最高,包含 live 状态与 recordId)
|
|
173
|
+
if (children && children.length > 0) {
|
|
174
|
+
for (const c of children) {
|
|
175
|
+
if (!c.childId) continue;
|
|
176
|
+
nodeMap.set(c.childId, {
|
|
177
|
+
childId: c.childId,
|
|
178
|
+
task: c.task || '',
|
|
179
|
+
status: toNodeStatus(c.status),
|
|
180
|
+
recordId: c.recordId,
|
|
181
|
+
steers: [],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 2. 扫描 actions 里的编排六件套
|
|
187
|
+
const actionList = actions ?? [];
|
|
188
|
+
let hasOrchAction = false;
|
|
189
|
+
|
|
190
|
+
actionList.forEach((action, actionIdx) => {
|
|
191
|
+
if (!isOrchestrationTool(action.tool)) return;
|
|
192
|
+
hasOrchAction = true;
|
|
193
|
+
|
|
194
|
+
const rawArgs = safeParseJson(action.arguments) ?? {};
|
|
195
|
+
const envelope = parseToolEnvelope(action.result);
|
|
196
|
+
const innerMsg = safeParseJson(envelope.message);
|
|
197
|
+
const innerData = safeParseJson(envelope.fields?.data);
|
|
198
|
+
|
|
199
|
+
if (action.tool === 'agent_spawn') {
|
|
200
|
+
const task = typeof rawArgs.task === 'string' ? rawArgs.task.trim() : '';
|
|
201
|
+
const rawFilter = Array.isArray(rawArgs.toolFilter) ? rawArgs.toolFilter.map(String) : undefined;
|
|
202
|
+
|
|
203
|
+
// childId 优先从结果里提取
|
|
204
|
+
const childId =
|
|
205
|
+
(typeof envelope.fields.childId === 'string' ? envelope.fields.childId : null) ??
|
|
206
|
+
(typeof innerData?.childId === 'string' ? innerData.childId : null) ??
|
|
207
|
+
(typeof innerMsg?.childId === 'string' ? innerMsg.childId : null) ??
|
|
208
|
+
`spawn-${actionIdx}`;
|
|
209
|
+
|
|
210
|
+
let node = nodeMap.get(childId);
|
|
211
|
+
if (!node) {
|
|
212
|
+
node = {
|
|
213
|
+
childId,
|
|
214
|
+
task,
|
|
215
|
+
toolFilter: rawFilter,
|
|
216
|
+
status: 'running',
|
|
217
|
+
steers: [],
|
|
218
|
+
spawnActionIndex: actionIdx,
|
|
219
|
+
};
|
|
220
|
+
nodeMap.set(childId, node);
|
|
221
|
+
} else {
|
|
222
|
+
if (!node.task && task) node.task = task;
|
|
223
|
+
if (!node.toolFilter && rawFilter) node.toolFilter = rawFilter;
|
|
224
|
+
if (node.spawnActionIndex === undefined) node.spawnActionIndex = actionIdx;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (envelope.success === false || envelope.error) {
|
|
228
|
+
node.status = 'failed';
|
|
229
|
+
node.error = envelope.error || '派发失败';
|
|
230
|
+
}
|
|
231
|
+
} else if (action.tool === 'agent_wait') {
|
|
232
|
+
const childId = typeof rawArgs.childId === 'string' ? rawArgs.childId.trim() : '';
|
|
233
|
+
if (childId) {
|
|
234
|
+
let node = nodeMap.get(childId);
|
|
235
|
+
if (!node) {
|
|
236
|
+
node = {
|
|
237
|
+
childId,
|
|
238
|
+
task: '',
|
|
239
|
+
status: 'running',
|
|
240
|
+
steers: [],
|
|
241
|
+
waitActionIndex: actionIdx,
|
|
242
|
+
};
|
|
243
|
+
nodeMap.set(childId, node);
|
|
244
|
+
} else {
|
|
245
|
+
node.waitActionIndex = actionIdx;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const waitStatus =
|
|
249
|
+
(typeof innerData?.status === 'string' ? innerData.status : null) ??
|
|
250
|
+
(typeof innerMsg?.status === 'string' ? innerMsg.status : null);
|
|
251
|
+
const answer =
|
|
252
|
+
(typeof innerMsg?.answer === 'string' ? innerMsg.answer : null) ??
|
|
253
|
+
(typeof innerData?.answer === 'string' ? innerData.answer : null);
|
|
254
|
+
|
|
255
|
+
if (answer) {
|
|
256
|
+
node.answer = answer;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (waitStatus === 'completed') {
|
|
260
|
+
node.status = 'completed';
|
|
261
|
+
} else if (waitStatus === 'failed' || waitStatus === 'error' || envelope.success === false) {
|
|
262
|
+
node.status = 'failed';
|
|
263
|
+
node.error = envelope.error || '执行失败';
|
|
264
|
+
} else if (waitStatus === 'cancelled') {
|
|
265
|
+
node.status = 'cancelled';
|
|
266
|
+
} else if (waitStatus === 'interrupted') {
|
|
267
|
+
node.interrupted = true;
|
|
268
|
+
node.status = 'interrupted';
|
|
269
|
+
} else if (waitStatus === 'closed') {
|
|
270
|
+
node.closed = true;
|
|
271
|
+
if (node.status !== 'completed') node.status = 'closed';
|
|
272
|
+
} else if (waitStatus === 'running') {
|
|
273
|
+
node.status = 'running';
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} else if (action.tool === 'agent_send') {
|
|
277
|
+
const childId = typeof rawArgs.childId === 'string' ? rawArgs.childId.trim() : '';
|
|
278
|
+
const message = typeof rawArgs.message === 'string' ? rawArgs.message.trim() : '';
|
|
279
|
+
if (childId && message) {
|
|
280
|
+
const node = nodeMap.get(childId);
|
|
281
|
+
if (node) {
|
|
282
|
+
const delivery =
|
|
283
|
+
(typeof innerData?.delivery === 'string' ? innerData.delivery : null) ??
|
|
284
|
+
(typeof innerMsg?.delivery === 'string' ? innerMsg.delivery : null);
|
|
285
|
+
node.steers.push({ message, delivery: delivery ?? undefined });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} else if (action.tool === 'agent_interrupt') {
|
|
289
|
+
const childId = typeof rawArgs.childId === 'string' ? rawArgs.childId.trim() : '';
|
|
290
|
+
if (childId) {
|
|
291
|
+
const node = nodeMap.get(childId);
|
|
292
|
+
if (node) {
|
|
293
|
+
node.interrupted = true;
|
|
294
|
+
node.status = 'interrupted';
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} else if (action.tool === 'agent_close') {
|
|
298
|
+
const childId = typeof rawArgs.childId === 'string' ? rawArgs.childId.trim() : '';
|
|
299
|
+
if (childId) {
|
|
300
|
+
const node = nodeMap.get(childId);
|
|
301
|
+
if (node) {
|
|
302
|
+
node.closed = true;
|
|
303
|
+
if (node.status !== 'completed') {
|
|
304
|
+
node.status = 'closed';
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} else if (action.tool === 'agent_list') {
|
|
309
|
+
const childrenArr = Array.isArray(innerMsg?.children) ? innerMsg.children : null;
|
|
310
|
+
if (childrenArr) {
|
|
311
|
+
for (const rawChild of childrenArr) {
|
|
312
|
+
if (rawChild && typeof rawChild === 'object') {
|
|
313
|
+
const cid = typeof rawChild.childId === 'string' ? rawChild.childId : '';
|
|
314
|
+
if (!cid) continue;
|
|
315
|
+
const listedStatus =
|
|
316
|
+
typeof rawChild.status === 'string' ? toNodeStatus(rawChild.status) : undefined;
|
|
317
|
+
const listedAnswer =
|
|
318
|
+
(typeof rawChild.answer === 'string' && rawChild.answer) ||
|
|
319
|
+
(typeof rawChild.answerPreview === 'string' && rawChild.answerPreview) ||
|
|
320
|
+
'';
|
|
321
|
+
let node = nodeMap.get(cid);
|
|
322
|
+
if (!node) {
|
|
323
|
+
node = {
|
|
324
|
+
childId: cid,
|
|
325
|
+
task: typeof rawChild.task === 'string' ? rawChild.task : '',
|
|
326
|
+
status: listedStatus ?? 'running',
|
|
327
|
+
answer: listedAnswer || undefined,
|
|
328
|
+
steers: [],
|
|
329
|
+
};
|
|
330
|
+
nodeMap.set(cid, node);
|
|
331
|
+
} else {
|
|
332
|
+
if (!node.task && rawChild.task) node.task = String(rawChild.task);
|
|
333
|
+
if (!node.answer && listedAnswer) node.answer = listedAnswer;
|
|
334
|
+
if (listedStatus) node.status = listedStatus;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (!hasOrchAction && (!children || children.length === 0)) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return buildOrchestrationFlow(Array.from(nodeMap.values()));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function actionsFromTimeline(blocks?: ReadonlyArray<{ type: string; actions?: unknown[] }>): ActionLike[] {
|
|
350
|
+
if (!blocks) return [];
|
|
351
|
+
const actions: ActionLike[] = [];
|
|
352
|
+
for (const block of blocks) {
|
|
353
|
+
if (block.type === 'tools' && Array.isArray(block.actions)) {
|
|
354
|
+
actions.push(...(block.actions as ActionLike[]));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return actions;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 提取当前打开对话中最新一轮的编排流程。
|
|
362
|
+
* 正在流式/执行中的回合优先;历史回合倒序寻找最新的一次编排。
|
|
363
|
+
*/
|
|
364
|
+
export function resolveLatestSessionOrchestrationFlow(input: {
|
|
365
|
+
messages: Array<{ id: string }>;
|
|
366
|
+
executedActionsByMessageId?: Record<string, ActionLike[]>;
|
|
367
|
+
currentTurnActions?: ActionLike[];
|
|
368
|
+
currentTurnChildren?: ChildInfo[];
|
|
369
|
+
orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
|
|
370
|
+
timelineByMessageId?: Record<string, Array<{ type: string; actions?: unknown[] }>>;
|
|
371
|
+
currentTurnTimeline?: Array<{ type: string; actions?: unknown[] }>;
|
|
372
|
+
/** 父回合仍在流式输出。结束后残留的 running 分支收成已停止。 */
|
|
373
|
+
turnActive?: boolean;
|
|
374
|
+
}): OrchestrationFlowData | null {
|
|
375
|
+
const finish = (flow: OrchestrationFlowData | null, active: boolean) => {
|
|
376
|
+
if (!flow || flow.totalCount === 0) return null;
|
|
377
|
+
return active ? flow : settleStoppedOrchestration(flow);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// 1. 优先实时回合
|
|
381
|
+
const liveActions =
|
|
382
|
+
input.currentTurnActions ?? actionsFromTimeline(input.currentTurnTimeline);
|
|
383
|
+
const liveFlow = finish(
|
|
384
|
+
extractOrchestrationFlow(liveActions, input.currentTurnChildren),
|
|
385
|
+
input.turnActive !== false,
|
|
386
|
+
);
|
|
387
|
+
if (liveFlow) return liveFlow;
|
|
388
|
+
|
|
389
|
+
// 2. 倒序历史回合。落库的回合已经结束,残留 running 同样收成已停止。
|
|
390
|
+
for (let i = input.messages.length - 1; i >= 0; i -= 1) {
|
|
391
|
+
const id = input.messages[i].id;
|
|
392
|
+
const actions =
|
|
393
|
+
input.executedActionsByMessageId?.[id] ??
|
|
394
|
+
actionsFromTimeline(input.timelineByMessageId?.[id]);
|
|
395
|
+
const children = input.orchestrationChildrenByMessageId?.[id];
|
|
396
|
+
const flow = finish(extractOrchestrationFlow(actions, children), false);
|
|
397
|
+
if (flow) return flow;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Chat id from `#/agent/:chatId` (hash router) or `/agent/:chatId`. */
|
|
2
|
+
export function chatIdFromLocation(location: { pathname: string; hash: string }): string | null {
|
|
3
|
+
const hashed = location.hash.startsWith('#') ? location.hash.slice(1) : '';
|
|
4
|
+
const raw = hashed || location.pathname;
|
|
5
|
+
const match = raw.match(/\/agent\/([^/?#]+)/);
|
|
6
|
+
if (!match?.[1]) return null;
|
|
7
|
+
try {
|
|
8
|
+
return decodeURIComponent(match[1]);
|
|
9
|
+
} catch {
|
|
10
|
+
return match[1];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Stamp an unscoped prompt with the chat open when it arrived. */
|
|
15
|
+
export function bindPromptToActiveChat<T extends { chatId?: string }>(request: T): T {
|
|
16
|
+
if (request.chatId) return request;
|
|
17
|
+
const chatId = chatIdFromLocation(window.location);
|
|
18
|
+
if (!chatId) return request;
|
|
19
|
+
return { ...request, chatId };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A prompt with a chat id belongs only to that chat.
|
|
24
|
+
* A prompt without one stays visible only when the composer itself has no chat,
|
|
25
|
+
* so an older request is still answerable.
|
|
26
|
+
*/
|
|
27
|
+
export function promptVisibleInChat(
|
|
28
|
+
request: { chatId?: string },
|
|
29
|
+
chatId: string | null | undefined,
|
|
30
|
+
): boolean {
|
|
31
|
+
if (!request.chatId) return !chatId;
|
|
32
|
+
return request.chatId === chatId;
|
|
33
|
+
}
|