@steerable/agent-shell-web 0.6.26 → 0.6.28
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/AgentSidebar.tsx +24 -24
- package/src/components/BrandLockup.tsx +1 -1
- package/src/components/ChatHeader.tsx +5 -15
- package/src/components/LocalLlmSettingsModal.tsx +5 -5
- package/src/components/TerminalPanel.tsx +1 -1
- package/src/components/chat/ApprovalModal.tsx +5 -5
- package/src/components/chat/AskUserQuestionMenu.tsx +13 -13
- package/src/components/chat/AssistantMessage.tsx +41 -13
- package/src/components/chat/ChatInput.tsx +24 -24
- package/src/components/chat/ExecPolicyPicker.tsx +4 -4
- package/src/components/chat/ExecutedActionsCard.tsx +10 -3
- package/src/components/chat/FilePathCode.tsx +2 -1
- package/src/components/chat/LocalChatPanel.tsx +39 -4
- package/src/components/chat/Markdown.tsx +15 -14
- package/src/components/chat/MessageList.tsx +39 -16
- package/src/components/chat/ModelPicker.tsx +6 -6
- package/src/components/chat/SessionTodoList.tsx +137 -0
- package/src/components/chat/SessionTreeModal.tsx +3 -3
- package/src/components/chat/SuggestedReplies.tsx +5 -4
- package/src/components/chat/TaskPanelModal.tsx +3 -3
- package/src/components/chat/TaskProcessPanel.tsx +17 -11
- package/src/components/chat/TurnFilesCard.tsx +2 -2
- package/src/components/chat/TurnProcessGroup.tsx +281 -117
- package/src/components/chat/UserMessage.tsx +4 -4
- package/src/components/chat/next-steps-tags.ts +28 -0
- package/src/components/chat/process-status.ts +125 -32
- package/src/components/chat/todo-list-model.ts +167 -0
- package/src/components/chat/turn-timeline.ts +100 -11
- package/src/components/settings/AgentsSettingsPanel.tsx +2 -2
- package/src/components/settings/AppearanceSettingsPanel.tsx +43 -30
- package/src/components/settings/DiagnoseSettingsPanel.tsx +2 -2
- package/src/components/settings/InsightsSettingsPanel.tsx +4 -4
- package/src/components/settings/LlmSettingsPanel.tsx +10 -10
- package/src/components/settings/McpSettingsPanel.tsx +3 -3
- package/src/components/settings/ModelIdCombobox.tsx +2 -2
- package/src/components/settings/SecuritySettingsPanel.tsx +1 -1
- package/src/components/settings/SettingsSaveButton.tsx +1 -1
- package/src/components/settings/SkillsSettingsPanel.tsx +2 -2
- package/src/components/settings/TelemetrySettingsPanel.tsx +2 -2
- package/src/components/settings/UsagePanel.tsx +14 -10
- package/src/components/settings/WebSearchSettingsPanel.tsx +2 -2
- package/src/layouts/AgentLayout.tsx +3 -3
- package/src/lib/chat-transport.ts +68 -12
- package/src/lib/show-thinking-content.ts +40 -15
- package/src/pages/AgentPage.tsx +56 -6
- package/src/pages/ChatPanelPreviewPage.tsx +88 -8
- package/src/pages/SettingsPage.tsx +39 -39
- package/src/styles/index.css +12 -2
|
@@ -1,125 +1,275 @@
|
|
|
1
1
|
import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
2
|
import { LuChevronDown, LuChevronRight } from 'react-icons/lu';
|
|
3
3
|
import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
useThinkingDisplay,
|
|
6
|
+
type ThinkingDisplayMode,
|
|
7
|
+
} from '@/lib/show-thinking-content';
|
|
5
8
|
import { Markdown } from './Markdown';
|
|
6
9
|
import { ToolsFlow } from './ExecutedActionsCard';
|
|
7
|
-
import {
|
|
8
|
-
|
|
10
|
+
import {
|
|
11
|
+
estimateReasoningDurationMs,
|
|
12
|
+
processStatusLabel,
|
|
13
|
+
thinkingFoldLabel,
|
|
14
|
+
} from './process-status';
|
|
15
|
+
import { splitTurnProcess, type TurnBlock } from './turn-timeline';
|
|
9
16
|
|
|
10
17
|
/**
|
|
11
|
-
* Codex / DeepSeek-style turn process
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* Codex / DeepSeek-style turn process. Each LLM round keeps three surfaces
|
|
19
|
+
* distinct: 思考 (muted CoT), 思考后的 response (reply bubble), 工具
|
|
20
|
+
* (cards). Trailing text becomes 最后一次结论 only after the stream ends —
|
|
21
|
+
* promoting it earlier would restyle it as thinking when the next round
|
|
22
|
+
* starts. Settings 「显示思考内容」 only clip the 思考正文: hidden /
|
|
23
|
+
* 5-line peek / full. A finished 思考 fold auto-collapses in chat;
|
|
24
|
+
* only the live round stays open. Tools and the work row stay independent.
|
|
16
25
|
*/
|
|
17
26
|
|
|
18
|
-
export const THINKING_PEEK_LINES =
|
|
27
|
+
export const THINKING_PEEK_LINES = 5;
|
|
28
|
+
/** 思考正文行高(`leading-snug`)。 */
|
|
29
|
+
const THINKING_LINE_HEIGHT = 1.375;
|
|
30
|
+
/**
|
|
31
|
+
* 最多 5 行:用 max-height,短思考按内容收,不撑空。
|
|
32
|
+
* 不用 CSS `lh`:Windows Electron 在中文字体尚未就绪时 `lh` 会算成 0,
|
|
33
|
+
* 思考 peek 整块消失,字体加载后又把主列表高度撑跳。
|
|
34
|
+
*/
|
|
35
|
+
export const THINKING_PEEK_HEIGHT = `${THINKING_PEEK_LINES * THINKING_LINE_HEIGHT}em`;
|
|
36
|
+
|
|
37
|
+
const THINKING_TEXT =
|
|
38
|
+
'border-l border-agent-border/70 pl-2 text-xs leading-snug text-agent-muted-foreground';
|
|
39
|
+
|
|
40
|
+
function splitThinkingParagraphs(content: string): string[] {
|
|
41
|
+
return content.split(/\n{2,}/).filter((part) => part.length > 0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ReasoningBody({
|
|
45
|
+
content,
|
|
46
|
+
showCursor,
|
|
47
|
+
}: {
|
|
48
|
+
content: string;
|
|
49
|
+
showCursor?: boolean;
|
|
50
|
+
}) {
|
|
51
|
+
const parts = splitThinkingParagraphs(content);
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<div className="space-y-1">
|
|
55
|
+
{parts.map((part, index) => (
|
|
56
|
+
<div key={index} className="whitespace-pre-wrap break-words">
|
|
57
|
+
{part}
|
|
58
|
+
</div>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
{showCursor ? (
|
|
62
|
+
<span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
|
|
63
|
+
) : null}
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
19
67
|
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
68
|
+
function resolveThinkingDisplay(
|
|
69
|
+
preference: ThinkingDisplayMode,
|
|
70
|
+
thinkingDisplay?: ThinkingDisplayMode,
|
|
71
|
+
showThinkingContent?: boolean,
|
|
72
|
+
): ThinkingDisplayMode {
|
|
73
|
+
if (thinkingDisplay) return thinkingDisplay;
|
|
74
|
+
if (showThinkingContent === true) return 'full';
|
|
75
|
+
if (showThinkingContent === false) return 'peek';
|
|
76
|
+
return preference;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function useLiveNow(enabled: boolean): number {
|
|
24
80
|
const [now, setNow] = useState(() => Date.now());
|
|
25
81
|
useEffect(() => {
|
|
26
|
-
if (!enabled
|
|
82
|
+
if (!enabled) return;
|
|
27
83
|
setNow(Date.now());
|
|
28
84
|
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
|
29
85
|
return () => window.clearInterval(id);
|
|
30
|
-
}, [enabled
|
|
31
|
-
|
|
32
|
-
return Math.max(0, now - startedAtMs);
|
|
86
|
+
}, [enabled]);
|
|
87
|
+
return now;
|
|
33
88
|
}
|
|
34
89
|
|
|
35
|
-
function
|
|
90
|
+
function ReasoningPane({
|
|
91
|
+
content,
|
|
92
|
+
showCursor,
|
|
93
|
+
clipped,
|
|
94
|
+
}: {
|
|
95
|
+
content: string;
|
|
96
|
+
showCursor?: boolean;
|
|
97
|
+
clipped?: boolean;
|
|
98
|
+
}) {
|
|
99
|
+
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
100
|
+
useLayoutEffect(() => {
|
|
101
|
+
if (!clipped) return;
|
|
102
|
+
const el = scrollerRef.current;
|
|
103
|
+
if (!el) return;
|
|
104
|
+
el.scrollTop = el.scrollHeight;
|
|
105
|
+
}, [clipped, content]);
|
|
106
|
+
const body = (
|
|
107
|
+
<div className={THINKING_TEXT}>
|
|
108
|
+
<ReasoningBody content={content} showCursor={showCursor} />
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
if (!clipped) return body;
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
ref={scrollerRef}
|
|
115
|
+
className="overflow-y-auto overflow-anchor-none [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
|
116
|
+
style={{ maxHeight: THINKING_PEEK_HEIGHT }}
|
|
117
|
+
data-thinking-peek=""
|
|
118
|
+
data-testid="thinking-peek"
|
|
119
|
+
data-peek-lines={THINKING_PEEK_LINES}
|
|
120
|
+
>
|
|
121
|
+
{body}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ThinkingFold({
|
|
127
|
+
content,
|
|
128
|
+
label,
|
|
129
|
+
showCursor,
|
|
130
|
+
mode,
|
|
131
|
+
defaultOpen,
|
|
132
|
+
}: {
|
|
133
|
+
content: string;
|
|
134
|
+
label: string;
|
|
135
|
+
showCursor?: boolean;
|
|
136
|
+
mode: ThinkingDisplayMode;
|
|
137
|
+
defaultOpen: boolean;
|
|
138
|
+
}) {
|
|
139
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
setOpen(defaultOpen);
|
|
142
|
+
}, [defaultOpen]);
|
|
143
|
+
|
|
144
|
+
if (mode === 'hidden') return null;
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className="space-y-1" data-testid="turn-thinking">
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={() => setOpen((value) => !value)}
|
|
151
|
+
className="flex min-w-0 items-center gap-1 py-0.5 text-left text-[11px] text-agent-muted-foreground/80 transition-colors hover:text-agent-foreground"
|
|
152
|
+
aria-expanded={open}
|
|
153
|
+
data-thinking-fold=""
|
|
154
|
+
>
|
|
155
|
+
<span className="truncate">{label}</span>
|
|
156
|
+
{open ? (
|
|
157
|
+
<LuChevronDown className="h-3 w-3 shrink-0" />
|
|
158
|
+
) : (
|
|
159
|
+
<LuChevronRight className="h-3 w-3 shrink-0" />
|
|
160
|
+
)}
|
|
161
|
+
</button>
|
|
162
|
+
{open ? (
|
|
163
|
+
<ReasoningPane
|
|
164
|
+
content={content}
|
|
165
|
+
showCursor={showCursor}
|
|
166
|
+
clipped={mode === 'peek'}
|
|
167
|
+
/>
|
|
168
|
+
) : null}
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function ProcessBlockItems({
|
|
36
174
|
blocks,
|
|
37
175
|
isStreaming,
|
|
38
176
|
agents,
|
|
39
177
|
chats,
|
|
40
178
|
chatId,
|
|
179
|
+
thinkingDisplay,
|
|
180
|
+
keepFinishedThinkingOpen = false,
|
|
181
|
+
thinkingElapsedByIndex,
|
|
41
182
|
}: {
|
|
42
183
|
blocks: TurnBlock[];
|
|
43
184
|
isStreaming: boolean;
|
|
44
185
|
agents: LocalChatAgent[];
|
|
45
186
|
chats: LocalChat[];
|
|
46
187
|
chatId?: string | null;
|
|
188
|
+
thinkingDisplay: ThinkingDisplayMode;
|
|
189
|
+
keepFinishedThinkingOpen?: boolean;
|
|
190
|
+
thinkingElapsedByIndex?: Array<number | undefined>;
|
|
47
191
|
}) {
|
|
48
192
|
const lastIndex = blocks.length - 1;
|
|
49
193
|
return (
|
|
50
|
-
|
|
194
|
+
<>
|
|
51
195
|
{blocks.map((block, index) => {
|
|
52
196
|
const isLast = index === lastIndex;
|
|
53
197
|
if (block.type === 'reasoning') {
|
|
198
|
+
const isLive = isStreaming && isLast;
|
|
199
|
+
const elapsedMs =
|
|
200
|
+
thinkingElapsedByIndex?.[index]
|
|
201
|
+
?? block.durationMs
|
|
202
|
+
?? (isLive ? undefined : estimateReasoningDurationMs(block.content));
|
|
54
203
|
return (
|
|
55
|
-
<
|
|
204
|
+
<ThinkingFold
|
|
56
205
|
key={`reasoning-${index}`}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)}
|
|
63
|
-
|
|
206
|
+
content={block.content}
|
|
207
|
+
label={thinkingFoldLabel({
|
|
208
|
+
content: block.content,
|
|
209
|
+
isLive,
|
|
210
|
+
elapsedMs,
|
|
211
|
+
})}
|
|
212
|
+
showCursor={isLive}
|
|
213
|
+
mode={thinkingDisplay}
|
|
214
|
+
defaultOpen={isLive || keepFinishedThinkingOpen}
|
|
215
|
+
/>
|
|
64
216
|
);
|
|
65
217
|
}
|
|
66
218
|
if (block.type === 'tools') {
|
|
67
|
-
return
|
|
219
|
+
return (
|
|
220
|
+
<div key={`tools-${index}`} data-testid="turn-tools">
|
|
221
|
+
<ToolsFlow actions={block.actions} compact />
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
68
224
|
}
|
|
69
225
|
return (
|
|
70
|
-
<div
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
226
|
+
<div key={`text-${index}`} data-testid="turn-response">
|
|
227
|
+
<div>
|
|
228
|
+
<div className="markdown-content text-xs leading-relaxed text-agent-foreground">
|
|
229
|
+
<Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
|
|
230
|
+
</div>
|
|
231
|
+
{isStreaming && isLast ? (
|
|
232
|
+
<span className="ml-0.5 inline-block h-3.5 w-[3px] animate-agent-cursor-blink bg-agent-foreground/60 align-text-bottom" />
|
|
233
|
+
) : null}
|
|
234
|
+
</div>
|
|
75
235
|
</div>
|
|
76
236
|
);
|
|
77
237
|
})}
|
|
78
|
-
|
|
238
|
+
</>
|
|
79
239
|
);
|
|
80
240
|
}
|
|
81
241
|
|
|
82
|
-
function
|
|
242
|
+
function ProcessBlocks({
|
|
83
243
|
blocks,
|
|
84
244
|
isStreaming,
|
|
85
245
|
agents,
|
|
86
246
|
chats,
|
|
87
247
|
chatId,
|
|
248
|
+
thinkingDisplay,
|
|
249
|
+
keepFinishedThinkingOpen,
|
|
250
|
+
thinkingElapsedByIndex,
|
|
88
251
|
}: {
|
|
89
252
|
blocks: TurnBlock[];
|
|
90
253
|
isStreaming: boolean;
|
|
91
254
|
agents: LocalChatAgent[];
|
|
92
255
|
chats: LocalChat[];
|
|
93
256
|
chatId?: string | null;
|
|
257
|
+
thinkingDisplay: ThinkingDisplayMode;
|
|
258
|
+
keepFinishedThinkingOpen: boolean;
|
|
259
|
+
thinkingElapsedByIndex?: Array<number | undefined>;
|
|
94
260
|
}) {
|
|
95
|
-
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
96
|
-
const reasoning = blocks.filter(
|
|
97
|
-
(block): block is Extract<TurnBlock, { type: 'reasoning' }> =>
|
|
98
|
-
block.type === 'reasoning' && block.content.trim().length > 0,
|
|
99
|
-
);
|
|
100
|
-
const sig = reasoning.map((block) => block.content.length).join(',');
|
|
101
|
-
useLayoutEffect(() => {
|
|
102
|
-
const el = scrollerRef.current;
|
|
103
|
-
if (!el) return;
|
|
104
|
-
el.scrollTop = el.scrollHeight;
|
|
105
|
-
}, [sig]);
|
|
106
|
-
const lastIndex = reasoning.length - 1;
|
|
107
261
|
return (
|
|
108
|
-
<div
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
<span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
|
|
120
|
-
)}
|
|
121
|
-
</div>
|
|
122
|
-
))}
|
|
262
|
+
<div className="space-y-1.5">
|
|
263
|
+
<ProcessBlockItems
|
|
264
|
+
blocks={blocks}
|
|
265
|
+
isStreaming={isStreaming}
|
|
266
|
+
agents={agents}
|
|
267
|
+
chats={chats}
|
|
268
|
+
chatId={chatId}
|
|
269
|
+
thinkingDisplay={thinkingDisplay}
|
|
270
|
+
keepFinishedThinkingOpen={keepFinishedThinkingOpen}
|
|
271
|
+
thinkingElapsedByIndex={thinkingElapsedByIndex}
|
|
272
|
+
/>
|
|
123
273
|
</div>
|
|
124
274
|
);
|
|
125
275
|
}
|
|
@@ -134,7 +284,9 @@ export function TurnProcessGroup({
|
|
|
134
284
|
streamingHint,
|
|
135
285
|
startedAtMs,
|
|
136
286
|
durationMs,
|
|
287
|
+
thinkingDisplay: thinkingDisplayProp,
|
|
137
288
|
showThinkingContent: showThinkingContentProp,
|
|
289
|
+
collapseWhenFinished = true,
|
|
138
290
|
renderAnswer,
|
|
139
291
|
}: {
|
|
140
292
|
blocks: TurnBlock[];
|
|
@@ -149,25 +301,48 @@ export function TurnProcessGroup({
|
|
|
149
301
|
startedAtMs?: number;
|
|
150
302
|
/** Frozen wall-clock of a finished turn (metadata or just-ended stream). */
|
|
151
303
|
durationMs?: number;
|
|
304
|
+
/** Override the settings preference (hidden / peek / full). */
|
|
305
|
+
thinkingDisplay?: ThinkingDisplayMode;
|
|
152
306
|
/**
|
|
153
|
-
*
|
|
307
|
+
* Legacy override. `true` = full, `false` = peek.
|
|
154
308
|
* Task process panel passes true so the inspector stays expanded.
|
|
155
309
|
*/
|
|
156
310
|
showThinkingContent?: boolean;
|
|
311
|
+
/**
|
|
312
|
+
* After the stream ends, fold the work row to the summary line.
|
|
313
|
+
* Task inspector sets false so the replay stays open.
|
|
314
|
+
*/
|
|
315
|
+
collapseWhenFinished?: boolean;
|
|
157
316
|
renderAnswer: (block: Extract<TurnBlock, { type: 'text' }>, isLast: boolean) => ReactNode;
|
|
158
317
|
}) {
|
|
159
|
-
const preference =
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
318
|
+
const preference = useThinkingDisplay();
|
|
319
|
+
const thinkingDisplay = resolveThinkingDisplay(
|
|
320
|
+
preference,
|
|
321
|
+
thinkingDisplayProp,
|
|
322
|
+
showThinkingContentProp,
|
|
164
323
|
);
|
|
165
|
-
const
|
|
166
|
-
const
|
|
324
|
+
const showFullThinking = thinkingDisplay === 'full';
|
|
325
|
+
const { process, answer } = splitTurnProcess(blocks, { finalize: !isStreaming });
|
|
326
|
+
const defaultOpen = isStreaming
|
|
327
|
+
? thinkingDisplay !== 'hidden'
|
|
328
|
+
: showFullThinking && !collapseWhenFinished;
|
|
329
|
+
const [userOpen, setUserOpen] = useState<boolean | null>(null);
|
|
330
|
+
const open = userOpen ?? defaultOpen;
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
setUserOpen(null);
|
|
333
|
+
}, [isStreaming]);
|
|
167
334
|
const answerLenRef = useRef(answer.length);
|
|
168
|
-
isStreamingRef.current = isStreaming;
|
|
169
335
|
answerLenRef.current = answer.length;
|
|
170
|
-
const
|
|
336
|
+
const now = useLiveNow(isStreaming);
|
|
337
|
+
const streamClockRef = useRef<number | undefined>(undefined);
|
|
338
|
+
if (isStreaming) {
|
|
339
|
+
streamClockRef.current ??= startedAtMs ?? now;
|
|
340
|
+
} else {
|
|
341
|
+
streamClockRef.current = undefined;
|
|
342
|
+
}
|
|
343
|
+
const liveElapsedMs = isStreaming
|
|
344
|
+
? Math.max(0, now - (startedAtMs ?? streamClockRef.current ?? now))
|
|
345
|
+
: undefined;
|
|
171
346
|
const lastLiveElapsedRef = useRef<number | undefined>(undefined);
|
|
172
347
|
if (isStreaming && liveElapsedMs != null) {
|
|
173
348
|
lastLiveElapsedRef.current = liveElapsedMs;
|
|
@@ -177,72 +352,67 @@ export function TurnProcessGroup({
|
|
|
177
352
|
: durationMs ?? lastLiveElapsedRef.current;
|
|
178
353
|
|
|
179
354
|
const lastProcess = process[process.length - 1];
|
|
355
|
+
const lastIsReasoning = lastProcess?.type === 'reasoning';
|
|
356
|
+
const reasoningDurationsRef = useRef<number[]>([]);
|
|
180
357
|
const reasoningClockRef = useRef<{ index: number; startedAt: number } | null>(null);
|
|
181
|
-
if (isStreaming &&
|
|
358
|
+
if (isStreaming && lastIsReasoning) {
|
|
182
359
|
const index = process.length - 1;
|
|
183
360
|
if (reasoningClockRef.current?.index !== index) {
|
|
184
|
-
|
|
361
|
+
const prev = reasoningClockRef.current;
|
|
362
|
+
if (prev) {
|
|
363
|
+
reasoningDurationsRef.current[prev.index] = Math.max(0, now - prev.startedAt);
|
|
364
|
+
}
|
|
365
|
+
reasoningClockRef.current = { index, startedAt: now };
|
|
185
366
|
}
|
|
186
|
-
} else if (
|
|
367
|
+
} else if (reasoningClockRef.current) {
|
|
368
|
+
const prev = reasoningClockRef.current;
|
|
369
|
+
reasoningDurationsRef.current[prev.index] = Math.max(0, now - prev.startedAt);
|
|
187
370
|
reasoningClockRef.current = null;
|
|
188
371
|
}
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
if (isStreamingRef.current || answerLenRef.current === 0) setOpen(true);
|
|
200
|
-
}, [showThinkingContent]);
|
|
201
|
-
|
|
202
|
-
useEffect(() => {
|
|
203
|
-
const wasStreaming = wasStreamingRef.current;
|
|
204
|
-
if (wasStreaming && !isStreaming && answer.length > 0) {
|
|
205
|
-
setOpen(false);
|
|
372
|
+
const thinkingElapsedByIndex = process.map((block, index) => {
|
|
373
|
+
if (block.type !== 'reasoning') return undefined;
|
|
374
|
+
if (
|
|
375
|
+
isStreaming &&
|
|
376
|
+
lastIsReasoning &&
|
|
377
|
+
index === process.length - 1 &&
|
|
378
|
+
reasoningClockRef.current
|
|
379
|
+
) {
|
|
380
|
+
return Math.max(0, now - reasoningClockRef.current.startedAt);
|
|
206
381
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
wasStreamingRef.current = isStreaming;
|
|
211
|
-
}, [isStreaming, answer.length, showThinkingContent]);
|
|
382
|
+
return reasoningDurationsRef.current[index];
|
|
383
|
+
});
|
|
212
384
|
|
|
213
385
|
if (blocks.length === 0) return <>{emptyFallback}</>;
|
|
214
386
|
|
|
215
387
|
const showToggle = process.length > 0;
|
|
216
388
|
const showProcess = !showToggle || open;
|
|
217
|
-
const showThinkingPeek =
|
|
218
|
-
isStreaming && !open && showToggle && processHasReasoning(process);
|
|
219
389
|
const showStreamingHint =
|
|
220
390
|
Boolean(streamingHint) && isStreaming && answer.length === 0 && !showToggle;
|
|
221
391
|
|
|
222
392
|
return (
|
|
223
|
-
<div className="space-y-1
|
|
393
|
+
<div className="space-y-1" data-turn-timeline>
|
|
224
394
|
{showToggle && (
|
|
225
395
|
<button
|
|
226
396
|
type="button"
|
|
227
|
-
onClick={() =>
|
|
397
|
+
onClick={() => setUserOpen(!open)}
|
|
228
398
|
className="flex w-full items-center gap-1 py-0.5 text-left text-xs text-agent-muted-foreground transition-colors hover:text-agent-foreground"
|
|
229
399
|
aria-expanded={open}
|
|
230
400
|
data-turn-process=""
|
|
401
|
+
data-testid="turn-process-toggle"
|
|
231
402
|
data-open={open || undefined}
|
|
232
403
|
>
|
|
233
|
-
|
|
234
|
-
<LuChevronDown className="h-3.5 w-3.5 shrink-0" />
|
|
235
|
-
) : (
|
|
236
|
-
<LuChevronRight className="h-3.5 w-3.5 shrink-0" />
|
|
237
|
-
)}
|
|
238
|
-
<span className="truncate">
|
|
404
|
+
<span className="min-w-0 truncate">
|
|
239
405
|
{processStatusLabel({
|
|
240
406
|
process,
|
|
241
407
|
isStreaming,
|
|
242
408
|
elapsedMs,
|
|
243
|
-
reasoningElapsedMs,
|
|
244
409
|
})}
|
|
245
410
|
</span>
|
|
411
|
+
{open ? (
|
|
412
|
+
<LuChevronDown className="h-3.5 w-3.5 shrink-0" />
|
|
413
|
+
) : (
|
|
414
|
+
<LuChevronRight className="h-3.5 w-3.5 shrink-0" />
|
|
415
|
+
)}
|
|
246
416
|
</button>
|
|
247
417
|
)}
|
|
248
418
|
{showProcess && (
|
|
@@ -252,15 +422,9 @@ export function TurnProcessGroup({
|
|
|
252
422
|
agents={agents}
|
|
253
423
|
chats={chats}
|
|
254
424
|
chatId={chatId}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
<ThinkingPeek
|
|
259
|
-
blocks={process}
|
|
260
|
-
isStreaming={answer.length === 0}
|
|
261
|
-
agents={agents}
|
|
262
|
-
chats={chats}
|
|
263
|
-
chatId={chatId}
|
|
425
|
+
thinkingDisplay={thinkingDisplay}
|
|
426
|
+
keepFinishedThinkingOpen={!collapseWhenFinished}
|
|
427
|
+
thinkingElapsedByIndex={thinkingElapsedByIndex}
|
|
264
428
|
/>
|
|
265
429
|
)}
|
|
266
430
|
{showStreamingHint ? streamingHint : null}
|
|
@@ -62,7 +62,7 @@ export function UserMessage({ message, agents = [], chats = [] }: UserMessagePro
|
|
|
62
62
|
|
|
63
63
|
return (
|
|
64
64
|
<motion.div
|
|
65
|
-
className="group/message mb-
|
|
65
|
+
className="group/message mb-1"
|
|
66
66
|
data-message-role="user"
|
|
67
67
|
data-message-id={message.id}
|
|
68
68
|
initial={{ opacity: 0, y: 20 }}
|
|
@@ -70,7 +70,7 @@ export function UserMessage({ message, agents = [], chats = [] }: UserMessagePro
|
|
|
70
70
|
transition={{ duration: 0.3 }}
|
|
71
71
|
>
|
|
72
72
|
<div className="flex justify-start">
|
|
73
|
-
<div className="mx-auto w-full max-w-[var(--chat-input-box-width)] rounded-lg border border-agent-border bg-agent-muted
|
|
73
|
+
<div className="mx-auto w-full max-w-[var(--chat-input-box-width)] rounded-lg border border-agent-border bg-agent-muted px-2.5 py-2 text-xs text-agent-foreground">
|
|
74
74
|
<div className="relative">
|
|
75
75
|
<div
|
|
76
76
|
ref={contentRef}
|
|
@@ -121,7 +121,7 @@ export function UserMessage({ message, agents = [], chats = [] }: UserMessagePro
|
|
|
121
121
|
</button>
|
|
122
122
|
)}
|
|
123
123
|
|
|
124
|
-
<div className="mt-
|
|
124
|
+
<div className="mt-1 flex items-center justify-between">
|
|
125
125
|
<button
|
|
126
126
|
type="button"
|
|
127
127
|
onClick={() => void copy()}
|
|
@@ -140,7 +140,7 @@ export function UserMessage({ message, agents = [], chats = [] }: UserMessagePro
|
|
|
140
140
|
<LuCopy className="h-3 w-3" />
|
|
141
141
|
)}
|
|
142
142
|
</button>
|
|
143
|
-
<div className="text-[
|
|
143
|
+
<div className="text-[11px] text-agent-muted-foreground">
|
|
144
144
|
{message.createdAt
|
|
145
145
|
? getFriendlyDate(new Date(message.createdAt))
|
|
146
146
|
: getFriendlyDate(new Date())}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 助手回复里的自定义 `[next_steps]...[/next_steps]` 标签:
|
|
3
|
+
* 给追问建议当判断来源,气泡里只显示标签内的正文,不显示标签本身。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const COMPLETE_BLOCK_RE = /\[next_steps\]([\s\S]*?)\[\/next_steps\]/gi;
|
|
7
|
+
const OPEN_TAG = 'next_steps]';
|
|
8
|
+
const CLOSE_TAG = '/next_steps]';
|
|
9
|
+
|
|
10
|
+
function isIncompleteTagSuffix(suffix: string): boolean {
|
|
11
|
+
const body = suffix.slice(1).toLowerCase();
|
|
12
|
+
if (!body) return false;
|
|
13
|
+
return OPEN_TAG.startsWith(body) || CLOSE_TAG.startsWith(body);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stripIncompleteTrailingTag(text: string): string {
|
|
17
|
+
const idx = text.lastIndexOf('[');
|
|
18
|
+
if (idx < 0) return text;
|
|
19
|
+
const suffix = text.slice(idx);
|
|
20
|
+
return isIncompleteTagSuffix(suffix) ? text.slice(0, idx) : text;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 去掉自定义标签,保留里面的建议正文。 */
|
|
24
|
+
export function stripNextStepsTags(text: string): string {
|
|
25
|
+
let out = (text ?? '').replace(COMPLETE_BLOCK_RE, '$1');
|
|
26
|
+
out = out.replace(/\[\/?next_steps\]/gi, '');
|
|
27
|
+
return stripIncompleteTrailingTag(out);
|
|
28
|
+
}
|