@steerable/agent-shell-web 0.6.27 → 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 +16 -3
- 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 +5 -4
- package/src/components/chat/TurnFilesCard.tsx +2 -2
- package/src/components/chat/TurnProcessGroup.tsx +226 -99
- 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 -53
- 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 +3 -3
- 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 +4 -4
- package/src/pages/AgentPage.tsx +56 -6
- package/src/pages/ChatPanelPreviewPage.tsx +26 -4
- package/src/pages/SettingsPage.tsx +39 -39
- package/src/styles/index.css +12 -2
|
@@ -7,23 +7,39 @@ import {
|
|
|
7
7
|
} from '@/lib/show-thinking-content';
|
|
8
8
|
import { Markdown } from './Markdown';
|
|
9
9
|
import { ToolsFlow } from './ExecutedActionsCard';
|
|
10
|
-
import {
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
estimateReasoningDurationMs,
|
|
12
|
+
processStatusLabel,
|
|
13
|
+
thinkingFoldLabel,
|
|
14
|
+
} from './process-status';
|
|
15
|
+
import { splitTurnProcess, type TurnBlock } from './turn-timeline';
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
|
-
* Codex / DeepSeek-style turn process
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
18
25
|
*/
|
|
19
26
|
|
|
20
27
|
export const THINKING_PEEK_LINES = 5;
|
|
28
|
+
/** 思考正文行高(`leading-snug`)。 */
|
|
29
|
+
const THINKING_LINE_HEIGHT = 1.375;
|
|
21
30
|
/**
|
|
22
|
-
* 5
|
|
31
|
+
* 最多 5 行:用 max-height,短思考按内容收,不撑空。
|
|
23
32
|
* 不用 CSS `lh`:Windows Electron 在中文字体尚未就绪时 `lh` 会算成 0,
|
|
24
33
|
* 思考 peek 整块消失,字体加载后又把主列表高度撑跳。
|
|
25
34
|
*/
|
|
26
|
-
export const THINKING_PEEK_HEIGHT = `${THINKING_PEEK_LINES *
|
|
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
|
+
}
|
|
27
43
|
|
|
28
44
|
function ReasoningBody({
|
|
29
45
|
content,
|
|
@@ -32,9 +48,16 @@ function ReasoningBody({
|
|
|
32
48
|
content: string;
|
|
33
49
|
showCursor?: boolean;
|
|
34
50
|
}) {
|
|
51
|
+
const parts = splitThinkingParagraphs(content);
|
|
35
52
|
return (
|
|
36
53
|
<>
|
|
37
|
-
<div className="
|
|
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>
|
|
38
61
|
{showCursor ? (
|
|
39
62
|
<span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
|
|
40
63
|
) : null}
|
|
@@ -64,83 +87,189 @@ function useLiveNow(enabled: boolean): number {
|
|
|
64
87
|
return now;
|
|
65
88
|
}
|
|
66
89
|
|
|
67
|
-
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({
|
|
68
174
|
blocks,
|
|
69
175
|
isStreaming,
|
|
70
176
|
agents,
|
|
71
177
|
chats,
|
|
72
178
|
chatId,
|
|
179
|
+
thinkingDisplay,
|
|
180
|
+
keepFinishedThinkingOpen = false,
|
|
181
|
+
thinkingElapsedByIndex,
|
|
73
182
|
}: {
|
|
74
183
|
blocks: TurnBlock[];
|
|
75
184
|
isStreaming: boolean;
|
|
76
185
|
agents: LocalChatAgent[];
|
|
77
186
|
chats: LocalChat[];
|
|
78
187
|
chatId?: string | null;
|
|
188
|
+
thinkingDisplay: ThinkingDisplayMode;
|
|
189
|
+
keepFinishedThinkingOpen?: boolean;
|
|
190
|
+
thinkingElapsedByIndex?: Array<number | undefined>;
|
|
79
191
|
}) {
|
|
80
192
|
const lastIndex = blocks.length - 1;
|
|
81
193
|
return (
|
|
82
|
-
|
|
194
|
+
<>
|
|
83
195
|
{blocks.map((block, index) => {
|
|
84
196
|
const isLast = index === lastIndex;
|
|
85
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));
|
|
86
203
|
return (
|
|
87
|
-
<
|
|
204
|
+
<ThinkingFold
|
|
88
205
|
key={`reasoning-${index}`}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
/>
|
|
93
216
|
);
|
|
94
217
|
}
|
|
95
218
|
if (block.type === 'tools') {
|
|
96
|
-
return
|
|
219
|
+
return (
|
|
220
|
+
<div key={`tools-${index}`} data-testid="turn-tools">
|
|
221
|
+
<ToolsFlow actions={block.actions} compact />
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
97
224
|
}
|
|
98
225
|
return (
|
|
99
|
-
<div
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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>
|
|
104
235
|
</div>
|
|
105
236
|
);
|
|
106
237
|
})}
|
|
107
|
-
|
|
238
|
+
</>
|
|
108
239
|
);
|
|
109
240
|
}
|
|
110
241
|
|
|
111
|
-
function
|
|
242
|
+
function ProcessBlocks({
|
|
112
243
|
blocks,
|
|
113
244
|
isStreaming,
|
|
245
|
+
agents,
|
|
246
|
+
chats,
|
|
247
|
+
chatId,
|
|
248
|
+
thinkingDisplay,
|
|
249
|
+
keepFinishedThinkingOpen,
|
|
250
|
+
thinkingElapsedByIndex,
|
|
114
251
|
}: {
|
|
115
252
|
blocks: TurnBlock[];
|
|
116
253
|
isStreaming: boolean;
|
|
254
|
+
agents: LocalChatAgent[];
|
|
255
|
+
chats: LocalChat[];
|
|
256
|
+
chatId?: string | null;
|
|
257
|
+
thinkingDisplay: ThinkingDisplayMode;
|
|
258
|
+
keepFinishedThinkingOpen: boolean;
|
|
259
|
+
thinkingElapsedByIndex?: Array<number | undefined>;
|
|
117
260
|
}) {
|
|
118
|
-
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
119
|
-
const reasoning = blocks.filter(
|
|
120
|
-
(block): block is Extract<TurnBlock, { type: 'reasoning' }> =>
|
|
121
|
-
block.type === 'reasoning' && block.content.trim().length > 0,
|
|
122
|
-
);
|
|
123
|
-
const sig = reasoning.map((block) => block.content.length).join(',');
|
|
124
|
-
useLayoutEffect(() => {
|
|
125
|
-
const el = scrollerRef.current;
|
|
126
|
-
if (!el) return;
|
|
127
|
-
el.scrollTop = el.scrollHeight;
|
|
128
|
-
}, [sig]);
|
|
129
|
-
const lastIndex = reasoning.length - 1;
|
|
130
261
|
return (
|
|
131
|
-
<div
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
</div>
|
|
143
|
-
))}
|
|
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
|
+
/>
|
|
144
273
|
</div>
|
|
145
274
|
);
|
|
146
275
|
}
|
|
@@ -157,6 +286,7 @@ export function TurnProcessGroup({
|
|
|
157
286
|
durationMs,
|
|
158
287
|
thinkingDisplay: thinkingDisplayProp,
|
|
159
288
|
showThinkingContent: showThinkingContentProp,
|
|
289
|
+
collapseWhenFinished = true,
|
|
160
290
|
renderAnswer,
|
|
161
291
|
}: {
|
|
162
292
|
blocks: TurnBlock[];
|
|
@@ -178,6 +308,11 @@ export function TurnProcessGroup({
|
|
|
178
308
|
* Task process panel passes true so the inspector stays expanded.
|
|
179
309
|
*/
|
|
180
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;
|
|
181
316
|
renderAnswer: (block: Extract<TurnBlock, { type: 'text' }>, isLast: boolean) => ReactNode;
|
|
182
317
|
}) {
|
|
183
318
|
const preference = useThinkingDisplay();
|
|
@@ -187,15 +322,16 @@ export function TurnProcessGroup({
|
|
|
187
322
|
showThinkingContentProp,
|
|
188
323
|
);
|
|
189
324
|
const showFullThinking = thinkingDisplay === 'full';
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
);
|
|
195
|
-
const
|
|
196
|
-
|
|
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]);
|
|
197
334
|
const answerLenRef = useRef(answer.length);
|
|
198
|
-
isStreamingRef.current = isStreaming;
|
|
199
335
|
answerLenRef.current = answer.length;
|
|
200
336
|
const now = useLiveNow(isStreaming);
|
|
201
337
|
const streamClockRef = useRef<number | undefined>(undefined);
|
|
@@ -216,76 +352,67 @@ export function TurnProcessGroup({
|
|
|
216
352
|
: durationMs ?? lastLiveElapsedRef.current;
|
|
217
353
|
|
|
218
354
|
const lastProcess = process[process.length - 1];
|
|
355
|
+
const lastIsReasoning = lastProcess?.type === 'reasoning';
|
|
356
|
+
const reasoningDurationsRef = useRef<number[]>([]);
|
|
219
357
|
const reasoningClockRef = useRef<{ index: number; startedAt: number } | null>(null);
|
|
220
|
-
if (isStreaming &&
|
|
358
|
+
if (isStreaming && lastIsReasoning) {
|
|
221
359
|
const index = process.length - 1;
|
|
222
360
|
if (reasoningClockRef.current?.index !== index) {
|
|
361
|
+
const prev = reasoningClockRef.current;
|
|
362
|
+
if (prev) {
|
|
363
|
+
reasoningDurationsRef.current[prev.index] = Math.max(0, now - prev.startedAt);
|
|
364
|
+
}
|
|
223
365
|
reasoningClockRef.current = { index, startedAt: now };
|
|
224
366
|
}
|
|
225
|
-
} else if (
|
|
367
|
+
} else if (reasoningClockRef.current) {
|
|
368
|
+
const prev = reasoningClockRef.current;
|
|
369
|
+
reasoningDurationsRef.current[prev.index] = Math.max(0, now - prev.startedAt);
|
|
226
370
|
reasoningClockRef.current = null;
|
|
227
371
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
if (isStreamingRef.current || answerLenRef.current === 0) setOpen(true);
|
|
239
|
-
}, [showFullThinking]);
|
|
240
|
-
|
|
241
|
-
useEffect(() => {
|
|
242
|
-
const wasStreaming = wasStreamingRef.current;
|
|
243
|
-
if (wasStreaming && !isStreaming && answer.length > 0) {
|
|
244
|
-
setOpen(false);
|
|
245
|
-
}
|
|
246
|
-
if (!wasStreaming && isStreaming) {
|
|
247
|
-
setOpen(showFullThinking);
|
|
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);
|
|
248
381
|
}
|
|
249
|
-
|
|
250
|
-
}
|
|
382
|
+
return reasoningDurationsRef.current[index];
|
|
383
|
+
});
|
|
251
384
|
|
|
252
385
|
if (blocks.length === 0) return <>{emptyFallback}</>;
|
|
253
386
|
|
|
254
387
|
const showToggle = process.length > 0;
|
|
255
388
|
const showProcess = !showToggle || open;
|
|
256
|
-
const showThinkingPeek =
|
|
257
|
-
showThinkingPeekAllowed &&
|
|
258
|
-
isStreaming &&
|
|
259
|
-
!open &&
|
|
260
|
-
showToggle &&
|
|
261
|
-
processHasReasoning(process);
|
|
262
389
|
const showStreamingHint =
|
|
263
390
|
Boolean(streamingHint) && isStreaming && answer.length === 0 && !showToggle;
|
|
264
391
|
|
|
265
392
|
return (
|
|
266
|
-
<div className="space-y-1
|
|
393
|
+
<div className="space-y-1" data-turn-timeline>
|
|
267
394
|
{showToggle && (
|
|
268
395
|
<button
|
|
269
396
|
type="button"
|
|
270
|
-
onClick={() =>
|
|
397
|
+
onClick={() => setUserOpen(!open)}
|
|
271
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"
|
|
272
399
|
aria-expanded={open}
|
|
273
400
|
data-turn-process=""
|
|
401
|
+
data-testid="turn-process-toggle"
|
|
274
402
|
data-open={open || undefined}
|
|
275
403
|
>
|
|
276
|
-
|
|
277
|
-
<LuChevronDown className="h-3.5 w-3.5 shrink-0" />
|
|
278
|
-
) : (
|
|
279
|
-
<LuChevronRight className="h-3.5 w-3.5 shrink-0" />
|
|
280
|
-
)}
|
|
281
|
-
<span className="truncate">
|
|
404
|
+
<span className="min-w-0 truncate">
|
|
282
405
|
{processStatusLabel({
|
|
283
406
|
process,
|
|
284
407
|
isStreaming,
|
|
285
408
|
elapsedMs,
|
|
286
|
-
reasoningElapsedMs,
|
|
287
409
|
})}
|
|
288
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
|
+
)}
|
|
289
416
|
</button>
|
|
290
417
|
)}
|
|
291
418
|
{showProcess && (
|
|
@@ -295,11 +422,11 @@ export function TurnProcessGroup({
|
|
|
295
422
|
agents={agents}
|
|
296
423
|
chats={chats}
|
|
297
424
|
chatId={chatId}
|
|
425
|
+
thinkingDisplay={thinkingDisplay}
|
|
426
|
+
keepFinishedThinkingOpen={!collapseWhenFinished}
|
|
427
|
+
thinkingElapsedByIndex={thinkingElapsedByIndex}
|
|
298
428
|
/>
|
|
299
429
|
)}
|
|
300
|
-
{showThinkingPeek && (
|
|
301
|
-
<ThinkingPeek blocks={process} isStreaming={answer.length === 0} />
|
|
302
|
-
)}
|
|
303
430
|
{showStreamingHint ? streamingHint : null}
|
|
304
431
|
{answer.map((block, index) => (
|
|
305
432
|
<Fragment key={`answer-${index}`}>
|
|
@@ -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
|
+
}
|