@steerable/agent-shell-web 0.6.20 → 0.6.21
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/components/chat/ApprovalModal.tsx +21 -41
- package/src/components/chat/ApprovalPromptProvider.tsx +98 -0
- package/src/components/chat/AskUserPromptProvider.tsx +99 -0
- package/src/components/chat/AssistantMessage.tsx +25 -2
- package/src/components/chat/ChatInput.tsx +116 -13
- package/src/components/chat/FilePathCode.tsx +65 -0
- package/src/components/chat/LocalChatPanel.tsx +19 -0
- package/src/components/chat/Markdown.tsx +54 -25
- package/src/components/chat/MessageList.tsx +66 -18
- package/src/components/chat/ModelPicker.tsx +5 -3
- package/src/components/chat/SuggestedReplies.tsx +34 -0
- package/src/components/chat/TurnFilesCard.tsx +113 -0
- package/src/components/chat/TurnProcessGroup.tsx +7 -2
- package/src/components/chat/path-mentions.ts +148 -0
- package/src/components/chat/suggested-replies-model.ts +40 -0
- package/src/components/chat/turn-files.ts +50 -0
- package/src/layouts/AgentLayout.tsx +15 -26
- package/src/lib/browser-dev-electron-mock.ts +15 -0
- package/src/lib/electron-bridge.ts +13 -7
- package/src/lib/http-bridge.ts +8 -2
- package/src/lib/local-api.ts +31 -0
- package/src/pages/AgentPage.tsx +126 -1
- package/src/pages/ChatPanelPreviewPage.tsx +4 -0
- package/src/components/chat/AskUserModalHost.tsx +0 -61
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
|
2
|
+
import { openLocalPath, type ResolvedLocalPath } from '@/lib/local-api';
|
|
3
|
+
import { peekResolvedPath, subscribeResolvedPath } from './path-mentions';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 行内代码里的文件路径:后端确认存在后变成可点击,点击用系统默认应用打开。
|
|
7
|
+
*
|
|
8
|
+
* 未确认存在(或后端不可达)时渲染成与普通行内代码完全一致的外观——路径
|
|
9
|
+
* 靠形状判断挑出,误报难免,不能给点不开的假 affordance。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const INLINE_CODE_CLASS =
|
|
13
|
+
'rounded bg-agent-muted px-1 py-0.5 font-mono text-[0.85em] text-agent-foreground';
|
|
14
|
+
|
|
15
|
+
interface FilePathCodeProps extends ComponentPropsWithoutRef<'code'> {
|
|
16
|
+
/** 行内代码的纯文本,即待解析的路径字面量。 */
|
|
17
|
+
candidate: string;
|
|
18
|
+
/** 当前对话(相对路径按其绑定项目根解析),落地页可缺省。 */
|
|
19
|
+
chatId?: string | null;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function FilePathCode({ candidate, chatId, children, ...rest }: FilePathCodeProps) {
|
|
24
|
+
const [resolved, setResolved] = useState<ResolvedLocalPath | null>(
|
|
25
|
+
() => peekResolvedPath(candidate, chatId) ?? null,
|
|
26
|
+
);
|
|
27
|
+
const [openError, setOpenError] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
setOpenError(null);
|
|
31
|
+
return subscribeResolvedPath(candidate, chatId, (entry) => setResolved(entry));
|
|
32
|
+
}, [candidate, chatId]);
|
|
33
|
+
|
|
34
|
+
if (!resolved) {
|
|
35
|
+
return (
|
|
36
|
+
<code {...rest} className={INLINE_CODE_CLASS}>
|
|
37
|
+
{children}
|
|
38
|
+
</code>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const handleClick = async () => {
|
|
43
|
+
setOpenError(null);
|
|
44
|
+
try {
|
|
45
|
+
const result = await openLocalPath(resolved.path);
|
|
46
|
+
if (!result.success) setOpenError(result.error || '打开失败');
|
|
47
|
+
} catch (err) {
|
|
48
|
+
setOpenError(err instanceof Error ? err.message : '打开失败');
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
data-file-path-chip=""
|
|
56
|
+
onClick={handleClick}
|
|
57
|
+
title={openError ? `${resolved.path}(${openError})` : `点击打开 ${resolved.path}`}
|
|
58
|
+
className={`${INLINE_CODE_CLASS} cursor-pointer underline decoration-dotted underline-offset-2 transition-colors hover:bg-agent-accent/15 hover:text-agent-accent ${
|
|
59
|
+
openError ? 'text-red-600 dark:text-red-400' : ''
|
|
60
|
+
}`}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</button>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -15,6 +15,7 @@ import { MessageList } from './MessageList';
|
|
|
15
15
|
import type { ExecutedAction } from './ExecutedActionsCard';
|
|
16
16
|
import type { ChildInfo } from './OrchestrationChildrenCard';
|
|
17
17
|
import type { TurnBlock } from './turn-timeline';
|
|
18
|
+
import type { TurnFile } from './turn-files';
|
|
18
19
|
import {
|
|
19
20
|
isImageFile,
|
|
20
21
|
saveChatAttachments,
|
|
@@ -97,6 +98,10 @@ export interface LocalChatPanelProps {
|
|
|
97
98
|
currentTurnTimeline?: TurnBlock[];
|
|
98
99
|
currentTurnStartedAtMs?: number;
|
|
99
100
|
durationByMessageId?: Record<string, number>;
|
|
101
|
+
/** 回合产物文件列表(按落库消息 id 键控),转发给 MessageList。 */
|
|
102
|
+
turnFilesByMessageId?: Record<string, TurnFile[]>;
|
|
103
|
+
/** 当轮产物文件(流尾声到达、尚未归档到落库 id 的尾部消息用)。 */
|
|
104
|
+
currentTurnFiles?: TurnFile[];
|
|
100
105
|
/** P3.1: in-flight child agents + per-message reconciliation map. */
|
|
101
106
|
currentTurnChildren?: ChildInfo[];
|
|
102
107
|
orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
|
|
@@ -141,6 +146,11 @@ export interface LocalChatPanelProps {
|
|
|
141
146
|
onDismissFinishedTask?: (taskId: string) => void;
|
|
142
147
|
/** 分享当前对话(截图)。落到最近一条助手消息的时间戳行。 */
|
|
143
148
|
onShare?: () => Promise<boolean>;
|
|
149
|
+
/**
|
|
150
|
+
* 最近一条助手回复下的下一轮输入建议。点击即作为新用户消息发出。
|
|
151
|
+
*/
|
|
152
|
+
suggestedReplies?: string[];
|
|
153
|
+
onSelectSuggestion?: (text: string) => void;
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
export function LocalChatPanel({
|
|
@@ -166,6 +176,8 @@ export function LocalChatPanel({
|
|
|
166
176
|
currentTurnTimeline,
|
|
167
177
|
currentTurnStartedAtMs,
|
|
168
178
|
durationByMessageId,
|
|
179
|
+
turnFilesByMessageId,
|
|
180
|
+
currentTurnFiles,
|
|
169
181
|
currentTurnChildren,
|
|
170
182
|
orchestrationChildrenByMessageId,
|
|
171
183
|
currentRound,
|
|
@@ -189,6 +201,8 @@ export function LocalChatPanel({
|
|
|
189
201
|
onInspectTask,
|
|
190
202
|
onDismissFinishedTask,
|
|
191
203
|
onShare,
|
|
204
|
+
suggestedReplies,
|
|
205
|
+
onSelectSuggestion,
|
|
192
206
|
}: LocalChatPanelProps) {
|
|
193
207
|
const [inputValue, setInputValue] = useState('');
|
|
194
208
|
const [files, setFiles] = useState<AttachmentFile[]>([]);
|
|
@@ -374,6 +388,7 @@ export function LocalChatPanel({
|
|
|
374
388
|
isStreaming={isStreaming}
|
|
375
389
|
agents={agents}
|
|
376
390
|
chats={chats}
|
|
391
|
+
chatId={chatId}
|
|
377
392
|
currentAgent={currentAgent}
|
|
378
393
|
executedActionsByMessageId={executedActionsByMessageId}
|
|
379
394
|
currentTurnActions={currentTurnActions}
|
|
@@ -381,6 +396,8 @@ export function LocalChatPanel({
|
|
|
381
396
|
currentTurnTimeline={currentTurnTimeline}
|
|
382
397
|
currentTurnStartedAtMs={currentTurnStartedAtMs}
|
|
383
398
|
durationByMessageId={durationByMessageId}
|
|
399
|
+
turnFilesByMessageId={turnFilesByMessageId}
|
|
400
|
+
currentTurnFiles={currentTurnFiles}
|
|
384
401
|
currentTurnChildren={currentTurnChildren}
|
|
385
402
|
orchestrationChildrenByMessageId={orchestrationChildrenByMessageId}
|
|
386
403
|
currentRound={currentRound}
|
|
@@ -394,6 +411,8 @@ export function LocalChatPanel({
|
|
|
394
411
|
onInspectTask={onInspectTask}
|
|
395
412
|
onDismissFinishedTask={onDismissFinishedTask}
|
|
396
413
|
onShare={onShare}
|
|
414
|
+
suggestedReplies={suggestedReplies}
|
|
415
|
+
onSelectSuggestion={onSelectSuggestion}
|
|
397
416
|
/>
|
|
398
417
|
{inputBanner}
|
|
399
418
|
{chatInputNode}
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
type SkillItem,
|
|
19
19
|
} from '@/lib/slash-sources';
|
|
20
20
|
import { PlanTodoList } from './PlanTodoList';
|
|
21
|
+
import { FilePathCode } from './FilePathCode';
|
|
22
|
+
import { looksLikeFilePath } from './path-mentions';
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Markdown renderer shared by `UserMessage` and `AssistantMessage`.
|
|
@@ -146,7 +148,31 @@ const components: Components = {
|
|
|
146
148
|
),
|
|
147
149
|
|
|
148
150
|
// ── Code (inline + block) ─────────────────────────────────────────────
|
|
149
|
-
code: (
|
|
151
|
+
code: renderCode(null),
|
|
152
|
+
pre: (props: ComponentPropsWithoutRef<'pre'>) => {
|
|
153
|
+
// A ```plan fence arrives as <pre><code class="language-plan">…</code></pre>.
|
|
154
|
+
// The code renderer above already swaps the inner node for PlanTodoList;
|
|
155
|
+
// here we drop the <pre> shell so the card isn't boxed in a code block.
|
|
156
|
+
if (containsPlanCode(props.children)) {
|
|
157
|
+
return <>{props.children}</>;
|
|
158
|
+
}
|
|
159
|
+
return (
|
|
160
|
+
<pre
|
|
161
|
+
{...props}
|
|
162
|
+
className="my-2 overflow-x-auto rounded-md border border-agent-border bg-agent-muted/60 p-2.5 text-[12px] leading-relaxed text-agent-foreground"
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 行内代码 / 代码块渲染器。`chatId` 只影响行内代码:形状上像本地路径的
|
|
170
|
+
* 字面量交给 FilePathCode,由它经后端确认存在后变成可点击(相对路径按
|
|
171
|
+
* 该对话绑定的项目根解析)。代码块内的路径不参与——整块代码是给人读/
|
|
172
|
+
* 复制的,逐 token 挑路径会把块结构打散。
|
|
173
|
+
*/
|
|
174
|
+
function renderCode(chatId: string | null): Components['code'] {
|
|
175
|
+
return (props) => {
|
|
150
176
|
const { children, className, ...rest } = props as ComponentPropsWithoutRef<'code'> & {
|
|
151
177
|
// react-markdown v9 dropped the `inline` typed prop. Distinguish by
|
|
152
178
|
// language fence (the only time class is set is for fenced blocks
|
|
@@ -167,6 +193,14 @@ const components: Components = {
|
|
|
167
193
|
</code>
|
|
168
194
|
);
|
|
169
195
|
}
|
|
196
|
+
const text = childrenToText(children);
|
|
197
|
+
if (looksLikeFilePath(text)) {
|
|
198
|
+
return (
|
|
199
|
+
<FilePathCode {...rest} candidate={text.trim()} chatId={chatId}>
|
|
200
|
+
{children}
|
|
201
|
+
</FilePathCode>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
170
204
|
return (
|
|
171
205
|
<code
|
|
172
206
|
{...rest}
|
|
@@ -175,22 +209,8 @@ const components: Components = {
|
|
|
175
209
|
{children}
|
|
176
210
|
</code>
|
|
177
211
|
);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// A ```plan fence arrives as <pre><code class="language-plan">…</code></pre>.
|
|
181
|
-
// The code renderer above already swaps the inner node for PlanTodoList;
|
|
182
|
-
// here we drop the <pre> shell so the card isn't boxed in a code block.
|
|
183
|
-
if (containsPlanCode(props.children)) {
|
|
184
|
-
return <>{props.children}</>;
|
|
185
|
-
}
|
|
186
|
-
return (
|
|
187
|
-
<pre
|
|
188
|
-
{...props}
|
|
189
|
-
className="my-2 overflow-x-auto rounded-md border border-agent-border bg-agent-muted/60 p-2.5 text-[12px] leading-relaxed text-agent-foreground"
|
|
190
|
-
/>
|
|
191
|
-
);
|
|
192
|
-
},
|
|
193
|
-
};
|
|
212
|
+
};
|
|
213
|
+
}
|
|
194
214
|
|
|
195
215
|
function isPlanCodeClass(className: unknown): boolean {
|
|
196
216
|
return typeof className === 'string' && className.split(/\s+/).includes('language-plan');
|
|
@@ -359,6 +379,11 @@ interface MarkdownProps {
|
|
|
359
379
|
skills?: SkillItem[];
|
|
360
380
|
/** MCP tools behind `/mcp__srv__tool` chips. */
|
|
361
381
|
mcpTools?: McpToolItem[];
|
|
382
|
+
/**
|
|
383
|
+
* 当前对话。行内代码里的路径要按它绑定的项目根解析相对路径;缺省时
|
|
384
|
+
* 相对路径按 home 解析(同无项目对话里 exec 的缺省 cwd)。
|
|
385
|
+
*/
|
|
386
|
+
chatId?: string | null;
|
|
362
387
|
}
|
|
363
388
|
|
|
364
389
|
/**
|
|
@@ -401,15 +426,19 @@ export function Markdown({
|
|
|
401
426
|
chats = [],
|
|
402
427
|
skills = [],
|
|
403
428
|
mcpTools = [],
|
|
429
|
+
chatId = null,
|
|
404
430
|
}: MarkdownProps) {
|
|
405
|
-
const baseComponents: Components =
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
431
|
+
const baseComponents: Components = {
|
|
432
|
+
...components,
|
|
433
|
+
code: renderCode(chatId),
|
|
434
|
+
...(inlineParagraph
|
|
435
|
+
? {
|
|
436
|
+
p: (props: ComponentPropsWithoutRef<'p'>) => (
|
|
437
|
+
<span style={{ whiteSpace: 'pre-line' }} {...props} />
|
|
438
|
+
),
|
|
439
|
+
}
|
|
440
|
+
: {}),
|
|
441
|
+
};
|
|
413
442
|
|
|
414
443
|
const finalComponents = withTokens(baseComponents, (node) =>
|
|
415
444
|
renderTextWithMentions(node, agents, chats, skills, mcpTools),
|
|
@@ -13,10 +13,12 @@ import { UserMessage } from './UserMessage';
|
|
|
13
13
|
import { AssistantMessage } from './AssistantMessage';
|
|
14
14
|
import { InterruptedTurnCard } from './InterruptedTurnCard';
|
|
15
15
|
import { TaskOutcomeCards } from './TaskOutcomeCards';
|
|
16
|
+
import { SuggestedReplies } from './SuggestedReplies';
|
|
16
17
|
import type { ExecutedAction } from './ExecutedActionsCard';
|
|
17
18
|
import type { ChildInfo } from './OrchestrationChildrenCard';
|
|
18
19
|
import type { ChatMode } from './ChatInput';
|
|
19
20
|
import type { TurnBlock } from './turn-timeline';
|
|
21
|
+
import type { TurnFile } from './turn-files';
|
|
20
22
|
import { inferDurationMs, readPersistedDurationMs } from './elapsed';
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -55,6 +57,11 @@ interface MessageListProps {
|
|
|
55
57
|
emptyState?: ReactNode;
|
|
56
58
|
agents: LocalChatAgent[];
|
|
57
59
|
chats?: LocalChat[];
|
|
60
|
+
/**
|
|
61
|
+
* 当前对话。正文行内代码里的路径按其绑定项目根解析后,确认存在的变成
|
|
62
|
+
* 可点击(见 FilePathCode)。
|
|
63
|
+
*/
|
|
64
|
+
chatId?: string | null;
|
|
58
65
|
currentAgent: LocalChatAgent | null;
|
|
59
66
|
/**
|
|
60
67
|
* Tool calls keyed by the persisted message id (post-stream). Used for
|
|
@@ -73,6 +80,17 @@ interface MessageListProps {
|
|
|
73
80
|
currentTurnStartedAtMs?: number;
|
|
74
81
|
/** Frozen duration keyed by assistant message id (live freeze + history). */
|
|
75
82
|
durationByMessageId?: Record<string, number>;
|
|
83
|
+
/**
|
|
84
|
+
* 回合产物文件列表,按落库消息 id 键控(live 回合在 message_id 事件时
|
|
85
|
+
* 归档;历史回合从 messageMetadata.turnFiles 水合)。
|
|
86
|
+
*/
|
|
87
|
+
turnFilesByMessageId?: Record<string, TurnFile[]>;
|
|
88
|
+
/**
|
|
89
|
+
* 当轮产物文件:turn_files 事件在流尾声到达,此时尾部助手消息仍挂着
|
|
90
|
+
* 占位 id(框架不会在 message_id 后改写它),归档 map 按键查不到——
|
|
91
|
+
* 与 currentTurnActions 同款尾部回退。
|
|
92
|
+
*/
|
|
93
|
+
currentTurnFiles?: TurnFile[];
|
|
76
94
|
/**
|
|
77
95
|
* Tool calls accumulated for the in-flight assistant message that the
|
|
78
96
|
* backend hasn't assigned a DB id to yet. Rendered under the latest
|
|
@@ -116,6 +134,11 @@ interface MessageListProps {
|
|
|
116
134
|
onDismissFinishedTask?: (taskId: string) => void;
|
|
117
135
|
/** 分享当前对话(截图)。只画在最近一条助手消息的时间戳行上。 */
|
|
118
136
|
onShare?: () => Promise<boolean>;
|
|
137
|
+
/**
|
|
138
|
+
* 最近一条助手回复下的下一轮输入建议(WorkBuddy 式)。只在非流式时渲染。
|
|
139
|
+
*/
|
|
140
|
+
suggestedReplies?: string[];
|
|
141
|
+
onSelectSuggestion?: (text: string) => void;
|
|
119
142
|
}
|
|
120
143
|
|
|
121
144
|
export function MessageList({
|
|
@@ -124,6 +147,7 @@ export function MessageList({
|
|
|
124
147
|
emptyState,
|
|
125
148
|
agents,
|
|
126
149
|
chats = [],
|
|
150
|
+
chatId = null,
|
|
127
151
|
currentAgent,
|
|
128
152
|
executedActionsByMessageId,
|
|
129
153
|
currentTurnActions,
|
|
@@ -131,6 +155,8 @@ export function MessageList({
|
|
|
131
155
|
currentTurnTimeline,
|
|
132
156
|
currentTurnStartedAtMs,
|
|
133
157
|
durationByMessageId,
|
|
158
|
+
turnFilesByMessageId,
|
|
159
|
+
currentTurnFiles,
|
|
134
160
|
currentTurnChildren,
|
|
135
161
|
orchestrationChildrenByMessageId,
|
|
136
162
|
currentRound,
|
|
@@ -143,6 +169,8 @@ export function MessageList({
|
|
|
143
169
|
onInspectTask,
|
|
144
170
|
onDismissFinishedTask,
|
|
145
171
|
onShare,
|
|
172
|
+
suggestedReplies,
|
|
173
|
+
onSelectSuggestion,
|
|
146
174
|
}: MessageListProps) {
|
|
147
175
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
148
176
|
const endRef = useRef<HTMLDivElement>(null);
|
|
@@ -196,10 +224,11 @@ export function MessageList({
|
|
|
196
224
|
const lastTimelineSig = currentTurnTimeline
|
|
197
225
|
?.map((block) => (block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`))
|
|
198
226
|
.join('|') ?? '';
|
|
227
|
+
const suggestedSig = suggestedReplies?.join('\0') ?? '';
|
|
199
228
|
useEffect(() => {
|
|
200
229
|
if (!isAtBottom) return;
|
|
201
230
|
scrollToBottom('smooth');
|
|
202
|
-
}, [lastMessageId, lastContentLen, lastTimelineSig, isAtBottom, scrollToBottom]);
|
|
231
|
+
}, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, isAtBottom, scrollToBottom]);
|
|
203
232
|
|
|
204
233
|
return (
|
|
205
234
|
<div className="relative flex-1 overflow-hidden">
|
|
@@ -253,6 +282,12 @@ export function MessageList({
|
|
|
253
282
|
const turnTimeline = persistedTimeline
|
|
254
283
|
?? (isStreamingTail || isCurrentTimelineTail ? currentTurnTimeline : undefined);
|
|
255
284
|
|
|
285
|
+
const persistedTurnFiles = turnFilesByMessageId?.[message.id];
|
|
286
|
+
const isTurnFilesTail =
|
|
287
|
+
isLast && currentTurnFiles !== undefined && currentTurnFiles.length > 0;
|
|
288
|
+
const turnFiles = persistedTurnFiles
|
|
289
|
+
?? (isTurnFilesTail ? currentTurnFiles : undefined);
|
|
290
|
+
|
|
256
291
|
const metadataJson =
|
|
257
292
|
typeof message.messageMetadata === 'string'
|
|
258
293
|
? message.messageMetadata
|
|
@@ -271,23 +306,36 @@ export function MessageList({
|
|
|
271
306
|
?? inferDurationMs(previousUserCreatedAt, message.createdAt);
|
|
272
307
|
|
|
273
308
|
return (
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
309
|
+
<div key={message.id}>
|
|
310
|
+
<AssistantMessage
|
|
311
|
+
message={message}
|
|
312
|
+
isStreaming={isStreamingTail}
|
|
313
|
+
agents={agents}
|
|
314
|
+
chats={chats}
|
|
315
|
+
chatId={chatId}
|
|
316
|
+
currentAgent={currentAgent}
|
|
317
|
+
executedActions={actions}
|
|
318
|
+
timeline={turnTimeline}
|
|
319
|
+
orchestrationChildren={childList}
|
|
320
|
+
currentRound={isStreamingTail ? currentRound : undefined}
|
|
321
|
+
isPlanMode={isPlanMode}
|
|
322
|
+
onRegenerate={onRegenerate}
|
|
323
|
+
startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
|
|
324
|
+
durationMs={durationMs}
|
|
325
|
+
turnFiles={turnFiles}
|
|
326
|
+
onShare={message.id === lastAssistantId ? onShare : undefined}
|
|
327
|
+
/>
|
|
328
|
+
{!isStreaming &&
|
|
329
|
+
message.id === lastAssistantId &&
|
|
330
|
+
suggestedReplies &&
|
|
331
|
+
suggestedReplies.length > 0 &&
|
|
332
|
+
onSelectSuggestion ? (
|
|
333
|
+
<SuggestedReplies
|
|
334
|
+
suggestions={suggestedReplies}
|
|
335
|
+
onSelect={onSelectSuggestion}
|
|
336
|
+
/>
|
|
337
|
+
) : null}
|
|
338
|
+
</div>
|
|
291
339
|
);
|
|
292
340
|
})}
|
|
293
341
|
{/* W7-1: 中断提示卡在消息列尾部、与最后一条用户消息同列——
|
|
@@ -104,7 +104,9 @@ export function ModelPicker({
|
|
|
104
104
|
|
|
105
105
|
return (
|
|
106
106
|
<div ref={rootRef} className="flex min-w-0 items-center gap-1">
|
|
107
|
-
|
|
107
|
+
{/* flex + min-w-0 截断链:窄容器下模型 id 省略号收缩,而不是把
|
|
108
|
+
旁边的圆形图标按钮压扁。 */}
|
|
109
|
+
<div className="relative flex min-w-0">
|
|
108
110
|
<button
|
|
109
111
|
type="button"
|
|
110
112
|
onClick={() => {
|
|
@@ -115,7 +117,7 @@ export function ModelPicker({
|
|
|
115
117
|
if (next && status !== 'live' && !loading) void refresh();
|
|
116
118
|
}}
|
|
117
119
|
disabled={disabled}
|
|
118
|
-
className="inline-flex h-7 max-w-[160px] items-center gap-1.5 rounded-full border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
|
|
120
|
+
className="inline-flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-full border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
|
|
119
121
|
title={
|
|
120
122
|
status === 'offline'
|
|
121
123
|
? `模型目录不可用${catalog?.error ? `:${catalog.error}` : ''}(仍可手动指定模型)`
|
|
@@ -124,7 +126,7 @@ export function ModelPicker({
|
|
|
124
126
|
aria-haspopup="menu"
|
|
125
127
|
aria-expanded={modelMenuOpen}
|
|
126
128
|
>
|
|
127
|
-
<span className="truncate">{effectiveModel || '选择模型'}</span>
|
|
129
|
+
<span className="min-w-0 truncate">{effectiveModel || '选择模型'}</span>
|
|
128
130
|
{model != null && (
|
|
129
131
|
<span className="inline-flex shrink-0 items-center rounded bg-agent-foreground/10 px-1 py-0.5 text-[9px] font-medium text-agent-foreground">
|
|
130
132
|
本会话
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy-style follow-up chips: three next-turn user inputs rendered
|
|
3
|
+
* under the latest assistant reply. Clicking one sends that text immediately.
|
|
4
|
+
*/
|
|
5
|
+
export function SuggestedReplies({
|
|
6
|
+
suggestions,
|
|
7
|
+
onSelect,
|
|
8
|
+
}: {
|
|
9
|
+
suggestions: string[];
|
|
10
|
+
onSelect: (text: string) => void;
|
|
11
|
+
}) {
|
|
12
|
+
if (suggestions.length === 0) return null;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className="mx-auto flex w-full max-w-[var(--chat-input-box-width)] flex-col items-start gap-1.5 px-1 pt-1"
|
|
17
|
+
data-testid="suggested-replies"
|
|
18
|
+
>
|
|
19
|
+
{suggestions.map((text) => (
|
|
20
|
+
<button
|
|
21
|
+
key={text}
|
|
22
|
+
type="button"
|
|
23
|
+
data-testid="suggested-reply"
|
|
24
|
+
onClick={() => onSelect(text)}
|
|
25
|
+
className="max-w-full rounded-full border border-agent-border bg-agent-muted/40 px-3 py-1.5 text-left text-[13px] leading-snug text-agent-foreground transition-colors hover:bg-agent-foreground/5"
|
|
26
|
+
>
|
|
27
|
+
{text}
|
|
28
|
+
</button>
|
|
29
|
+
))}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default SuggestedReplies;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
LuFilePen,
|
|
4
|
+
LuFilePlus,
|
|
5
|
+
LuFiles,
|
|
6
|
+
LuLoaderCircle,
|
|
7
|
+
} from 'react-icons/lu';
|
|
8
|
+
import { openLocalPath } from '@/lib/local-api';
|
|
9
|
+
import {
|
|
10
|
+
formatFileSize,
|
|
11
|
+
splitTurnFilePath,
|
|
12
|
+
type TurnFile,
|
|
13
|
+
} from './turn-files';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* TurnFilesCard — 回合产物文件列表(Codex 式「本轮写了哪些文件」)。
|
|
17
|
+
*
|
|
18
|
+
* 渲染在助手回合的末尾(回答气泡之下、时间戳行之上),回合内新建/修改的
|
|
19
|
+
* 文件逐行列出;点击行用系统默认应用打开该文件(PPT → PowerPoint/WPS,
|
|
20
|
+
* 报告 → 对应阅读器)。打开失败在行内给出错误提示,不打断会话。
|
|
21
|
+
*
|
|
22
|
+
* 数据来自 local-backend 的 `turn_files` SSE 事件 / messageMetadata 里的
|
|
23
|
+
* `turnFiles`(见 `src/local-backend/turn-files.ts` 的收集逻辑)。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
interface TurnFilesCardProps {
|
|
27
|
+
files: TurnFile[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function TurnFilesCard({ files }: TurnFilesCardProps) {
|
|
31
|
+
const [openingPath, setOpeningPath] = useState<string | null>(null);
|
|
32
|
+
const [openError, setOpenError] = useState<{ path: string; message: string } | null>(null);
|
|
33
|
+
|
|
34
|
+
if (files.length === 0) return null;
|
|
35
|
+
|
|
36
|
+
const handleOpen = async (file: TurnFile) => {
|
|
37
|
+
if (openingPath) return;
|
|
38
|
+
setOpeningPath(file.path);
|
|
39
|
+
setOpenError(null);
|
|
40
|
+
try {
|
|
41
|
+
const result = await openLocalPath(file.path);
|
|
42
|
+
if (!result.success) {
|
|
43
|
+
setOpenError({ path: file.path, message: result.error || '打开失败' });
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
setOpenError({
|
|
47
|
+
path: file.path,
|
|
48
|
+
message: err instanceof Error ? err.message : String(err),
|
|
49
|
+
});
|
|
50
|
+
} finally {
|
|
51
|
+
setOpeningPath(null);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
className="rounded-agent-lg border border-agent-border bg-agent-canvas shadow-sm"
|
|
58
|
+
data-turn-files=""
|
|
59
|
+
>
|
|
60
|
+
<div className="flex items-center gap-1.5 border-b border-agent-border/60 px-3 py-1.5 text-[11px] text-agent-muted-foreground">
|
|
61
|
+
<LuFiles className="h-3.5 w-3.5" />
|
|
62
|
+
<span>本轮产生了 {files.length} 个文件,点击打开</span>
|
|
63
|
+
</div>
|
|
64
|
+
<ul className="max-h-64 overflow-y-auto py-1">
|
|
65
|
+
{files.map((file) => {
|
|
66
|
+
const { dir, name } = splitTurnFilePath(file.path);
|
|
67
|
+
const size = formatFileSize(file.size);
|
|
68
|
+
const busy = openingPath === file.path;
|
|
69
|
+
const error = openError?.path === file.path ? openError.message : null;
|
|
70
|
+
return (
|
|
71
|
+
<li key={file.path}>
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={() => void handleOpen(file)}
|
|
75
|
+
disabled={openingPath !== null}
|
|
76
|
+
title={file.path}
|
|
77
|
+
className="flex w-full items-center gap-2 px-3 py-1 text-left text-xs transition-colors hover:bg-agent-foreground/5 disabled:cursor-wait"
|
|
78
|
+
data-turn-file=""
|
|
79
|
+
data-kind={file.kind}
|
|
80
|
+
>
|
|
81
|
+
{busy ? (
|
|
82
|
+
<LuLoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin text-agent-muted-foreground" />
|
|
83
|
+
) : file.kind === 'created' ? (
|
|
84
|
+
<LuFilePlus className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
|
85
|
+
) : (
|
|
86
|
+
<LuFilePen className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
87
|
+
)}
|
|
88
|
+
<span className="min-w-0 flex-1 truncate">
|
|
89
|
+
<span className="font-medium text-agent-foreground">{name}</span>
|
|
90
|
+
{dir && (
|
|
91
|
+
<span className="ml-1.5 text-agent-muted-foreground">{dir}</span>
|
|
92
|
+
)}
|
|
93
|
+
</span>
|
|
94
|
+
{size && (
|
|
95
|
+
<span className="shrink-0 tabular-nums text-[11px] text-agent-muted-foreground">
|
|
96
|
+
{size}
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
</button>
|
|
100
|
+
{error && (
|
|
101
|
+
<div className="px-3 pb-1 text-[11px] text-agent-destructive" role="status">
|
|
102
|
+
打开失败:{error}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</li>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</ul>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default TurnFilesCard;
|
|
@@ -60,11 +60,13 @@ function ProcessBlocks({
|
|
|
60
60
|
isStreaming,
|
|
61
61
|
agents,
|
|
62
62
|
chats,
|
|
63
|
+
chatId,
|
|
63
64
|
}: {
|
|
64
65
|
blocks: TurnBlock[];
|
|
65
66
|
isStreaming: boolean;
|
|
66
67
|
agents: LocalChatAgent[];
|
|
67
68
|
chats: LocalChat[];
|
|
69
|
+
chatId?: string | null;
|
|
68
70
|
}) {
|
|
69
71
|
const lastIndex = blocks.length - 1;
|
|
70
72
|
return (
|
|
@@ -77,7 +79,7 @@ function ProcessBlocks({
|
|
|
77
79
|
key={`reasoning-${index}`}
|
|
78
80
|
className="text-xs leading-relaxed text-agent-muted-foreground"
|
|
79
81
|
>
|
|
80
|
-
<Markdown agents={agents} chats={chats}>{block.content}</Markdown>
|
|
82
|
+
<Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
|
|
81
83
|
{isStreaming && isLast && (
|
|
82
84
|
<span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
|
|
83
85
|
)}
|
|
@@ -92,7 +94,7 @@ function ProcessBlocks({
|
|
|
92
94
|
key={`text-${index}`}
|
|
93
95
|
className="markdown-content text-xs leading-relaxed text-agent-muted-foreground"
|
|
94
96
|
>
|
|
95
|
-
<Markdown agents={agents} chats={chats}>{block.content}</Markdown>
|
|
97
|
+
<Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
|
|
96
98
|
</div>
|
|
97
99
|
);
|
|
98
100
|
})}
|
|
@@ -105,6 +107,7 @@ export function TurnProcessGroup({
|
|
|
105
107
|
isStreaming,
|
|
106
108
|
agents,
|
|
107
109
|
chats,
|
|
110
|
+
chatId,
|
|
108
111
|
emptyFallback,
|
|
109
112
|
streamingHint,
|
|
110
113
|
startedAtMs,
|
|
@@ -115,6 +118,7 @@ export function TurnProcessGroup({
|
|
|
115
118
|
isStreaming: boolean;
|
|
116
119
|
agents: LocalChatAgent[];
|
|
117
120
|
chats: LocalChat[];
|
|
121
|
+
chatId?: string | null;
|
|
118
122
|
emptyFallback: ReactNode;
|
|
119
123
|
/** Shown while streaming after a tools row and before the summary lands. */
|
|
120
124
|
streamingHint?: ReactNode;
|
|
@@ -180,6 +184,7 @@ export function TurnProcessGroup({
|
|
|
180
184
|
isStreaming={isStreaming && answer.length === 0}
|
|
181
185
|
agents={agents}
|
|
182
186
|
chats={chats}
|
|
187
|
+
chatId={chatId}
|
|
183
188
|
/>
|
|
184
189
|
)}
|
|
185
190
|
{showStreamingHint ? streamingHint : null}
|