@steerable/agent-shell-web 0.6.25 → 0.6.27
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/MessageList.tsx +24 -14
- package/src/components/chat/SuggestedReplies.tsx +3 -2
- package/src/components/chat/TaskProcessPanel.tsx +13 -8
- package/src/components/chat/TurnProcessGroup.tsx +84 -47
- package/src/components/chat/process-status.ts +28 -7
- package/src/components/chat/suggested-replies-model.ts +1 -1
- package/src/components/settings/AppearanceSettingsPanel.tsx +42 -29
- package/src/lib/show-thinking-content.ts +40 -15
- package/src/pages/ChatPanelPreviewPage.tsx +62 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.27",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"react-router-dom": "^6.30.0",
|
|
47
47
|
"rehype-raw": "^7.0.0",
|
|
48
48
|
"remark-gfm": "^4.0.1",
|
|
49
|
-
"@steerable/agent-
|
|
50
|
-
"@steerable/agent-
|
|
49
|
+
"@steerable/agent-protocol": "0.6.27",
|
|
50
|
+
"@steerable/agent-ui": "0.6.27"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@tailwindcss/vite": "^4.3.0",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"typescript": "^5.8.3",
|
|
61
61
|
"vite": "^5.4.21",
|
|
62
62
|
"vitest": "^3.2.4",
|
|
63
|
-
"@steerable/pack-sdk": "0.6.
|
|
63
|
+
"@steerable/pack-sdk": "0.6.27"
|
|
64
64
|
},
|
|
65
65
|
"scripts": {
|
|
66
66
|
"gen": "echo agent-shell-web",
|
|
@@ -173,8 +173,8 @@ export function MessageList({
|
|
|
173
173
|
onSelectSuggestion,
|
|
174
174
|
}: MessageListProps) {
|
|
175
175
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
176
|
-
const endRef = useRef<HTMLDivElement>(null);
|
|
177
176
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
177
|
+
const isAtBottomRef = useRef(true);
|
|
178
178
|
|
|
179
179
|
const visibleMessages = messages.filter(
|
|
180
180
|
(m) => m.role === 'user' || m.role === 'assistant',
|
|
@@ -192,13 +192,18 @@ export function MessageList({
|
|
|
192
192
|
if (!el) return;
|
|
193
193
|
const atBottom =
|
|
194
194
|
el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD_PX;
|
|
195
|
+
isAtBottomRef.current = atBottom;
|
|
195
196
|
setIsAtBottom(atBottom);
|
|
196
197
|
}, []);
|
|
197
198
|
|
|
198
|
-
const scrollToBottom = useCallback((behavior: ScrollBehavior = '
|
|
199
|
-
const el =
|
|
199
|
+
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
|
|
200
|
+
const el = containerRef.current;
|
|
200
201
|
if (!el) return;
|
|
201
|
-
|
|
202
|
+
if (behavior === 'smooth') {
|
|
203
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
el.scrollTop = el.scrollHeight;
|
|
202
207
|
}, []);
|
|
203
208
|
|
|
204
209
|
useEffect(() => {
|
|
@@ -213,28 +218,30 @@ export function MessageList({
|
|
|
213
218
|
// Snap to bottom on first paint, regardless of whether the user "was" at
|
|
214
219
|
// the bottom — there's no "before" on mount.
|
|
215
220
|
useLayoutEffect(() => {
|
|
216
|
-
|
|
221
|
+
isAtBottomRef.current = true;
|
|
222
|
+
scrollToBottom('auto');
|
|
217
223
|
setIsAtBottom(true);
|
|
218
224
|
}, [scrollToBottom]);
|
|
219
225
|
|
|
220
|
-
//
|
|
221
|
-
//
|
|
226
|
+
// Stick to the bottom *before paint* while the user is still anchored.
|
|
227
|
+
// Smooth `scrollIntoView` on every reasoning token paints a frame mid-list
|
|
228
|
+
// then animates down — on Windows the classic scrollbar visibly jumps.
|
|
222
229
|
const lastMessageId = visibleMessages[visibleMessages.length - 1]?.id;
|
|
223
230
|
const lastContentLen = visibleMessages[visibleMessages.length - 1]?.content?.length ?? 0;
|
|
224
231
|
const lastTimelineSig = currentTurnTimeline
|
|
225
232
|
?.map((block) => (block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`))
|
|
226
233
|
.join('|') ?? '';
|
|
227
234
|
const suggestedSig = suggestedReplies?.join('\0') ?? '';
|
|
228
|
-
|
|
229
|
-
if (!
|
|
230
|
-
scrollToBottom('
|
|
231
|
-
}, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig,
|
|
235
|
+
useLayoutEffect(() => {
|
|
236
|
+
if (!isAtBottomRef.current) return;
|
|
237
|
+
scrollToBottom('auto');
|
|
238
|
+
}, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, scrollToBottom]);
|
|
232
239
|
|
|
233
240
|
return (
|
|
234
241
|
<div className="relative flex-1 overflow-hidden">
|
|
235
242
|
<div
|
|
236
243
|
ref={containerRef}
|
|
237
|
-
className="h-full overflow-y-auto px-3 py-3"
|
|
244
|
+
className="h-full overflow-y-auto overflow-anchor-none px-3 py-3"
|
|
238
245
|
>
|
|
239
246
|
{visibleMessages.length === 0 ? (
|
|
240
247
|
emptyState ?? null
|
|
@@ -359,13 +366,16 @@ export function MessageList({
|
|
|
359
366
|
) : null}
|
|
360
367
|
</div>
|
|
361
368
|
)}
|
|
362
|
-
<div ref={endRef} />
|
|
363
369
|
</div>
|
|
364
370
|
|
|
365
371
|
{!isAtBottom && visibleMessages.length > 0 && (
|
|
366
372
|
<button
|
|
367
373
|
type="button"
|
|
368
|
-
onClick={() =>
|
|
374
|
+
onClick={() => {
|
|
375
|
+
isAtBottomRef.current = true;
|
|
376
|
+
setIsAtBottom(true);
|
|
377
|
+
scrollToBottom('smooth');
|
|
378
|
+
}}
|
|
369
379
|
className="absolute bottom-3 right-3 flex h-8 w-8 items-center justify-center rounded-full border border-agent-border bg-agent-canvas text-agent-foreground shadow-md transition-colors hover:bg-agent-foreground/5"
|
|
370
380
|
title="回到底部"
|
|
371
381
|
aria-label="回到底部"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WorkBuddy-style follow-up chips:
|
|
3
|
-
*
|
|
2
|
+
* WorkBuddy-style follow-up chips: next-turn user inputs rendered under the
|
|
3
|
+
* latest assistant reply. Count follows the turn (skill next steps), not a
|
|
4
|
+
* fixed three. Clicking one sends that text immediately.
|
|
4
5
|
*/
|
|
5
6
|
export function SuggestedReplies({
|
|
6
7
|
suggestions,
|
|
@@ -35,16 +35,18 @@ export function TaskProcessPanel({
|
|
|
35
35
|
const [error, setError] = useState<string | null>(null);
|
|
36
36
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
37
37
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
38
|
+
const isAtBottomRef = useRef(true);
|
|
38
39
|
|
|
39
40
|
const checkAtBottom = useCallback(() => {
|
|
40
41
|
const el = containerRef.current;
|
|
41
42
|
if (!el) return;
|
|
42
43
|
const atBottom =
|
|
43
44
|
el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD_PX;
|
|
45
|
+
isAtBottomRef.current = atBottom;
|
|
44
46
|
setIsAtBottom(atBottom);
|
|
45
47
|
}, []);
|
|
46
48
|
|
|
47
|
-
const scrollToBottom = useCallback((behavior: ScrollBehavior = '
|
|
49
|
+
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
|
|
48
50
|
const el = containerRef.current;
|
|
49
51
|
if (!el) return;
|
|
50
52
|
if (behavior === 'smooth') {
|
|
@@ -65,20 +67,22 @@ export function TaskProcessPanel({
|
|
|
65
67
|
|
|
66
68
|
// 换任务 / 首屏:没有「之前在不在底部」可言,直接钉到底。
|
|
67
69
|
useLayoutEffect(() => {
|
|
68
|
-
|
|
70
|
+
isAtBottomRef.current = true;
|
|
71
|
+
scrollToBottom('auto');
|
|
69
72
|
setIsAtBottom(true);
|
|
70
73
|
}, [inspected.id, scrollToBottom]);
|
|
71
74
|
|
|
72
|
-
// live
|
|
75
|
+
// live 增量在 paint 前钉住底部。smooth scroll 会先露出一帧半截内容再往下
|
|
76
|
+
// 滑,Windows 经典滚动条就会上下跳。
|
|
73
77
|
const timelineSig = blocks
|
|
74
78
|
.map((block) =>
|
|
75
79
|
block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`,
|
|
76
80
|
)
|
|
77
81
|
.join('|');
|
|
78
|
-
|
|
79
|
-
if (!
|
|
80
|
-
scrollToBottom('
|
|
81
|
-
}, [timelineSig, live,
|
|
82
|
+
useLayoutEffect(() => {
|
|
83
|
+
if (!isAtBottomRef.current) return;
|
|
84
|
+
scrollToBottom('auto');
|
|
85
|
+
}, [timelineSig, live, scrollToBottom]);
|
|
82
86
|
|
|
83
87
|
useEffect(() => {
|
|
84
88
|
let cancelled = false;
|
|
@@ -153,7 +157,7 @@ export function TaskProcessPanel({
|
|
|
153
157
|
<div className="relative min-h-0 flex-1 overflow-hidden">
|
|
154
158
|
<div
|
|
155
159
|
ref={containerRef}
|
|
156
|
-
className="h-full overflow-y-auto p-3"
|
|
160
|
+
className="h-full overflow-y-auto overflow-anchor-none p-3"
|
|
157
161
|
data-testid="task-process-scroll"
|
|
158
162
|
>
|
|
159
163
|
{loading ? (
|
|
@@ -191,6 +195,7 @@ export function TaskProcessPanel({
|
|
|
191
195
|
<button
|
|
192
196
|
type="button"
|
|
193
197
|
onClick={() => {
|
|
198
|
+
isAtBottomRef.current = true;
|
|
194
199
|
setIsAtBottom(true);
|
|
195
200
|
scrollToBottom('smooth');
|
|
196
201
|
}}
|
|
@@ -1,7 +1,10 @@
|
|
|
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
10
|
import { processStatusLabel } from './process-status';
|
|
@@ -9,27 +12,56 @@ import { processHasReasoning, splitTurnProcess, type TurnBlock } from './turn-ti
|
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* Codex / DeepSeek-style turn process: think + tool rows stay in one
|
|
12
|
-
* disclosure.
|
|
13
|
-
*
|
|
14
|
-
* the turn ends. The status line still shows 思考中 / 工具名 / tok/s /
|
|
15
|
+
* disclosure. Settings 「显示思考内容」 has three modes: hidden / 5-line
|
|
16
|
+
* peek / full. The status line still shows 思考中 / 工具名 / tok/s /
|
|
15
17
|
* elapsed. The summary itself stays outside the fold.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
|
-
export const THINKING_PEEK_LINES =
|
|
20
|
+
export const THINKING_PEEK_LINES = 5;
|
|
21
|
+
/**
|
|
22
|
+
* 5 行 `text-xs` + `leading-relaxed` 的稳定高度。
|
|
23
|
+
* 不用 CSS `lh`:Windows Electron 在中文字体尚未就绪时 `lh` 会算成 0,
|
|
24
|
+
* 思考 peek 整块消失,字体加载后又把主列表高度撑跳。
|
|
25
|
+
*/
|
|
26
|
+
export const THINKING_PEEK_HEIGHT = `${THINKING_PEEK_LINES * 1.625}em`;
|
|
27
|
+
|
|
28
|
+
function ReasoningBody({
|
|
29
|
+
content,
|
|
30
|
+
showCursor,
|
|
31
|
+
}: {
|
|
32
|
+
content: string;
|
|
33
|
+
showCursor?: boolean;
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<div className="whitespace-pre-wrap break-words">{content}</div>
|
|
38
|
+
{showCursor ? (
|
|
39
|
+
<span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
|
|
40
|
+
) : null}
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveThinkingDisplay(
|
|
46
|
+
preference: ThinkingDisplayMode,
|
|
47
|
+
thinkingDisplay?: ThinkingDisplayMode,
|
|
48
|
+
showThinkingContent?: boolean,
|
|
49
|
+
): ThinkingDisplayMode {
|
|
50
|
+
if (thinkingDisplay) return thinkingDisplay;
|
|
51
|
+
if (showThinkingContent === true) return 'full';
|
|
52
|
+
if (showThinkingContent === false) return 'peek';
|
|
53
|
+
return preference;
|
|
54
|
+
}
|
|
19
55
|
|
|
20
|
-
function
|
|
21
|
-
startedAtMs: number | undefined,
|
|
22
|
-
enabled: boolean,
|
|
23
|
-
): number | undefined {
|
|
56
|
+
function useLiveNow(enabled: boolean): number {
|
|
24
57
|
const [now, setNow] = useState(() => Date.now());
|
|
25
58
|
useEffect(() => {
|
|
26
|
-
if (!enabled
|
|
59
|
+
if (!enabled) return;
|
|
27
60
|
setNow(Date.now());
|
|
28
61
|
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
|
29
62
|
return () => window.clearInterval(id);
|
|
30
|
-
}, [enabled
|
|
31
|
-
|
|
32
|
-
return Math.max(0, now - startedAtMs);
|
|
63
|
+
}, [enabled]);
|
|
64
|
+
return now;
|
|
33
65
|
}
|
|
34
66
|
|
|
35
67
|
function ProcessBlocks({
|
|
@@ -56,10 +88,7 @@ function ProcessBlocks({
|
|
|
56
88
|
key={`reasoning-${index}`}
|
|
57
89
|
className="text-xs leading-relaxed text-agent-muted-foreground"
|
|
58
90
|
>
|
|
59
|
-
<
|
|
60
|
-
{isStreaming && isLast && (
|
|
61
|
-
<span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
|
|
62
|
-
)}
|
|
91
|
+
<ReasoningBody content={block.content} showCursor={isStreaming && isLast} />
|
|
63
92
|
</div>
|
|
64
93
|
);
|
|
65
94
|
}
|
|
@@ -82,15 +111,9 @@ function ProcessBlocks({
|
|
|
82
111
|
function ThinkingPeek({
|
|
83
112
|
blocks,
|
|
84
113
|
isStreaming,
|
|
85
|
-
agents,
|
|
86
|
-
chats,
|
|
87
|
-
chatId,
|
|
88
114
|
}: {
|
|
89
115
|
blocks: TurnBlock[];
|
|
90
116
|
isStreaming: boolean;
|
|
91
|
-
agents: LocalChatAgent[];
|
|
92
|
-
chats: LocalChat[];
|
|
93
|
-
chatId?: string | null;
|
|
94
117
|
}) {
|
|
95
118
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
96
119
|
const reasoning = blocks.filter(
|
|
@@ -107,17 +130,15 @@ function ThinkingPeek({
|
|
|
107
130
|
return (
|
|
108
131
|
<div
|
|
109
132
|
ref={scrollerRef}
|
|
110
|
-
className="
|
|
133
|
+
className="overflow-y-auto overflow-anchor-none [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden border-l border-agent-border/70 pl-3 text-xs leading-relaxed text-agent-muted-foreground"
|
|
134
|
+
style={{ height: THINKING_PEEK_HEIGHT }}
|
|
111
135
|
data-thinking-peek=""
|
|
112
136
|
data-testid="thinking-peek"
|
|
113
137
|
data-peek-lines={THINKING_PEEK_LINES}
|
|
114
138
|
>
|
|
115
139
|
{reasoning.map((block, index) => (
|
|
116
140
|
<div key={`peek-reasoning-${index}`}>
|
|
117
|
-
<
|
|
118
|
-
{isStreaming && index === lastIndex && (
|
|
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
|
-
)}
|
|
141
|
+
<ReasoningBody content={block.content} showCursor={isStreaming && index === lastIndex} />
|
|
121
142
|
</div>
|
|
122
143
|
))}
|
|
123
144
|
</div>
|
|
@@ -134,6 +155,7 @@ export function TurnProcessGroup({
|
|
|
134
155
|
streamingHint,
|
|
135
156
|
startedAtMs,
|
|
136
157
|
durationMs,
|
|
158
|
+
thinkingDisplay: thinkingDisplayProp,
|
|
137
159
|
showThinkingContent: showThinkingContentProp,
|
|
138
160
|
renderAnswer,
|
|
139
161
|
}: {
|
|
@@ -149,25 +171,42 @@ export function TurnProcessGroup({
|
|
|
149
171
|
startedAtMs?: number;
|
|
150
172
|
/** Frozen wall-clock of a finished turn (metadata or just-ended stream). */
|
|
151
173
|
durationMs?: number;
|
|
174
|
+
/** Override the settings preference (hidden / peek / full). */
|
|
175
|
+
thinkingDisplay?: ThinkingDisplayMode;
|
|
152
176
|
/**
|
|
153
|
-
*
|
|
177
|
+
* Legacy override. `true` = full, `false` = peek.
|
|
154
178
|
* Task process panel passes true so the inspector stays expanded.
|
|
155
179
|
*/
|
|
156
180
|
showThinkingContent?: boolean;
|
|
157
181
|
renderAnswer: (block: Extract<TurnBlock, { type: 'text' }>, isLast: boolean) => ReactNode;
|
|
158
182
|
}) {
|
|
159
|
-
const preference =
|
|
160
|
-
const
|
|
183
|
+
const preference = useThinkingDisplay();
|
|
184
|
+
const thinkingDisplay = resolveThinkingDisplay(
|
|
185
|
+
preference,
|
|
186
|
+
thinkingDisplayProp,
|
|
187
|
+
showThinkingContentProp,
|
|
188
|
+
);
|
|
189
|
+
const showFullThinking = thinkingDisplay === 'full';
|
|
190
|
+
const showThinkingPeekAllowed = thinkingDisplay === 'peek';
|
|
161
191
|
const { process, answer } = splitTurnProcess(blocks);
|
|
162
192
|
const [open, setOpen] = useState(
|
|
163
|
-
() =>
|
|
193
|
+
() => showFullThinking && (isStreaming || answer.length === 0),
|
|
164
194
|
);
|
|
165
195
|
const wasStreamingRef = useRef(isStreaming);
|
|
166
196
|
const isStreamingRef = useRef(isStreaming);
|
|
167
197
|
const answerLenRef = useRef(answer.length);
|
|
168
198
|
isStreamingRef.current = isStreaming;
|
|
169
199
|
answerLenRef.current = answer.length;
|
|
170
|
-
const
|
|
200
|
+
const now = useLiveNow(isStreaming);
|
|
201
|
+
const streamClockRef = useRef<number | undefined>(undefined);
|
|
202
|
+
if (isStreaming) {
|
|
203
|
+
streamClockRef.current ??= startedAtMs ?? now;
|
|
204
|
+
} else {
|
|
205
|
+
streamClockRef.current = undefined;
|
|
206
|
+
}
|
|
207
|
+
const liveElapsedMs = isStreaming
|
|
208
|
+
? Math.max(0, now - (startedAtMs ?? streamClockRef.current ?? now))
|
|
209
|
+
: undefined;
|
|
171
210
|
const lastLiveElapsedRef = useRef<number | undefined>(undefined);
|
|
172
211
|
if (isStreaming && liveElapsedMs != null) {
|
|
173
212
|
lastLiveElapsedRef.current = liveElapsedMs;
|
|
@@ -181,23 +220,23 @@ export function TurnProcessGroup({
|
|
|
181
220
|
if (isStreaming && lastProcess?.type === 'reasoning') {
|
|
182
221
|
const index = process.length - 1;
|
|
183
222
|
if (reasoningClockRef.current?.index !== index) {
|
|
184
|
-
reasoningClockRef.current = { index, startedAt:
|
|
223
|
+
reasoningClockRef.current = { index, startedAt: now };
|
|
185
224
|
}
|
|
186
225
|
} else if (!isStreaming) {
|
|
187
226
|
reasoningClockRef.current = null;
|
|
188
227
|
}
|
|
189
228
|
const reasoningElapsedMs =
|
|
190
229
|
isStreaming && lastProcess?.type === 'reasoning' && reasoningClockRef.current
|
|
191
|
-
? Math.max(0,
|
|
230
|
+
? Math.max(0, now - reasoningClockRef.current.startedAt)
|
|
192
231
|
: undefined;
|
|
193
232
|
|
|
194
233
|
useEffect(() => {
|
|
195
|
-
if (!
|
|
234
|
+
if (!showFullThinking) {
|
|
196
235
|
setOpen(false);
|
|
197
236
|
return;
|
|
198
237
|
}
|
|
199
238
|
if (isStreamingRef.current || answerLenRef.current === 0) setOpen(true);
|
|
200
|
-
}, [
|
|
239
|
+
}, [showFullThinking]);
|
|
201
240
|
|
|
202
241
|
useEffect(() => {
|
|
203
242
|
const wasStreaming = wasStreamingRef.current;
|
|
@@ -205,17 +244,21 @@ export function TurnProcessGroup({
|
|
|
205
244
|
setOpen(false);
|
|
206
245
|
}
|
|
207
246
|
if (!wasStreaming && isStreaming) {
|
|
208
|
-
setOpen(
|
|
247
|
+
setOpen(showFullThinking);
|
|
209
248
|
}
|
|
210
249
|
wasStreamingRef.current = isStreaming;
|
|
211
|
-
}, [isStreaming, answer.length,
|
|
250
|
+
}, [isStreaming, answer.length, showFullThinking]);
|
|
212
251
|
|
|
213
252
|
if (blocks.length === 0) return <>{emptyFallback}</>;
|
|
214
253
|
|
|
215
254
|
const showToggle = process.length > 0;
|
|
216
255
|
const showProcess = !showToggle || open;
|
|
217
256
|
const showThinkingPeek =
|
|
218
|
-
|
|
257
|
+
showThinkingPeekAllowed &&
|
|
258
|
+
isStreaming &&
|
|
259
|
+
!open &&
|
|
260
|
+
showToggle &&
|
|
261
|
+
processHasReasoning(process);
|
|
219
262
|
const showStreamingHint =
|
|
220
263
|
Boolean(streamingHint) && isStreaming && answer.length === 0 && !showToggle;
|
|
221
264
|
|
|
@@ -255,13 +298,7 @@ export function TurnProcessGroup({
|
|
|
255
298
|
/>
|
|
256
299
|
)}
|
|
257
300
|
{showThinkingPeek && (
|
|
258
|
-
<ThinkingPeek
|
|
259
|
-
blocks={process}
|
|
260
|
-
isStreaming={answer.length === 0}
|
|
261
|
-
agents={agents}
|
|
262
|
-
chats={chats}
|
|
263
|
-
chatId={chatId}
|
|
264
|
-
/>
|
|
301
|
+
<ThinkingPeek blocks={process} isStreaming={answer.length === 0} />
|
|
265
302
|
)}
|
|
266
303
|
{showStreamingHint ? streamingHint : null}
|
|
267
304
|
{answer.map((block, index) => (
|
|
@@ -35,6 +35,22 @@ export function formatTokenSpeed(tokens: number, elapsedMs: number): string | nu
|
|
|
35
35
|
return `${shown} tok/s`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export function estimateProcessReasoningTokens(process: TurnBlock[]): number {
|
|
39
|
+
let tokens = 0;
|
|
40
|
+
for (const block of process) {
|
|
41
|
+
if (block.type === 'reasoning') tokens += estimateTextTokens(block.content);
|
|
42
|
+
}
|
|
43
|
+
return tokens;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function speedElapsedMs(
|
|
47
|
+
reasoningElapsedMs: number | undefined,
|
|
48
|
+
elapsedMs: number | undefined,
|
|
49
|
+
): number {
|
|
50
|
+
if (reasoningElapsedMs != null && reasoningElapsedMs >= 400) return reasoningElapsedMs;
|
|
51
|
+
return elapsedMs ?? reasoningElapsedMs ?? 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
export function lastToolsAreRunning(process: TurnBlock[]): boolean {
|
|
39
55
|
const last = process[process.length - 1];
|
|
40
56
|
if (last?.type !== 'tools' || last.actions.length === 0) return false;
|
|
@@ -79,13 +95,18 @@ export function processStatusLabel(input: {
|
|
|
79
95
|
if (names.length > 0) parts.push(names.join('、'));
|
|
80
96
|
} else {
|
|
81
97
|
parts.push('思考中');
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
estimateTextTokens(last.content)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
const tokens =
|
|
99
|
+
last?.type === 'reasoning'
|
|
100
|
+
? estimateTextTokens(last.content)
|
|
101
|
+
: estimateProcessReasoningTokens(process);
|
|
102
|
+
const speed = formatTokenSpeed(
|
|
103
|
+
tokens,
|
|
104
|
+
speedElapsedMs(
|
|
105
|
+
last?.type === 'reasoning' ? reasoningElapsedMs : undefined,
|
|
106
|
+
elapsedMs,
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
if (speed) parts.push(speed);
|
|
89
110
|
}
|
|
90
111
|
if (elapsed) parts.push(elapsed);
|
|
91
112
|
return parts.join(' · ');
|
|
@@ -17,7 +17,7 @@ function readSuggestedReplies(metadata: unknown): string[] | null {
|
|
|
17
17
|
parsed.suggestedReplies.length > 0 &&
|
|
18
18
|
parsed.suggestedReplies.every((item) => typeof item === 'string' && item.trim())
|
|
19
19
|
) {
|
|
20
|
-
return parsed.suggestedReplies.map((item) => item.trim()).slice(0,
|
|
20
|
+
return parsed.suggestedReplies.map((item) => item.trim()).slice(0, 8);
|
|
21
21
|
}
|
|
22
22
|
} catch {
|
|
23
23
|
return null;
|
|
@@ -1,43 +1,56 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
THINKING_DISPLAY_OPTIONS,
|
|
3
|
+
persistThinkingDisplay,
|
|
4
|
+
useThinkingDisplay,
|
|
5
|
+
} from '@/lib/show-thinking-content';
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
|
-
*
|
|
8
|
+
* 界面偏好:思考过程正文按「隐藏 / 显示5行 / 完整显示」三档。
|
|
5
9
|
* 立即写入 localStorage,不走设置页顶部的模型保存按钮。
|
|
6
10
|
*/
|
|
7
11
|
export function AppearanceSettingsPanel() {
|
|
8
|
-
const
|
|
12
|
+
const mode = useThinkingDisplay();
|
|
13
|
+
const current = THINKING_DISPLAY_OPTIONS.find((item) => item.mode === mode);
|
|
9
14
|
|
|
10
15
|
return (
|
|
11
16
|
<div
|
|
12
17
|
className="space-y-3 rounded-agent-md border border-agent-border bg-agent-card p-4"
|
|
13
18
|
data-testid="appearance-settings-panel"
|
|
14
19
|
>
|
|
15
|
-
<div
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
20
|
+
<div>
|
|
21
|
+
<p className="text-sm font-medium text-agent-foreground">显示思考内容</p>
|
|
22
|
+
<p className="mt-1 text-xs leading-relaxed text-agent-muted-foreground">
|
|
23
|
+
{current?.hint} 状态行仍显示思考中、工具名、速度和用时。
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
<div
|
|
27
|
+
className="flex w-full rounded-full border border-agent-border bg-agent-canvas p-0.5"
|
|
28
|
+
role="radiogroup"
|
|
29
|
+
aria-label="显示思考内容"
|
|
30
|
+
data-testid="thinking-display-toggle"
|
|
31
|
+
>
|
|
32
|
+
{THINKING_DISPLAY_OPTIONS.map((item) => {
|
|
33
|
+
const selected = mode === item.mode;
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
key={item.mode}
|
|
37
|
+
type="button"
|
|
38
|
+
role="radio"
|
|
39
|
+
aria-checked={selected}
|
|
40
|
+
aria-label={item.label}
|
|
41
|
+
data-testid={`thinking-display-${item.mode}`}
|
|
42
|
+
onClick={() => persistThinkingDisplay(item.mode)}
|
|
43
|
+
className={[
|
|
44
|
+
'h-7 flex-1 rounded-full px-2 text-xs transition-colors',
|
|
45
|
+
selected
|
|
46
|
+
? 'bg-agent-foreground/10 text-agent-foreground'
|
|
47
|
+
: 'text-agent-muted-foreground hover:text-agent-foreground',
|
|
48
|
+
].join(' ')}
|
|
49
|
+
>
|
|
50
|
+
{item.label}
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
41
54
|
</div>
|
|
42
55
|
</div>
|
|
43
56
|
);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 聊天里「显示思考内容」的本机偏好:隐藏 / 显示 5 行 / 完整显示。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 存在 localStorage
|
|
4
|
+
* 默认「显示 5 行」:流式中露出固定行数,结束后折叠,只留状态行。
|
|
5
|
+
* 存在 localStorage,设置页切换立即生效,不走后端。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { useEffect, useState } from 'react';
|
|
@@ -10,32 +10,57 @@ import { useEffect, useState } from 'react';
|
|
|
10
10
|
export const SHOW_THINKING_CONTENT_STORAGE_KEY = 'agent-show-thinking-content';
|
|
11
11
|
export const SHOW_THINKING_CONTENT_EVENT = 'agent-show-thinking-content-changed';
|
|
12
12
|
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
export const THINKING_DISPLAY_MODES = ['hidden', 'peek', 'full'] as const;
|
|
14
|
+
export type ThinkingDisplayMode = (typeof THINKING_DISPLAY_MODES)[number];
|
|
15
|
+
export const DEFAULT_THINKING_DISPLAY: ThinkingDisplayMode = 'peek';
|
|
16
|
+
|
|
17
|
+
export const THINKING_DISPLAY_OPTIONS: ReadonlyArray<{
|
|
18
|
+
mode: ThinkingDisplayMode;
|
|
19
|
+
label: string;
|
|
20
|
+
hint: string;
|
|
21
|
+
}> = [
|
|
22
|
+
{ mode: 'hidden', label: '隐藏', hint: '只保留状态行,不展示思考正文。' },
|
|
23
|
+
{ mode: 'peek', label: '显示5行', hint: '流式中露出 5 行思考,结束后自动折叠。' },
|
|
24
|
+
{ mode: 'full', label: '完整显示', hint: '流式中展开全部思考与工具过程,结束后可手动折叠。' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export function isThinkingDisplayMode(value: unknown): value is ThinkingDisplayMode {
|
|
28
|
+
return value === 'hidden' || value === 'peek' || value === 'full';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 兼容旧开关:'1' → 完整显示,'0' / 缺失 → 显示 5 行。 */
|
|
32
|
+
export function parseThinkingDisplay(raw: string | null | undefined): ThinkingDisplayMode {
|
|
33
|
+
if (isThinkingDisplayMode(raw)) return raw;
|
|
34
|
+
if (raw === '1') return 'full';
|
|
35
|
+
return DEFAULT_THINKING_DISPLAY;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readThinkingDisplay(): ThinkingDisplayMode {
|
|
39
|
+
if (typeof localStorage === 'undefined') return DEFAULT_THINKING_DISPLAY;
|
|
40
|
+
return parseThinkingDisplay(localStorage.getItem(SHOW_THINKING_CONTENT_STORAGE_KEY));
|
|
16
41
|
}
|
|
17
42
|
|
|
18
|
-
export function
|
|
43
|
+
export function persistThinkingDisplay(mode: ThinkingDisplayMode): void {
|
|
19
44
|
if (typeof localStorage !== 'undefined') {
|
|
20
|
-
localStorage.setItem(SHOW_THINKING_CONTENT_STORAGE_KEY,
|
|
45
|
+
localStorage.setItem(SHOW_THINKING_CONTENT_STORAGE_KEY, mode);
|
|
21
46
|
}
|
|
22
47
|
if (typeof window === 'undefined') return;
|
|
23
48
|
window.dispatchEvent(
|
|
24
|
-
new CustomEvent<
|
|
49
|
+
new CustomEvent<ThinkingDisplayMode>(SHOW_THINKING_CONTENT_EVENT, { detail: mode }),
|
|
25
50
|
);
|
|
26
51
|
}
|
|
27
52
|
|
|
28
53
|
/** 当前页与跨标签的偏好变更都会刷新。 */
|
|
29
|
-
export function
|
|
30
|
-
const [
|
|
54
|
+
export function useThinkingDisplay(): ThinkingDisplayMode {
|
|
55
|
+
const [mode, setMode] = useState(readThinkingDisplay);
|
|
31
56
|
useEffect(() => {
|
|
32
57
|
const onCustom = (event: Event) => {
|
|
33
|
-
const detail = (event as CustomEvent<
|
|
34
|
-
|
|
58
|
+
const detail = (event as CustomEvent<ThinkingDisplayMode>).detail;
|
|
59
|
+
setMode(isThinkingDisplayMode(detail) ? detail : readThinkingDisplay());
|
|
35
60
|
};
|
|
36
61
|
const onStorage = (event: StorageEvent) => {
|
|
37
62
|
if (event.key === SHOW_THINKING_CONTENT_STORAGE_KEY) {
|
|
38
|
-
|
|
63
|
+
setMode(parseThinkingDisplay(event.newValue));
|
|
39
64
|
}
|
|
40
65
|
};
|
|
41
66
|
window.addEventListener(SHOW_THINKING_CONTENT_EVENT, onCustom);
|
|
@@ -45,5 +70,5 @@ export function useShowThinkingContent(): boolean {
|
|
|
45
70
|
window.removeEventListener('storage', onStorage);
|
|
46
71
|
};
|
|
47
72
|
}, []);
|
|
48
|
-
return
|
|
73
|
+
return mode;
|
|
49
74
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react';
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
2
|
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
3
3
|
import { LocalChatPanel } from '@/components/chat/LocalChatPanel';
|
|
4
4
|
import { ChatHeader } from '@/components/ChatHeader';
|
|
@@ -123,17 +123,53 @@ const MOCK_TIMELINE: TurnBlock[] = [
|
|
|
123
123
|
* StreamingStatus's "正在思考..." baseline.
|
|
124
124
|
* • `tools-run` — in-flight bubble, round 2, 2 tools just ran; verifies
|
|
125
125
|
* StreamingStatus's "已调用 N 个工具" + "Round N" badge interaction.
|
|
126
|
+
* • `reasoning-stream` — reasoning tokens arrive over time; verifies the
|
|
127
|
+
* 5-line peek stays visible and the list scrollbar sticks to the bottom.
|
|
126
128
|
*/
|
|
127
|
-
type PreviewScene = 'static' | 'thinking' | 'tools-run';
|
|
129
|
+
type PreviewScene = 'static' | 'thinking' | 'tools-run' | 'reasoning-stream';
|
|
130
|
+
|
|
131
|
+
const STREAMING_REASONING = [
|
|
132
|
+
'先检查服务是否还能连上。',
|
|
133
|
+
'如果端口通了,再跑本目录的测试套件。',
|
|
134
|
+
'失败的话只读失败文件,不要整仓扫一遍。',
|
|
135
|
+
'断言颜色对不上时优先看 AssistantMessage 的默认色。',
|
|
136
|
+
'改完再复跑一次,确认没有带出新的失败。',
|
|
137
|
+
'工具调用保持最少:连通性检查、跑测试、必要时读文件。',
|
|
138
|
+
'最后用一两句说清楚结果和下一步。',
|
|
139
|
+
'如果还在思考,后面的句子会被 5 行窗口裁掉。',
|
|
140
|
+
'比较 a < b 时不要被 Markdown 当成 HTML 标签吃掉。',
|
|
141
|
+
'滚动条应一直钉在最下面,不要随着 token 上下跳。',
|
|
142
|
+
].join('\n');
|
|
128
143
|
|
|
129
144
|
const SCENES: { id: PreviewScene; label: string }[] = [
|
|
130
145
|
{ id: 'static', label: '静态历史' },
|
|
131
146
|
{ id: 'thinking', label: '流式 · 空内容' },
|
|
132
147
|
{ id: 'tools-run', label: '流式 · 工具已跑 · round 2' },
|
|
148
|
+
{ id: 'reasoning-stream', label: '流式 · 推理打字' },
|
|
133
149
|
];
|
|
134
150
|
|
|
135
151
|
export function ChatPanelPreviewPage() {
|
|
136
152
|
const [scene, setScene] = useState<PreviewScene>('static');
|
|
153
|
+
const [streamedReasoning, setStreamedReasoning] = useState('');
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (scene !== 'reasoning-stream') {
|
|
157
|
+
setStreamedReasoning('');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
setStreamedReasoning('');
|
|
161
|
+
let index = 0;
|
|
162
|
+
const id = window.setInterval(() => {
|
|
163
|
+
index += 2;
|
|
164
|
+
if (index >= STREAMING_REASONING.length) {
|
|
165
|
+
setStreamedReasoning(STREAMING_REASONING);
|
|
166
|
+
window.clearInterval(id);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
setStreamedReasoning(STREAMING_REASONING.slice(0, index));
|
|
170
|
+
}, 40);
|
|
171
|
+
return () => window.clearInterval(id);
|
|
172
|
+
}, [scene]);
|
|
137
173
|
|
|
138
174
|
// Explicit struct type so TS doesn't try to narrow `actionsByMsgId` to the
|
|
139
175
|
// union of {} | { m2: ... } — both shapes are valid `Record<string, ...>`
|
|
@@ -198,7 +234,7 @@ export function ChatPanelPreviewPage() {
|
|
|
198
234
|
'改完再复跑一次,确认没有带出新的失败。',
|
|
199
235
|
'工具调用保持最少:连通性检查、跑测试、必要时读文件。',
|
|
200
236
|
'最后用一两句说清楚结果和下一步。',
|
|
201
|
-
'如果还在思考,后面的句子会被
|
|
237
|
+
'如果还在思考,后面的句子会被 5 行窗口裁掉。',
|
|
202
238
|
].join('\n'),
|
|
203
239
|
},
|
|
204
240
|
{
|
|
@@ -209,6 +245,28 @@ export function ChatPanelPreviewPage() {
|
|
|
209
245
|
currentTurnStartedAtMs: Date.now() - 12_000,
|
|
210
246
|
currentRound: 2,
|
|
211
247
|
};
|
|
248
|
+
case 'reasoning-stream':
|
|
249
|
+
return {
|
|
250
|
+
messages: [
|
|
251
|
+
MOCK_MESSAGES[0],
|
|
252
|
+
{
|
|
253
|
+
id: 'm-streaming',
|
|
254
|
+
role: 'assistant',
|
|
255
|
+
agentId: MOCK_AGENT.id,
|
|
256
|
+
content: '',
|
|
257
|
+
createdAt: new Date().toISOString(),
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
isStreaming: true,
|
|
261
|
+
actionsByMsgId: {} as Record<string, ExecutedAction[]>,
|
|
262
|
+
currentTurnActions: [],
|
|
263
|
+
timelineByMsgId: {} as Record<string, TurnBlock[]>,
|
|
264
|
+
currentTurnTimeline: streamedReasoning
|
|
265
|
+
? [{ type: 'reasoning', content: streamedReasoning }]
|
|
266
|
+
: [],
|
|
267
|
+
currentTurnStartedAtMs: Date.now() - 1_000,
|
|
268
|
+
currentRound: 1,
|
|
269
|
+
};
|
|
212
270
|
case 'static':
|
|
213
271
|
default:
|
|
214
272
|
return {
|
|
@@ -222,7 +280,7 @@ export function ChatPanelPreviewPage() {
|
|
|
222
280
|
suggestedReplies: ['修一下失败的断言颜色', '把测试再跑一遍', '解释这次失败的原因'],
|
|
223
281
|
};
|
|
224
282
|
}
|
|
225
|
-
}, [scene]);
|
|
283
|
+
}, [scene, streamedReasoning]);
|
|
226
284
|
|
|
227
285
|
return (
|
|
228
286
|
<div className="flex h-full w-full flex-col bg-agent-muted/30">
|