@steerable/agent-shell-web 0.6.21 → 0.6.24
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/README.md +6 -0
- package/package.json +5 -4
- package/src/auth/gate.ts +26 -0
- package/src/components/ChatHeader.tsx +1 -17
- package/src/components/chat/TaskProcessPanel.tsx +1 -0
- package/src/components/chat/TurnProcessGroup.tsx +115 -39
- package/src/components/chat/process-status.ts +101 -0
- package/src/components/settings/AppearanceSettingsPanel.tsx +44 -0
- package/src/fixtures/browser-dev-data.json +1 -1
- package/src/lib/show-thinking-content.ts +49 -0
- package/src/main.tsx +41 -19
- package/src/pages/AgentPage.tsx +0 -1
- package/src/pages/ChatPanelPreviewPage.tsx +18 -3
- package/src/pages/SettingsPage.tsx +11 -2
package/README.md
CHANGED
|
@@ -17,4 +17,10 @@ bundle; there is no prebuilt `dist/` here. See
|
|
|
17
17
|
[`docs/spec/architecture.md`](https://github.com/pathlyapp/steerable-framework/blob/develop/docs/spec/architecture.md)
|
|
18
18
|
for the tier model.
|
|
19
19
|
|
|
20
|
+
Products that require authentication can register an `AppShellGate` from
|
|
21
|
+
`@steerable/agent-shell-web/auth/gate` before calling `bootstrap()`. An
|
|
22
|
+
enabled gate renders before the application router and calls
|
|
23
|
+
`onAuthenticated` to continue normal bootstrap. With no gate, or a disabled
|
|
24
|
+
gate, startup is unchanged.
|
|
25
|
+
|
|
20
26
|
License: Apache-2.0.
|
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.24",
|
|
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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"url": "https://github.com/pathlyapp/steerable-framework/issues"
|
|
15
15
|
},
|
|
16
16
|
"exports": {
|
|
17
|
+
"./auth/gate": "./src/auth/gate.ts",
|
|
17
18
|
"./vite.base": {
|
|
18
19
|
"types": "./vite.base.d.ts",
|
|
19
20
|
"default": "./vite.base.js"
|
|
@@ -45,8 +46,8 @@
|
|
|
45
46
|
"react-router-dom": "^6.30.0",
|
|
46
47
|
"rehype-raw": "^7.0.0",
|
|
47
48
|
"remark-gfm": "^4.0.1",
|
|
48
|
-
"@steerable/agent-ui": "0.6.
|
|
49
|
-
"@steerable/agent-protocol": "0.6.
|
|
49
|
+
"@steerable/agent-ui": "0.6.24",
|
|
50
|
+
"@steerable/agent-protocol": "0.6.24"
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
53
|
"@tailwindcss/vite": "^4.3.0",
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
"typescript": "^5.8.3",
|
|
60
61
|
"vite": "^5.4.21",
|
|
61
62
|
"vitest": "^3.2.4",
|
|
62
|
-
"@steerable/pack-sdk": "0.6.
|
|
63
|
+
"@steerable/pack-sdk": "0.6.24"
|
|
63
64
|
},
|
|
64
65
|
"scripts": {
|
|
65
66
|
"gen": "echo agent-shell-web",
|
package/src/auth/gate.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Optional product-provided gate rendered before the application router.
|
|
5
|
+
*/
|
|
6
|
+
export interface AppShellGate {
|
|
7
|
+
/** Allows one product entry to disable its gate for unsupported runtimes. */
|
|
8
|
+
enabled(): boolean;
|
|
9
|
+
/** Renders authentication UI and resumes bootstrap through the callback. */
|
|
10
|
+
Component: ComponentType<{ onAuthenticated: () => void }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let registeredGate: AppShellGate | null = null;
|
|
14
|
+
|
|
15
|
+
/** Registers the app-shell gate before calling `bootstrap()`. */
|
|
16
|
+
export function registerAppShellGate(gate: AppShellGate): void {
|
|
17
|
+
if (registeredGate) {
|
|
18
|
+
throw new Error('[app-shell-gate] a gate is already registered');
|
|
19
|
+
}
|
|
20
|
+
registeredGate = gate;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Returns the product-provided gate, if one was registered. */
|
|
24
|
+
export function getAppShellGate(): AppShellGate | null {
|
|
25
|
+
return registeredGate;
|
|
26
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import { LuCheck, LuGitBranch, LuListTodo, LuListTree, LuLoaderCircle } from 'react-icons/lu';
|
|
4
|
-
import type { LocalChat,
|
|
4
|
+
import type { LocalChat, LocalTask } from '@/lib/local-api';
|
|
5
5
|
import {
|
|
6
6
|
activateChatBranch,
|
|
7
7
|
getChatBranches,
|
|
@@ -13,7 +13,6 @@ import { actionableTasks, summarizeTasks, type ChatTaskSummary } from './chat/us
|
|
|
13
13
|
|
|
14
14
|
interface ChatHeaderProps {
|
|
15
15
|
chat: LocalChat | null;
|
|
16
|
-
agent: LocalChatAgent | null;
|
|
17
16
|
/**
|
|
18
17
|
* W1.2.1: called after the active branch switches — the page re-hydrates
|
|
19
18
|
* the message list from the re-projected store. When provided (and the
|
|
@@ -29,8 +28,6 @@ interface ChatHeaderProps {
|
|
|
29
28
|
/**
|
|
30
29
|
* ChatHeader — slim title bar above ChatPanel. Shows:
|
|
31
30
|
* - chat title (完整显示,长标题换行不截断)
|
|
32
|
-
* - bound agent name + icon (read-only here; switching is done from the
|
|
33
|
-
* sidebar's expert team list)
|
|
34
31
|
* - shortened chat id (for support / debugging)
|
|
35
32
|
*
|
|
36
33
|
* Terminal / 场景包调试窗 / 本地模型设置 USED to live here, but per the
|
|
@@ -40,7 +37,6 @@ interface ChatHeaderProps {
|
|
|
40
37
|
*/
|
|
41
38
|
export function ChatHeader({
|
|
42
39
|
chat,
|
|
43
|
-
agent,
|
|
44
40
|
onBranchSwitched,
|
|
45
41
|
onInspectTask,
|
|
46
42
|
tasks = [],
|
|
@@ -186,18 +182,6 @@ export function ChatHeader({
|
|
|
186
182
|
>
|
|
187
183
|
{chat?.title ?? '未选择对话'}
|
|
188
184
|
</span>
|
|
189
|
-
<span className="shrink-0 text-xs text-agent-muted-foreground">
|
|
190
|
-
{agent ? (
|
|
191
|
-
<>
|
|
192
|
-
<span className="mr-0.5">{agent.icon || '🤖'}</span>
|
|
193
|
-
{agent.name}
|
|
194
|
-
</>
|
|
195
|
-
) : chat?.agentId ? (
|
|
196
|
-
<span className="font-mono">{chat.agentId}</span>
|
|
197
|
-
) : (
|
|
198
|
-
<span className="italic">no agent</span>
|
|
199
|
-
)}
|
|
200
|
-
</span>
|
|
201
185
|
{chat && (
|
|
202
186
|
<span className="hidden shrink-0 font-mono text-[10px] text-agent-muted-foreground/70 md:inline">
|
|
203
187
|
{shortenId(chat.id)}
|
|
@@ -1,24 +1,22 @@
|
|
|
1
|
-
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
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 { useShowThinkingContent } from '@/lib/show-thinking-content';
|
|
4
5
|
import { Markdown } from './Markdown';
|
|
5
6
|
import { ToolsFlow } from './ExecutedActionsCard';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
countProcessTools,
|
|
9
|
-
processHasReasoning,
|
|
10
|
-
splitTurnProcess,
|
|
11
|
-
type TurnBlock,
|
|
12
|
-
} from './turn-timeline';
|
|
7
|
+
import { processStatusLabel } from './process-status';
|
|
8
|
+
import { processHasReasoning, splitTurnProcess, type TurnBlock } from './turn-timeline';
|
|
13
9
|
|
|
14
10
|
/**
|
|
15
11
|
* Codex / DeepSeek-style turn process: think + tool rows stay in one
|
|
16
|
-
* disclosure.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* disclosure. Default is collapsed (settings 「显示思考内容」 off). While
|
|
13
|
+
* streaming, a fixed 7-line peek shows the latest reasoning; it folds when
|
|
14
|
+
* the turn ends. The status line still shows 思考中 / 工具名 / tok/s /
|
|
15
|
+
* elapsed. The summary itself stays outside the fold.
|
|
20
16
|
*/
|
|
21
17
|
|
|
18
|
+
export const THINKING_PEEK_LINES = 7;
|
|
19
|
+
|
|
22
20
|
function useLiveElapsedMs(
|
|
23
21
|
startedAtMs: number | undefined,
|
|
24
22
|
enabled: boolean,
|
|
@@ -34,27 +32,6 @@ function useLiveElapsedMs(
|
|
|
34
32
|
return Math.max(0, now - startedAtMs);
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
function processLabel(
|
|
38
|
-
process: TurnBlock[],
|
|
39
|
-
isStreaming: boolean,
|
|
40
|
-
elapsedMs: number | undefined,
|
|
41
|
-
): string {
|
|
42
|
-
const elapsed =
|
|
43
|
-
elapsedMs != null && (isStreaming || elapsedMs >= 1000)
|
|
44
|
-
? formatElapsedCompact(elapsedMs)
|
|
45
|
-
: null;
|
|
46
|
-
if (isStreaming) {
|
|
47
|
-
return elapsed ? `正在执行… ${elapsed}` : '正在执行…';
|
|
48
|
-
}
|
|
49
|
-
const parts: string[] = [];
|
|
50
|
-
const tools = countProcessTools(process);
|
|
51
|
-
if (tools > 0) parts.push(`${tools} 次工具调用`);
|
|
52
|
-
if (processHasReasoning(process)) parts.push('已思考');
|
|
53
|
-
if (elapsed) parts.push(`工作了 ${elapsed}`);
|
|
54
|
-
if (parts.length === 0) return '执行过程';
|
|
55
|
-
return parts.join(' · ');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
35
|
function ProcessBlocks({
|
|
59
36
|
blocks,
|
|
60
37
|
isStreaming,
|
|
@@ -102,6 +79,51 @@ function ProcessBlocks({
|
|
|
102
79
|
);
|
|
103
80
|
}
|
|
104
81
|
|
|
82
|
+
function ThinkingPeek({
|
|
83
|
+
blocks,
|
|
84
|
+
isStreaming,
|
|
85
|
+
agents,
|
|
86
|
+
chats,
|
|
87
|
+
chatId,
|
|
88
|
+
}: {
|
|
89
|
+
blocks: TurnBlock[];
|
|
90
|
+
isStreaming: boolean;
|
|
91
|
+
agents: LocalChatAgent[];
|
|
92
|
+
chats: LocalChat[];
|
|
93
|
+
chatId?: string | null;
|
|
94
|
+
}) {
|
|
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
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
ref={scrollerRef}
|
|
110
|
+
className="h-[7lh] overflow-hidden border-l border-agent-border/70 pl-3 text-xs leading-relaxed text-agent-muted-foreground"
|
|
111
|
+
data-thinking-peek=""
|
|
112
|
+
data-testid="thinking-peek"
|
|
113
|
+
data-peek-lines={THINKING_PEEK_LINES}
|
|
114
|
+
>
|
|
115
|
+
{reasoning.map((block, index) => (
|
|
116
|
+
<div key={`peek-reasoning-${index}`}>
|
|
117
|
+
<Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
|
|
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
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
105
127
|
export function TurnProcessGroup({
|
|
106
128
|
blocks,
|
|
107
129
|
isStreaming,
|
|
@@ -112,6 +134,7 @@ export function TurnProcessGroup({
|
|
|
112
134
|
streamingHint,
|
|
113
135
|
startedAtMs,
|
|
114
136
|
durationMs,
|
|
137
|
+
showThinkingContent: showThinkingContentProp,
|
|
115
138
|
renderAnswer,
|
|
116
139
|
}: {
|
|
117
140
|
blocks: TurnBlock[];
|
|
@@ -126,11 +149,24 @@ export function TurnProcessGroup({
|
|
|
126
149
|
startedAtMs?: number;
|
|
127
150
|
/** Frozen wall-clock of a finished turn (metadata or just-ended stream). */
|
|
128
151
|
durationMs?: number;
|
|
152
|
+
/**
|
|
153
|
+
* Override the settings preference. Omitted = read 「显示思考内容」.
|
|
154
|
+
* Task process panel passes true so the inspector stays expanded.
|
|
155
|
+
*/
|
|
156
|
+
showThinkingContent?: boolean;
|
|
129
157
|
renderAnswer: (block: Extract<TurnBlock, { type: 'text' }>, isLast: boolean) => ReactNode;
|
|
130
158
|
}) {
|
|
159
|
+
const preference = useShowThinkingContent();
|
|
160
|
+
const showThinkingContent = showThinkingContentProp ?? preference;
|
|
131
161
|
const { process, answer } = splitTurnProcess(blocks);
|
|
132
|
-
const [open, setOpen] = useState(
|
|
162
|
+
const [open, setOpen] = useState(
|
|
163
|
+
() => showThinkingContent && (isStreaming || answer.length === 0),
|
|
164
|
+
);
|
|
133
165
|
const wasStreamingRef = useRef(isStreaming);
|
|
166
|
+
const isStreamingRef = useRef(isStreaming);
|
|
167
|
+
const answerLenRef = useRef(answer.length);
|
|
168
|
+
isStreamingRef.current = isStreaming;
|
|
169
|
+
answerLenRef.current = answer.length;
|
|
134
170
|
const liveElapsedMs = useLiveElapsedMs(startedAtMs, isStreaming);
|
|
135
171
|
const lastLiveElapsedRef = useRef<number | undefined>(undefined);
|
|
136
172
|
if (isStreaming && liveElapsedMs != null) {
|
|
@@ -140,24 +176,48 @@ export function TurnProcessGroup({
|
|
|
140
176
|
? liveElapsedMs
|
|
141
177
|
: durationMs ?? lastLiveElapsedRef.current;
|
|
142
178
|
|
|
179
|
+
const lastProcess = process[process.length - 1];
|
|
180
|
+
const reasoningClockRef = useRef<{ index: number; startedAt: number } | null>(null);
|
|
181
|
+
if (isStreaming && lastProcess?.type === 'reasoning') {
|
|
182
|
+
const index = process.length - 1;
|
|
183
|
+
if (reasoningClockRef.current?.index !== index) {
|
|
184
|
+
reasoningClockRef.current = { index, startedAt: Date.now() };
|
|
185
|
+
}
|
|
186
|
+
} else if (!isStreaming) {
|
|
187
|
+
reasoningClockRef.current = null;
|
|
188
|
+
}
|
|
189
|
+
const reasoningElapsedMs =
|
|
190
|
+
isStreaming && lastProcess?.type === 'reasoning' && reasoningClockRef.current
|
|
191
|
+
? Math.max(0, Date.now() - reasoningClockRef.current.startedAt)
|
|
192
|
+
: undefined;
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (!showThinkingContent) {
|
|
196
|
+
setOpen(false);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (isStreamingRef.current || answerLenRef.current === 0) setOpen(true);
|
|
200
|
+
}, [showThinkingContent]);
|
|
201
|
+
|
|
143
202
|
useEffect(() => {
|
|
144
203
|
const wasStreaming = wasStreamingRef.current;
|
|
145
204
|
if (wasStreaming && !isStreaming && answer.length > 0) {
|
|
146
205
|
setOpen(false);
|
|
147
206
|
}
|
|
148
207
|
if (!wasStreaming && isStreaming) {
|
|
149
|
-
setOpen(
|
|
208
|
+
setOpen(showThinkingContent);
|
|
150
209
|
}
|
|
151
210
|
wasStreamingRef.current = isStreaming;
|
|
152
|
-
}, [isStreaming, answer.length]);
|
|
211
|
+
}, [isStreaming, answer.length, showThinkingContent]);
|
|
153
212
|
|
|
154
213
|
if (blocks.length === 0) return <>{emptyFallback}</>;
|
|
155
214
|
|
|
156
215
|
const showToggle = process.length > 0;
|
|
157
216
|
const showProcess = !showToggle || open;
|
|
158
|
-
const
|
|
217
|
+
const showThinkingPeek =
|
|
218
|
+
isStreaming && !open && showToggle && processHasReasoning(process);
|
|
159
219
|
const showStreamingHint =
|
|
160
|
-
Boolean(streamingHint) && isStreaming && answer.length === 0 &&
|
|
220
|
+
Boolean(streamingHint) && isStreaming && answer.length === 0 && !showToggle;
|
|
161
221
|
|
|
162
222
|
return (
|
|
163
223
|
<div className="space-y-1.5" data-turn-timeline>
|
|
@@ -175,7 +235,14 @@ export function TurnProcessGroup({
|
|
|
175
235
|
) : (
|
|
176
236
|
<LuChevronRight className="h-3.5 w-3.5 shrink-0" />
|
|
177
237
|
)}
|
|
178
|
-
<span className="truncate">
|
|
238
|
+
<span className="truncate">
|
|
239
|
+
{processStatusLabel({
|
|
240
|
+
process,
|
|
241
|
+
isStreaming,
|
|
242
|
+
elapsedMs,
|
|
243
|
+
reasoningElapsedMs,
|
|
244
|
+
})}
|
|
245
|
+
</span>
|
|
179
246
|
</button>
|
|
180
247
|
)}
|
|
181
248
|
{showProcess && (
|
|
@@ -187,6 +254,15 @@ export function TurnProcessGroup({
|
|
|
187
254
|
chatId={chatId}
|
|
188
255
|
/>
|
|
189
256
|
)}
|
|
257
|
+
{showThinkingPeek && (
|
|
258
|
+
<ThinkingPeek
|
|
259
|
+
blocks={process}
|
|
260
|
+
isStreaming={answer.length === 0}
|
|
261
|
+
agents={agents}
|
|
262
|
+
chats={chats}
|
|
263
|
+
chatId={chatId}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
190
266
|
{showStreamingHint ? streamingHint : null}
|
|
191
267
|
{answer.map((block, index) => (
|
|
192
268
|
<Fragment key={`answer-${index}`}>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-process status line: live 「思考中 / 调用工具中」 plus finished
|
|
3
|
+
* 「N 次工具调用 · 已思考 · 工作了 …」. Token speed is a coarse estimate
|
|
4
|
+
* from reasoning text (same CJK/other weights as the desktop compactor).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { formatElapsedCompact } from './elapsed';
|
|
8
|
+
import {
|
|
9
|
+
countProcessTools,
|
|
10
|
+
processHasReasoning,
|
|
11
|
+
type TurnBlock,
|
|
12
|
+
} from './turn-timeline';
|
|
13
|
+
|
|
14
|
+
export function estimateTextTokens(text: string): number {
|
|
15
|
+
if (!text) return 0;
|
|
16
|
+
let cjk = 0;
|
|
17
|
+
for (let i = 0; i < text.length; i++) {
|
|
18
|
+
const code = text.charCodeAt(i);
|
|
19
|
+
if (
|
|
20
|
+
(code >= 0x4e00 && code <= 0x9fff) ||
|
|
21
|
+
(code >= 0x3000 && code <= 0x303f) ||
|
|
22
|
+
(code >= 0xff00 && code <= 0xffef)
|
|
23
|
+
) {
|
|
24
|
+
cjk++;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return Math.ceil(cjk * 0.6 + (text.length - cjk) * 0.25);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatTokenSpeed(tokens: number, elapsedMs: number): string | null {
|
|
31
|
+
if (tokens < 1 || elapsedMs < 400) return null;
|
|
32
|
+
const perSec = tokens / (elapsedMs / 1000);
|
|
33
|
+
if (perSec < 0.5) return null;
|
|
34
|
+
const shown = perSec >= 10 ? Math.round(perSec) : Math.round(perSec * 10) / 10;
|
|
35
|
+
return `${shown} tok/s`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function lastToolsAreRunning(process: TurnBlock[]): boolean {
|
|
39
|
+
const last = process[process.length - 1];
|
|
40
|
+
if (last?.type !== 'tools' || last.actions.length === 0) return false;
|
|
41
|
+
return last.actions.some((action) => action.result == null);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function activeToolNames(process: TurnBlock[]): string[] {
|
|
45
|
+
for (let i = process.length - 1; i >= 0; i -= 1) {
|
|
46
|
+
const block = process[i];
|
|
47
|
+
if (block.type !== 'tools' || block.actions.length === 0) continue;
|
|
48
|
+
const running = block.actions.filter((action) => action.result == null);
|
|
49
|
+
const source = running.length > 0 ? running : block.actions;
|
|
50
|
+
return source.slice(-2).map((action) => action.tool);
|
|
51
|
+
}
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatElapsedPart(
|
|
56
|
+
isStreaming: boolean,
|
|
57
|
+
elapsedMs: number | undefined,
|
|
58
|
+
): string | null {
|
|
59
|
+
if (elapsedMs == null) return null;
|
|
60
|
+
if (!isStreaming && elapsedMs < 1000) return null;
|
|
61
|
+
return formatElapsedCompact(elapsedMs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function processStatusLabel(input: {
|
|
65
|
+
process: TurnBlock[];
|
|
66
|
+
isStreaming: boolean;
|
|
67
|
+
elapsedMs?: number;
|
|
68
|
+
reasoningElapsedMs?: number;
|
|
69
|
+
}): string {
|
|
70
|
+
const { process, isStreaming, elapsedMs, reasoningElapsedMs } = input;
|
|
71
|
+
const elapsed = formatElapsedPart(isStreaming, elapsedMs);
|
|
72
|
+
|
|
73
|
+
if (isStreaming) {
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
const last = process[process.length - 1];
|
|
76
|
+
if (lastToolsAreRunning(process)) {
|
|
77
|
+
parts.push('调用工具中');
|
|
78
|
+
const names = activeToolNames(process);
|
|
79
|
+
if (names.length > 0) parts.push(names.join('、'));
|
|
80
|
+
} else {
|
|
81
|
+
parts.push('思考中');
|
|
82
|
+
if (last?.type === 'reasoning') {
|
|
83
|
+
const speed = formatTokenSpeed(
|
|
84
|
+
estimateTextTokens(last.content),
|
|
85
|
+
reasoningElapsedMs ?? elapsedMs ?? 0,
|
|
86
|
+
);
|
|
87
|
+
if (speed) parts.push(speed);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (elapsed) parts.push(elapsed);
|
|
91
|
+
return parts.join(' · ');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const parts: string[] = [];
|
|
95
|
+
const tools = countProcessTools(process);
|
|
96
|
+
if (tools > 0) parts.push(`${tools} 次工具调用`);
|
|
97
|
+
if (processHasReasoning(process)) parts.push('已思考');
|
|
98
|
+
if (elapsed) parts.push(`工作了 ${elapsed}`);
|
|
99
|
+
if (parts.length === 0) return '执行过程';
|
|
100
|
+
return parts.join(' · ');
|
|
101
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { persistShowThinkingContent, useShowThinkingContent } from '@/lib/show-thinking-content';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 界面偏好:思考过程正文默认折叠,状态行仍显示思考中 / 工具名 / 速度 / 时间。
|
|
5
|
+
* 立即写入 localStorage,不走设置页顶部的模型保存按钮。
|
|
6
|
+
*/
|
|
7
|
+
export function AppearanceSettingsPanel() {
|
|
8
|
+
const showThinkingContent = useShowThinkingContent();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
className="space-y-3 rounded-agent-md border border-agent-border bg-agent-card p-4"
|
|
13
|
+
data-testid="appearance-settings-panel"
|
|
14
|
+
>
|
|
15
|
+
<div className="flex items-start justify-between gap-4">
|
|
16
|
+
<div className="min-w-0">
|
|
17
|
+
<p className="text-sm font-medium text-agent-foreground">显示思考内容</p>
|
|
18
|
+
<p className="mt-1 text-xs leading-relaxed text-agent-muted-foreground">
|
|
19
|
+
关闭后流式中只露出 7 行思考,结束后自动折叠。状态行仍显示思考中、工具名、速度和用时。
|
|
20
|
+
</p>
|
|
21
|
+
</div>
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
role="switch"
|
|
25
|
+
aria-checked={showThinkingContent}
|
|
26
|
+
aria-label="显示思考内容"
|
|
27
|
+
data-testid="show-thinking-content-toggle"
|
|
28
|
+
onClick={() => persistShowThinkingContent(!showThinkingContent)}
|
|
29
|
+
className={[
|
|
30
|
+
'relative mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full px-0.5 transition-colors',
|
|
31
|
+
showThinkingContent ? 'bg-agent-foreground' : 'bg-agent-foreground/25',
|
|
32
|
+
].join(' ')}
|
|
33
|
+
>
|
|
34
|
+
<span
|
|
35
|
+
className={[
|
|
36
|
+
'h-4 w-4 rounded-full bg-agent-canvas shadow-sm transition-transform',
|
|
37
|
+
showThinkingContent ? 'translate-x-4' : 'translate-x-0',
|
|
38
|
+
].join(' ')}
|
|
39
|
+
/>
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 聊天里「显示思考内容」的本机偏好。
|
|
3
|
+
*
|
|
4
|
+
* 默认关闭:思考 / 工具过程折叠,只留状态行。打开后流式中自动展开。
|
|
5
|
+
* 存在 localStorage,设置页开关立即生效,不走后端。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
export const SHOW_THINKING_CONTENT_STORAGE_KEY = 'agent-show-thinking-content';
|
|
11
|
+
export const SHOW_THINKING_CONTENT_EVENT = 'agent-show-thinking-content-changed';
|
|
12
|
+
|
|
13
|
+
export function readShowThinkingContent(): boolean {
|
|
14
|
+
if (typeof localStorage === 'undefined') return false;
|
|
15
|
+
return localStorage.getItem(SHOW_THINKING_CONTENT_STORAGE_KEY) === '1';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function persistShowThinkingContent(show: boolean): void {
|
|
19
|
+
if (typeof localStorage !== 'undefined') {
|
|
20
|
+
localStorage.setItem(SHOW_THINKING_CONTENT_STORAGE_KEY, show ? '1' : '0');
|
|
21
|
+
}
|
|
22
|
+
if (typeof window === 'undefined') return;
|
|
23
|
+
window.dispatchEvent(
|
|
24
|
+
new CustomEvent<boolean>(SHOW_THINKING_CONTENT_EVENT, { detail: show }),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 当前页与跨标签的偏好变更都会刷新。 */
|
|
29
|
+
export function useShowThinkingContent(): boolean {
|
|
30
|
+
const [show, setShow] = useState(readShowThinkingContent);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const onCustom = (event: Event) => {
|
|
33
|
+
const detail = (event as CustomEvent<boolean>).detail;
|
|
34
|
+
setShow(typeof detail === 'boolean' ? detail : readShowThinkingContent());
|
|
35
|
+
};
|
|
36
|
+
const onStorage = (event: StorageEvent) => {
|
|
37
|
+
if (event.key === SHOW_THINKING_CONTENT_STORAGE_KEY) {
|
|
38
|
+
setShow(event.newValue === '1');
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
window.addEventListener(SHOW_THINKING_CONTENT_EVENT, onCustom);
|
|
42
|
+
window.addEventListener('storage', onStorage);
|
|
43
|
+
return () => {
|
|
44
|
+
window.removeEventListener(SHOW_THINKING_CONTENT_EVENT, onCustom);
|
|
45
|
+
window.removeEventListener('storage', onStorage);
|
|
46
|
+
};
|
|
47
|
+
}, []);
|
|
48
|
+
return show;
|
|
49
|
+
}
|
package/src/main.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StrictMode, lazy, Suspense, type ReactNode } from 'react';
|
|
2
|
-
import { createRoot } from 'react-dom/client';
|
|
2
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
3
3
|
import { createHashRouter, RouterProvider } from 'react-router-dom';
|
|
4
4
|
import { AppShell } from './AppShell';
|
|
5
5
|
import { AgentLayout } from './layouts/AgentLayout';
|
|
@@ -7,6 +7,7 @@ import { AgentPage } from './pages/AgentPage';
|
|
|
7
7
|
import { SettingsPage } from './pages/SettingsPage';
|
|
8
8
|
import { getPackRoutes } from './packs/registry';
|
|
9
9
|
import { getBrandLogoUrl, BRAND_NAME } from './brand';
|
|
10
|
+
import { getAppShellGate } from './auth/gate';
|
|
10
11
|
import './styles/index.css';
|
|
11
12
|
|
|
12
13
|
// 浏览器 dev mock 的安装在 bootstrap() 开头(动态 import + DEV 门)。
|
|
@@ -40,19 +41,7 @@ const ChatPanelPreviewPage = import.meta.env.DEV
|
|
|
40
41
|
* (products/<id>/web/main.tsx)先静态调用包的 register*Renderer(),
|
|
41
42
|
* 再调本函数——路由注册发生在路由表创建之前。
|
|
42
43
|
*/
|
|
43
|
-
|
|
44
|
-
if (import.meta.env.DEV) {
|
|
45
|
-
// 动态 import:prod 构建里 Rollup 把 DEV 分支连同 mock(含其 fixtures)
|
|
46
|
-
// 整体树摇掉,浏览器 dev mock 数据不进产物。
|
|
47
|
-
const { installBrowserDevElectronMock } = await import('./lib/browser-dev-electron-mock');
|
|
48
|
-
installBrowserDevElectronMock();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// 标题与 favicon 在 bootstrap 时设置——此刻产品入口已完成包注册
|
|
52
|
-
// (品牌 logo 由包经 setBrandLogoUrl 注入,见 brand.ts)。
|
|
53
|
-
document.title = BRAND_NAME;
|
|
54
|
-
document.querySelector<HTMLLinkElement>('link[rel="icon"]')?.setAttribute('href', getBrandLogoUrl());
|
|
55
|
-
|
|
44
|
+
function renderApplication(root: Root): void {
|
|
56
45
|
const router = createHashRouter([
|
|
57
46
|
{
|
|
58
47
|
path: '/',
|
|
@@ -90,14 +79,47 @@ export async function bootstrap(): Promise<void> {
|
|
|
90
79
|
},
|
|
91
80
|
]);
|
|
92
81
|
|
|
82
|
+
root.render(
|
|
83
|
+
<StrictMode>
|
|
84
|
+
<RouterProvider router={router} />
|
|
85
|
+
</StrictMode>,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function bootstrap(): Promise<void> {
|
|
90
|
+
if (import.meta.env.DEV) {
|
|
91
|
+
// 动态 import:prod 构建里 Rollup 把 DEV 分支连同 mock(含其 fixtures)
|
|
92
|
+
// 整体树摇掉,浏览器 dev mock 数据不进产物。
|
|
93
|
+
const { installBrowserDevElectronMock } = await import('./lib/browser-dev-electron-mock');
|
|
94
|
+
installBrowserDevElectronMock();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 标题与 favicon 在 bootstrap 时设置——此刻产品入口已完成包注册
|
|
98
|
+
// (品牌 logo 由包经 setBrandLogoUrl 注入,见 brand.ts)。
|
|
99
|
+
document.title = BRAND_NAME;
|
|
100
|
+
document.querySelector<HTMLLinkElement>('link[rel="icon"]')?.setAttribute('href', getBrandLogoUrl());
|
|
101
|
+
|
|
93
102
|
const rootEl = document.getElementById('root');
|
|
94
103
|
if (!rootEl) {
|
|
95
104
|
throw new Error('Missing #root element in index.html');
|
|
96
105
|
}
|
|
106
|
+
const root = createRoot(rootEl);
|
|
107
|
+
const gate = getAppShellGate();
|
|
108
|
+
if (gate?.enabled()) {
|
|
109
|
+
let authenticated = false;
|
|
110
|
+
const onAuthenticated = () => {
|
|
111
|
+
if (authenticated) return;
|
|
112
|
+
authenticated = true;
|
|
113
|
+
renderApplication(root);
|
|
114
|
+
};
|
|
115
|
+
const Gate = gate.Component;
|
|
116
|
+
root.render(
|
|
117
|
+
<StrictMode>
|
|
118
|
+
<Gate onAuthenticated={onAuthenticated} />
|
|
119
|
+
</StrictMode>,
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
97
123
|
|
|
98
|
-
|
|
99
|
-
<StrictMode>
|
|
100
|
-
<RouterProvider router={router} />
|
|
101
|
-
</StrictMode>,
|
|
102
|
-
);
|
|
124
|
+
renderApplication(root);
|
|
103
125
|
}
|
package/src/pages/AgentPage.tsx
CHANGED
|
@@ -188,8 +188,23 @@ export function ChatPanelPreviewPage() {
|
|
|
188
188
|
currentTurnActions: MOCK_ACTIONS.slice(0, 2),
|
|
189
189
|
timelineByMsgId: {} as Record<string, TurnBlock[]>,
|
|
190
190
|
currentTurnTimeline: [
|
|
191
|
-
{
|
|
192
|
-
|
|
191
|
+
{
|
|
192
|
+
type: 'reasoning',
|
|
193
|
+
content: [
|
|
194
|
+
'先检查服务是否还能连上。',
|
|
195
|
+
'如果端口通了,再跑本目录的测试套件。',
|
|
196
|
+
'失败的话只读失败文件,不要整仓扫一遍。',
|
|
197
|
+
'断言颜色对不上时优先看 AssistantMessage 的默认色。',
|
|
198
|
+
'改完再复跑一次,确认没有带出新的失败。',
|
|
199
|
+
'工具调用保持最少:连通性检查、跑测试、必要时读文件。',
|
|
200
|
+
'最后用一两句说清楚结果和下一步。',
|
|
201
|
+
'如果还在思考,后面的句子会被 7 行窗口裁掉。',
|
|
202
|
+
].join('\n'),
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
type: 'tools',
|
|
206
|
+
actions: [MOCK_ACTIONS[0], { ...MOCK_ACTIONS[1], result: undefined }],
|
|
207
|
+
},
|
|
193
208
|
],
|
|
194
209
|
currentTurnStartedAtMs: Date.now() - 12_000,
|
|
195
210
|
currentRound: 2,
|
|
@@ -242,7 +257,7 @@ export function ChatPanelPreviewPage() {
|
|
|
242
257
|
onCancel={() => {}}
|
|
243
258
|
className="flex-1"
|
|
244
259
|
header={
|
|
245
|
-
<ChatHeader chat={MOCK_CHAT}
|
|
260
|
+
<ChatHeader chat={MOCK_CHAT} />
|
|
246
261
|
}
|
|
247
262
|
inputPlaceholder="预览模式 — 输入不会真的发送…"
|
|
248
263
|
agents={[MOCK_AGENT]}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { useRef, useState } from 'react';
|
|
2
|
-
import { LuActivity, LuBlocks, LuBot, LuChartBar, LuNetwork, LuPlug, LuSearch, LuSettings, LuShieldCheck } from 'react-icons/lu';
|
|
2
|
+
import { LuActivity, LuBlocks, LuBot, LuChartBar, LuMonitor, LuNetwork, LuPlug, LuSearch, LuSettings, LuShieldCheck } from 'react-icons/lu';
|
|
3
3
|
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
|
4
4
|
import { isElectron } from '@/lib/electron-bridge';
|
|
5
5
|
import { AgentsSettingsPanel } from '@/components/settings/AgentsSettingsPanel';
|
|
6
|
+
import { AppearanceSettingsPanel } from '@/components/settings/AppearanceSettingsPanel';
|
|
6
7
|
import { DiagnoseSettingsPanel } from '@/components/settings/DiagnoseSettingsPanel';
|
|
7
8
|
import { InsightsSettingsPanel } from '@/components/settings/InsightsSettingsPanel';
|
|
8
9
|
import {
|
|
@@ -32,7 +33,7 @@ function resolveSection(raw: string | null): SettingsSection {
|
|
|
32
33
|
* - `skills` 侧栏「Skill 设置」
|
|
33
34
|
* - `mcp` 侧栏「MCP 设置」
|
|
34
35
|
* - `agents` 侧栏「智能体管理」
|
|
35
|
-
* - 缺省/`general`
|
|
36
|
+
* - 缺省/`general` 侧栏底「设置」(界面 / 模型 / 搜索 / 用量 / 安全 / 洞察 / 遥测)
|
|
36
37
|
*
|
|
37
38
|
* Panel 数据自管理(挂载即拉取),页面本身不持有后端状态。
|
|
38
39
|
*/
|
|
@@ -111,6 +112,14 @@ export function SettingsPage() {
|
|
|
111
112
|
|
|
112
113
|
{section === 'general' && (
|
|
113
114
|
<>
|
|
115
|
+
<section className="space-y-3" data-testid="settings-section-appearance">
|
|
116
|
+
<h2 className="flex items-center gap-1.5 text-sm font-semibold text-agent-foreground">
|
|
117
|
+
<LuMonitor className="h-4 w-4 text-agent-muted-foreground" />
|
|
118
|
+
界面
|
|
119
|
+
</h2>
|
|
120
|
+
<AppearanceSettingsPanel />
|
|
121
|
+
</section>
|
|
122
|
+
|
|
114
123
|
<section className="space-y-3" data-testid="settings-section-llm">
|
|
115
124
|
<h2 className="flex items-center gap-1.5 text-sm font-semibold text-agent-foreground">
|
|
116
125
|
<LuSettings className="h-4 w-4 text-agent-muted-foreground" />
|