@steerable/agent-shell-web 0.6.51 → 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 +22 -12
- package/src/components/chat/LocalChatPanel.tsx +25 -19
- package/src/components/chat/MessageList.tsx +77 -5
- package/src/components/chat/ModelPicker.tsx +21 -1
- package/src/components/chat/OrchestrationFlowCard.tsx +13 -3
- package/src/components/chat/SessionOrchestrationFlow.tsx +66 -19
- 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 +108 -43
- 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/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/pages/AgentPage.tsx +114 -64
- package/src/pages/SettingsPage.tsx +131 -46
- 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
|
+
}
|
|
@@ -29,7 +29,8 @@ export type OrchestrationNodeStatus =
|
|
|
29
29
|
| 'completed'
|
|
30
30
|
| 'failed'
|
|
31
31
|
| 'interrupted'
|
|
32
|
-
| 'closed'
|
|
32
|
+
| 'closed'
|
|
33
|
+
| 'cancelled';
|
|
33
34
|
|
|
34
35
|
export interface OrchestrationSteer {
|
|
35
36
|
message: string;
|
|
@@ -70,6 +71,77 @@ interface ActionLike {
|
|
|
70
71
|
result?: unknown;
|
|
71
72
|
}
|
|
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
|
+
|
|
73
145
|
function safeParseJson(value: unknown): Record<string, unknown> | null {
|
|
74
146
|
if (!value) return null;
|
|
75
147
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
@@ -104,7 +176,7 @@ export function extractOrchestrationFlow(
|
|
|
104
176
|
nodeMap.set(c.childId, {
|
|
105
177
|
childId: c.childId,
|
|
106
178
|
task: c.task || '',
|
|
107
|
-
status: (c.status
|
|
179
|
+
status: toNodeStatus(c.status),
|
|
108
180
|
recordId: c.recordId,
|
|
109
181
|
steers: [],
|
|
110
182
|
});
|
|
@@ -189,6 +261,14 @@ export function extractOrchestrationFlow(
|
|
|
189
261
|
} else if (waitStatus === 'failed' || waitStatus === 'error' || envelope.success === false) {
|
|
190
262
|
node.status = 'failed';
|
|
191
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';
|
|
192
272
|
} else if (waitStatus === 'running') {
|
|
193
273
|
node.status = 'running';
|
|
194
274
|
}
|
|
@@ -232,19 +312,26 @@ export function extractOrchestrationFlow(
|
|
|
232
312
|
if (rawChild && typeof rawChild === 'object') {
|
|
233
313
|
const cid = typeof rawChild.childId === 'string' ? rawChild.childId : '';
|
|
234
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
|
+
'';
|
|
235
321
|
let node = nodeMap.get(cid);
|
|
236
322
|
if (!node) {
|
|
237
323
|
node = {
|
|
238
324
|
childId: cid,
|
|
239
325
|
task: typeof rawChild.task === 'string' ? rawChild.task : '',
|
|
240
|
-
status:
|
|
241
|
-
answer:
|
|
326
|
+
status: listedStatus ?? 'running',
|
|
327
|
+
answer: listedAnswer || undefined,
|
|
242
328
|
steers: [],
|
|
243
329
|
};
|
|
244
330
|
nodeMap.set(cid, node);
|
|
245
331
|
} else {
|
|
246
332
|
if (!node.task && rawChild.task) node.task = String(rawChild.task);
|
|
247
|
-
if (!node.answer &&
|
|
333
|
+
if (!node.answer && listedAnswer) node.answer = listedAnswer;
|
|
334
|
+
if (listedStatus) node.status = listedStatus;
|
|
248
335
|
}
|
|
249
336
|
}
|
|
250
337
|
}
|
|
@@ -256,39 +343,7 @@ export function extractOrchestrationFlow(
|
|
|
256
343
|
return null;
|
|
257
344
|
}
|
|
258
345
|
|
|
259
|
-
|
|
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
|
-
};
|
|
346
|
+
return buildOrchestrationFlow(Array.from(nodeMap.values()));
|
|
292
347
|
}
|
|
293
348
|
|
|
294
349
|
function actionsFromTimeline(blocks?: ReadonlyArray<{ type: string; actions?: unknown[] }>): ActionLike[] {
|
|
@@ -314,22 +369,32 @@ export function resolveLatestSessionOrchestrationFlow(input: {
|
|
|
314
369
|
orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
|
|
315
370
|
timelineByMessageId?: Record<string, Array<{ type: string; actions?: unknown[] }>>;
|
|
316
371
|
currentTurnTimeline?: Array<{ type: string; actions?: unknown[] }>;
|
|
372
|
+
/** 父回合仍在流式输出。结束后残留的 running 分支收成已停止。 */
|
|
373
|
+
turnActive?: boolean;
|
|
317
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
|
+
|
|
318
380
|
// 1. 优先实时回合
|
|
319
381
|
const liveActions =
|
|
320
382
|
input.currentTurnActions ?? actionsFromTimeline(input.currentTurnTimeline);
|
|
321
|
-
const liveFlow =
|
|
322
|
-
|
|
383
|
+
const liveFlow = finish(
|
|
384
|
+
extractOrchestrationFlow(liveActions, input.currentTurnChildren),
|
|
385
|
+
input.turnActive !== false,
|
|
386
|
+
);
|
|
387
|
+
if (liveFlow) return liveFlow;
|
|
323
388
|
|
|
324
|
-
// 2.
|
|
389
|
+
// 2. 倒序历史回合。落库的回合已经结束,残留 running 同样收成已停止。
|
|
325
390
|
for (let i = input.messages.length - 1; i >= 0; i -= 1) {
|
|
326
391
|
const id = input.messages[i].id;
|
|
327
392
|
const actions =
|
|
328
393
|
input.executedActionsByMessageId?.[id] ??
|
|
329
394
|
actionsFromTimeline(input.timelineByMessageId?.[id]);
|
|
330
395
|
const children = input.orchestrationChildrenByMessageId?.[id];
|
|
331
|
-
const flow = extractOrchestrationFlow(actions, children);
|
|
332
|
-
if (flow
|
|
396
|
+
const flow = finish(extractOrchestrationFlow(actions, children), false);
|
|
397
|
+
if (flow) return flow;
|
|
333
398
|
}
|
|
334
399
|
|
|
335
400
|
return null;
|
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
LuPlus,
|
|
6
6
|
LuTrash2,
|
|
7
7
|
} from 'react-icons/lu';
|
|
8
|
+
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
|
8
9
|
import { isElectron } from '@/lib/electron-bridge';
|
|
9
10
|
import {
|
|
10
11
|
archiveChatAgent,
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
/**
|
|
23
24
|
* AgentsSettingsPanel — 智能体目录(列表 / 新建 / 编辑 / 归档)。
|
|
24
25
|
*
|
|
25
|
-
* 渲染在 `/settings?section=
|
|
26
|
+
* 渲染在 `/settings?section=plugins` 的智能体分类。自定义智能体出现在输入框
|
|
26
27
|
* 上方的专家选择器里。内置智能体可改文案与能力,不能归档。
|
|
27
28
|
*
|
|
28
29
|
* 除文案外,每个智能体还持有真实生效的能力面(勾选技能 / 技能范围 /
|
|
@@ -91,7 +92,10 @@ export function AgentsSettingsPanel({
|
|
|
91
92
|
const [capability, setCapability] = useState<AgentCapabilityDraft>(
|
|
92
93
|
DEFAULT_CAPABILITY_DRAFT,
|
|
93
94
|
);
|
|
94
|
-
const [
|
|
95
|
+
const [pendingArchive, setPendingArchive] = useState<{
|
|
96
|
+
id: string;
|
|
97
|
+
name: string;
|
|
98
|
+
} | null>(null);
|
|
95
99
|
const [archivingId, setArchivingId] = useState<string | null>(null);
|
|
96
100
|
const [error, setError] = useState<string | null>(null);
|
|
97
101
|
|
|
@@ -179,17 +183,14 @@ export function AgentsSettingsPanel({
|
|
|
179
183
|
}
|
|
180
184
|
};
|
|
181
185
|
|
|
182
|
-
const handleArchive = async (
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
setArchivingId(agent.id);
|
|
186
|
+
const handleArchive = async () => {
|
|
187
|
+
if (!pendingArchive || archivingId === pendingArchive.id) return;
|
|
188
|
+
const id = pendingArchive.id;
|
|
189
|
+
setArchivingId(id);
|
|
189
190
|
setError(null);
|
|
190
191
|
try {
|
|
191
|
-
await archiveChatAgent(
|
|
192
|
-
|
|
192
|
+
await archiveChatAgent(id);
|
|
193
|
+
setPendingArchive(null);
|
|
193
194
|
await fetchAgents();
|
|
194
195
|
await onCatalogChange?.();
|
|
195
196
|
} catch (err) {
|
|
@@ -371,18 +372,12 @@ export function AgentsSettingsPanel({
|
|
|
371
372
|
{!agent.isBuiltin && (
|
|
372
373
|
<button
|
|
373
374
|
type="button"
|
|
374
|
-
onClick={() =>
|
|
375
|
-
|
|
376
|
-
className={`rounded-full p-1 transition-colors ${
|
|
377
|
-
confirmArchiveId === agent.id
|
|
378
|
-
? 'bg-agent-destructive/10 text-agent-destructive hover:bg-agent-destructive/20'
|
|
379
|
-
: 'text-agent-muted-foreground hover:bg-agent-muted hover:text-agent-destructive'
|
|
380
|
-
}`}
|
|
381
|
-
title={
|
|
382
|
-
confirmArchiveId === agent.id
|
|
383
|
-
? '再次点击确认删除'
|
|
384
|
-
: '删除'
|
|
375
|
+
onClick={() =>
|
|
376
|
+
setPendingArchive({ id: agent.id, name: agent.name })
|
|
385
377
|
}
|
|
378
|
+
disabled={archivingId === agent.id}
|
|
379
|
+
className="rounded-full p-1 text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-destructive"
|
|
380
|
+
title="删除"
|
|
386
381
|
data-testid={`agent-archive-${agent.id}`}
|
|
387
382
|
>
|
|
388
383
|
{archivingId === agent.id ? (
|
|
@@ -407,6 +402,22 @@ export function AgentsSettingsPanel({
|
|
|
407
402
|
{error}
|
|
408
403
|
</div>
|
|
409
404
|
)}
|
|
405
|
+
<ConfirmDialog
|
|
406
|
+
open={pendingArchive !== null}
|
|
407
|
+
title="删除智能体"
|
|
408
|
+
description={
|
|
409
|
+
pendingArchive
|
|
410
|
+
? `确定删除「${pendingArchive.name}」?它会从专家选择器中移除,已有会话不受影响。`
|
|
411
|
+
: ''
|
|
412
|
+
}
|
|
413
|
+
pending={pendingArchive !== null && archivingId === pendingArchive.id}
|
|
414
|
+
onCancel={() => {
|
|
415
|
+
if (archivingId) return;
|
|
416
|
+
setPendingArchive(null);
|
|
417
|
+
}}
|
|
418
|
+
onConfirm={() => void handleArchive()}
|
|
419
|
+
testId="agent-archive-dialog"
|
|
420
|
+
/>
|
|
410
421
|
</div>
|
|
411
422
|
);
|
|
412
423
|
}
|
|
@@ -15,7 +15,7 @@ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
|
|
|
15
15
|
* McpSettingsPanel — MCP 服务管理面板(JSON 导入 / 手动增改 / 启停 / 测试 /
|
|
16
16
|
* 删除)。
|
|
17
17
|
*
|
|
18
|
-
* 渲染在 `/settings?section=
|
|
18
|
+
* 渲染在 `/settings?section=plugins` 的 MCP 分类(AgentLayout 右侧内容区)。
|
|
19
19
|
*
|
|
20
20
|
* 数据与状态完全自管理:挂载时拉一次列表,操作后刷新。后端走
|
|
21
21
|
* local-backend REST(`/api/v2/mcp/servers*`),注册表持久化在 main 进程的
|
|
@@ -11,7 +11,7 @@ import { getPackHiddenSlashSkills } from '@/packs/registry';
|
|
|
11
11
|
/**
|
|
12
12
|
* SkillsSettingsPanel — 本地技能管理面板(导入目录 / 列表 / 卸载)。
|
|
13
13
|
*
|
|
14
|
-
* 渲染在 `/settings?section=
|
|
14
|
+
* 渲染在 `/settings?section=plugins` 的 Skills 分类(AgentLayout 右侧内容区)。
|
|
15
15
|
*
|
|
16
16
|
* 数据与状态完全自管理:挂载时拉一次列表,操作后刷新。后端走
|
|
17
17
|
* local-backend REST(`GET/POST /api/v2/chat-agents/skills*`),无专用 IPC。
|