@steerable/agent-shell-web 0.6.20 → 0.6.22
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/ApprovalModal.tsx +21 -41
- package/src/components/chat/ApprovalPromptProvider.tsx +98 -0
- package/src/components/chat/AskUserPromptProvider.tsx +99 -0
- package/src/components/chat/AssistantMessage.tsx +25 -2
- package/src/components/chat/ChatInput.tsx +116 -13
- package/src/components/chat/FilePathCode.tsx +65 -0
- package/src/components/chat/LocalChatPanel.tsx +19 -0
- package/src/components/chat/Markdown.tsx +54 -25
- package/src/components/chat/MessageList.tsx +66 -18
- package/src/components/chat/ModelPicker.tsx +5 -3
- package/src/components/chat/SuggestedReplies.tsx +34 -0
- package/src/components/chat/TurnFilesCard.tsx +113 -0
- package/src/components/chat/TurnProcessGroup.tsx +7 -2
- package/src/components/chat/path-mentions.ts +148 -0
- package/src/components/chat/suggested-replies-model.ts +40 -0
- package/src/components/chat/turn-files.ts +50 -0
- package/src/fixtures/browser-dev-data.json +1 -1
- package/src/layouts/AgentLayout.tsx +15 -26
- package/src/lib/browser-dev-electron-mock.ts +15 -0
- package/src/lib/electron-bridge.ts +13 -7
- package/src/lib/http-bridge.ts +8 -2
- package/src/lib/local-api.ts +31 -0
- package/src/main.tsx +41 -19
- package/src/pages/AgentPage.tsx +126 -2
- package/src/pages/ChatPanelPreviewPage.tsx +5 -1
- package/src/components/chat/AskUserModalHost.tsx +0 -61
package/src/lib/local-api.ts
CHANGED
|
@@ -332,6 +332,37 @@ export async function cancelChatTurn(chatId: string) {
|
|
|
332
332
|
});
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* 用系统默认应用打开本地绝对路径(回合产物列表的点击行为)。
|
|
337
|
+
* 成功时后端返回 `{ success: true }`;打开失败返回 `{ success: false, error }`。
|
|
338
|
+
*/
|
|
339
|
+
export async function openLocalPath(targetPath: string) {
|
|
340
|
+
return bridge().localBackend.request<{ success: boolean; error?: string }>({
|
|
341
|
+
method: 'POST',
|
|
342
|
+
path: '/api/v2/local/open-path',
|
|
343
|
+
body: { path: targetPath },
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 后端确认存在的路径:`candidate` 是正文里的原字面量,`path` 是落地绝对路径。 */
|
|
348
|
+
export interface ResolvedLocalPath {
|
|
349
|
+
candidate: string;
|
|
350
|
+
path: string;
|
|
351
|
+
isDirectory: boolean;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 批量确认正文里提到的路径是否真实存在(用于决定行内代码要不要变成可点击)。
|
|
356
|
+
* 相对路径由后端按对话绑定的项目根解析,无项目时按 home;不存在的候选不回。
|
|
357
|
+
*/
|
|
358
|
+
export async function resolveLocalPaths(candidates: string[], chatId?: string | null) {
|
|
359
|
+
return bridge().localBackend.request<{ resolved: ResolvedLocalPath[] }>({
|
|
360
|
+
method: 'POST',
|
|
361
|
+
path: '/api/v2/local/resolve-paths',
|
|
362
|
+
body: { candidates, ...(chatId ? { chatId } : {}) },
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
335
366
|
/* ---------------- Projects(项目模式) ---------------- */
|
|
336
367
|
|
|
337
368
|
export interface LocalProject {
|
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
|
@@ -26,7 +26,12 @@ import type { ExecutedAction } from "@/components/chat/ExecutedActionsCard";
|
|
|
26
26
|
import type { ChildInfo } from "@/components/chat/OrchestrationChildrenCard";
|
|
27
27
|
import { foldOrchestrationChildEvents } from "@/components/chat/orchestration-children-model";
|
|
28
28
|
import { parseTurnBlocks, type TurnBlock } from "@/components/chat/turn-timeline";
|
|
29
|
+
import { parseTurnFiles, type TurnFile } from "@/components/chat/turn-files";
|
|
29
30
|
import { readPersistedDurationMs } from "@/components/chat/elapsed";
|
|
31
|
+
import {
|
|
32
|
+
extractLatestSuggestedReplies,
|
|
33
|
+
type SuggestedRepliesState,
|
|
34
|
+
} from "@/components/chat/suggested-replies-model";
|
|
30
35
|
import { useChatTasks } from "@/components/chat/useChatTasks";
|
|
31
36
|
import { LuListChecks, LuArrowRight } from "react-icons/lu";
|
|
32
37
|
import { LocalLlmSettingsModal } from "@/components/LocalLlmSettingsModal";
|
|
@@ -136,6 +141,26 @@ function extractPersistedDurations(
|
|
|
136
141
|
return seeded;
|
|
137
142
|
}
|
|
138
143
|
|
|
144
|
+
function extractPersistedTurnFiles(
|
|
145
|
+
messages: ChatMessageWithMetadata[] | undefined,
|
|
146
|
+
): Record<string, TurnFile[]> {
|
|
147
|
+
const seeded: Record<string, TurnFile[]> = {};
|
|
148
|
+
if (!messages || messages.length === 0) return seeded;
|
|
149
|
+
for (const message of messages) {
|
|
150
|
+
if (message.role !== "assistant" || !message.messageMetadata) continue;
|
|
151
|
+
try {
|
|
152
|
+
const metadata = JSON.parse(message.messageMetadata) as {
|
|
153
|
+
turnFiles?: unknown;
|
|
154
|
+
};
|
|
155
|
+
const files = parseTurnFiles(metadata.turnFiles);
|
|
156
|
+
if (files) seeded[message.id] = files;
|
|
157
|
+
} catch {
|
|
158
|
+
// Ignore malformed legacy metadata.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return seeded;
|
|
162
|
+
}
|
|
163
|
+
|
|
139
164
|
/**
|
|
140
165
|
* Routing layer for `/agent/:chatId`.
|
|
141
166
|
*
|
|
@@ -205,6 +230,11 @@ function AgentChatLoader({
|
|
|
205
230
|
const [initialDurations, setInitialDurations] = useState<
|
|
206
231
|
Record<string, number>
|
|
207
232
|
>({});
|
|
233
|
+
const [initialTurnFiles, setInitialTurnFiles] = useState<
|
|
234
|
+
Record<string, TurnFile[]>
|
|
235
|
+
>({});
|
|
236
|
+
const [initialSuggestedReplies, setInitialSuggestedReplies] =
|
|
237
|
+
useState<SuggestedRepliesState | null>(null);
|
|
208
238
|
// W7-1: 后端在 messages 响应里下发 interrupted(上一轮崩溃/强杀中断)。
|
|
209
239
|
const [initialInterrupted, setInitialInterrupted] = useState(false);
|
|
210
240
|
// 运行中回合的实时快照:切走再切回时用它在历史消息之上叠出「正在运行」
|
|
@@ -234,10 +264,17 @@ function AgentChatLoader({
|
|
|
234
264
|
getChatLiveStream(chatId),
|
|
235
265
|
]);
|
|
236
266
|
if (cancelled) return;
|
|
237
|
-
|
|
267
|
+
const ordered = chronological(response.messages);
|
|
268
|
+
setInitialMessages(ordered);
|
|
238
269
|
setInitialExecutedActions(extractPersistedActions(response.messages));
|
|
239
270
|
setInitialTimelines(extractPersistedTimelines(response.messages));
|
|
240
271
|
setInitialDurations(extractPersistedDurations(response.messages));
|
|
272
|
+
setInitialTurnFiles(extractPersistedTurnFiles(response.messages));
|
|
273
|
+
setInitialSuggestedReplies(
|
|
274
|
+
extractLatestSuggestedReplies(
|
|
275
|
+
ordered as ChatMessageWithMetadata[],
|
|
276
|
+
),
|
|
277
|
+
);
|
|
241
278
|
setInitialInterrupted(response.interrupted === true);
|
|
242
279
|
setInitialLiveStream(live ?? { active: false });
|
|
243
280
|
} catch (err) {
|
|
@@ -247,6 +284,8 @@ function AgentChatLoader({
|
|
|
247
284
|
setInitialExecutedActions({});
|
|
248
285
|
setInitialTimelines({});
|
|
249
286
|
setInitialDurations({});
|
|
287
|
+
setInitialTurnFiles({});
|
|
288
|
+
setInitialSuggestedReplies(null);
|
|
250
289
|
setInitialInterrupted(false);
|
|
251
290
|
setInitialLiveStream({ active: false });
|
|
252
291
|
}
|
|
@@ -271,6 +310,8 @@ function AgentChatLoader({
|
|
|
271
310
|
initialExecutedActions={initialExecutedActions}
|
|
272
311
|
initialTimelines={initialTimelines}
|
|
273
312
|
initialDurations={initialDurations}
|
|
313
|
+
initialTurnFiles={initialTurnFiles}
|
|
314
|
+
initialSuggestedReplies={initialSuggestedReplies}
|
|
274
315
|
initialInterrupted={initialInterrupted}
|
|
275
316
|
initialLiveStream={initialLiveStream}
|
|
276
317
|
hydrationError={hydrationError}
|
|
@@ -285,6 +326,8 @@ interface AgentChatViewProps {
|
|
|
285
326
|
initialExecutedActions: Record<string, ExecutedAction[]>;
|
|
286
327
|
initialTimelines: Record<string, TurnBlock[]>;
|
|
287
328
|
initialDurations: Record<string, number>;
|
|
329
|
+
initialTurnFiles: Record<string, TurnFile[]>;
|
|
330
|
+
initialSuggestedReplies: SuggestedRepliesState | null;
|
|
288
331
|
/** W7-1: 打开会话时上一轮处于中断态(崩溃/强杀,无完成记录)。 */
|
|
289
332
|
initialInterrupted: boolean;
|
|
290
333
|
/** 打开会话时的运行中回合快照(切回恢复运行状态用)。 */
|
|
@@ -299,6 +342,8 @@ function AgentChatView({
|
|
|
299
342
|
initialExecutedActions,
|
|
300
343
|
initialTimelines,
|
|
301
344
|
initialDurations,
|
|
345
|
+
initialTurnFiles,
|
|
346
|
+
initialSuggestedReplies,
|
|
302
347
|
initialInterrupted,
|
|
303
348
|
initialLiveStream,
|
|
304
349
|
hydrationError,
|
|
@@ -389,6 +434,16 @@ function AgentChatView({
|
|
|
389
434
|
const [durationByMessageId, setDurationByMessageId] = useState<
|
|
390
435
|
Record<string, number>
|
|
391
436
|
>(initialDurations);
|
|
437
|
+
// 回合产物文件列表:与 executedActions 同款 reconcile 模式——live 回合
|
|
438
|
+
// 先挂 pendingTurnFilesRef,`message_id` 事件到达时归档到落库 id 上;
|
|
439
|
+
// 历史回合由 initialTurnFiles 从 messageMetadata.turnFiles 水合。
|
|
440
|
+
const [turnFilesByMessageId, setTurnFilesByMessageId] = useState<
|
|
441
|
+
Record<string, TurnFile[]>
|
|
442
|
+
>(initialTurnFiles);
|
|
443
|
+
// 当轮产物:turn_files 事件到达(流尾声)到 message_id 归档之间,以及
|
|
444
|
+
// 归档后占位 id 消息仍在屏上的这段时间,都靠它渲染尾部消息的文件列表。
|
|
445
|
+
const [currentTurnFiles, setCurrentTurnFiles] = useState<TurnFile[]>([]);
|
|
446
|
+
const pendingTurnFilesRef = useRef<TurnFile[]>([]);
|
|
392
447
|
const turnStartedAtRef = useRef<number | null>(null);
|
|
393
448
|
const [currentTurnStartedAtMs, setCurrentTurnStartedAtMs] = useState<
|
|
394
449
|
number | undefined
|
|
@@ -425,6 +480,11 @@ function AgentChatView({
|
|
|
425
480
|
const [planReady, setPlanReady] = useState(false);
|
|
426
481
|
const planTurnRef = useRef(false);
|
|
427
482
|
const wasStreamingRef = useRef(false);
|
|
483
|
+
const [suggestedReplies, setSuggestedReplies] =
|
|
484
|
+
useState<SuggestedRepliesState | null>(initialSuggestedReplies);
|
|
485
|
+
const [tailAssistantDbId, setTailAssistantDbId] = useState<string | null>(
|
|
486
|
+
initialSuggestedReplies?.messageId ?? null,
|
|
487
|
+
);
|
|
428
488
|
|
|
429
489
|
const handleModeChange = useCallback((next: ChatMode) => {
|
|
430
490
|
setMode(next);
|
|
@@ -459,6 +519,16 @@ function AgentChatView({
|
|
|
459
519
|
}
|
|
460
520
|
return;
|
|
461
521
|
}
|
|
522
|
+
if (ev.event === "turn_files") {
|
|
523
|
+
// 回合收尾时后端发一次(在 message_id 之前);先挂 pending,
|
|
524
|
+
// message_id 到达时随其他队列一起归档。
|
|
525
|
+
const files = parseTurnFiles(ev.payload?.files);
|
|
526
|
+
if (files) {
|
|
527
|
+
pendingTurnFilesRef.current = files;
|
|
528
|
+
setCurrentTurnFiles(files);
|
|
529
|
+
}
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
462
532
|
if (ev.event === "round_end") {
|
|
463
533
|
// chat-transport surfaces `completion executing` as round_end. Bumping
|
|
464
534
|
// the counter here is safe even if the round emits no tools — the
|
|
@@ -537,6 +607,15 @@ function AgentChatView({
|
|
|
537
607
|
}));
|
|
538
608
|
}
|
|
539
609
|
pendingChildrenRef.current = [];
|
|
610
|
+
const queuedTurnFiles = pendingTurnFilesRef.current;
|
|
611
|
+
if (queuedTurnFiles.length > 0) {
|
|
612
|
+
setTurnFilesByMessageId((prev) => ({
|
|
613
|
+
...prev,
|
|
614
|
+
[messageId]: queuedTurnFiles,
|
|
615
|
+
}));
|
|
616
|
+
}
|
|
617
|
+
pendingTurnFilesRef.current = [];
|
|
618
|
+
setTailAssistantDbId(messageId);
|
|
540
619
|
}
|
|
541
620
|
// AI 标题更新走的是后台 IPC 广播(chat-title-updated)而不是 SSE——
|
|
542
621
|
// 见 AgentLayout 里的订阅。SSE 通道在 [DONE] 之后就不再监听了,title-gen
|
|
@@ -684,6 +763,8 @@ function AgentChatView({
|
|
|
684
763
|
setCurrentTurnTimeline([]);
|
|
685
764
|
pendingChildrenRef.current = [];
|
|
686
765
|
setCurrentTurnChildren([]);
|
|
766
|
+
pendingTurnFilesRef.current = [];
|
|
767
|
+
setCurrentTurnFiles([]);
|
|
687
768
|
setCurrentRound(1);
|
|
688
769
|
const started = Date.now();
|
|
689
770
|
turnStartedAtRef.current = started;
|
|
@@ -694,6 +775,8 @@ function AgentChatView({
|
|
|
694
775
|
setPlanReady(false);
|
|
695
776
|
// 手动发起新一轮即取代中断态——用户已经继续往前走了。
|
|
696
777
|
setInterrupted(false);
|
|
778
|
+
setSuggestedReplies(null);
|
|
779
|
+
setTailAssistantDbId(null);
|
|
697
780
|
// 模型选择器的每轮覆盖随 metadata 下发:model 覆盖设置里的全局
|
|
698
781
|
// 模型;reasoningEffort 由 sidecar 按目录严格校验(不支持即报错,
|
|
699
782
|
// 不静默丢弃)。都为 null 时保持设置/预制默认。
|
|
@@ -711,6 +794,26 @@ function AgentChatView({
|
|
|
711
794
|
[sendUserMessage, modelOverride, effortOverride, execPolicy],
|
|
712
795
|
);
|
|
713
796
|
|
|
797
|
+
const handleSelectSuggestion = useCallback(
|
|
798
|
+
(text: string) => {
|
|
799
|
+
void handleSubmit({ content: text });
|
|
800
|
+
},
|
|
801
|
+
[handleSubmit],
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
useEffect(() => {
|
|
805
|
+
const bridge = getElectronBridge();
|
|
806
|
+
if (!bridge?.onSuggestedReplies) return;
|
|
807
|
+
return bridge.onSuggestedReplies((payload) => {
|
|
808
|
+
if (payload.chatId !== chatId) return;
|
|
809
|
+
if (!Array.isArray(payload.suggestions) || payload.suggestions.length === 0) return;
|
|
810
|
+
setSuggestedReplies({
|
|
811
|
+
messageId: payload.messageId,
|
|
812
|
+
suggestions: payload.suggestions,
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
}, [chatId]);
|
|
816
|
+
|
|
714
817
|
// 包槽位 fallback 发送通道(如文档包的单页修改在 sidecar 未就绪时把
|
|
715
818
|
// 后端拼好的指令退回主聊天):AgentLayout 持有发送器 ref,这里挂载时
|
|
716
819
|
// 注册、卸载时注销,包面板经 sendChatMessage 调到当前注册的 handleSubmit。
|
|
@@ -732,6 +835,8 @@ function AgentChatView({
|
|
|
732
835
|
setCurrentTurnTimeline([]);
|
|
733
836
|
pendingChildrenRef.current = [];
|
|
734
837
|
setCurrentTurnChildren([]);
|
|
838
|
+
pendingTurnFilesRef.current = [];
|
|
839
|
+
setCurrentTurnFiles([]);
|
|
735
840
|
setCurrentRound(1);
|
|
736
841
|
const started = Date.now();
|
|
737
842
|
turnStartedAtRef.current = started;
|
|
@@ -863,6 +968,22 @@ function AgentChatView({
|
|
|
863
968
|
)
|
|
864
969
|
: currentTurnChildren;
|
|
865
970
|
|
|
971
|
+
let lastAssistantId: string | undefined;
|
|
972
|
+
for (let i = effectiveMessages.length - 1; i >= 0; i -= 1) {
|
|
973
|
+
if (effectiveMessages[i].role === "assistant") {
|
|
974
|
+
lastAssistantId = effectiveMessages[i].id;
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
const visibleSuggestedReplies =
|
|
979
|
+
!effectiveIsStreaming &&
|
|
980
|
+
!interrupted &&
|
|
981
|
+
suggestedReplies &&
|
|
982
|
+
(suggestedReplies.messageId === lastAssistantId ||
|
|
983
|
+
suggestedReplies.messageId === tailAssistantDbId)
|
|
984
|
+
? suggestedReplies.suggestions
|
|
985
|
+
: undefined;
|
|
986
|
+
|
|
866
987
|
return (
|
|
867
988
|
<div className="flex h-full w-full flex-col">
|
|
868
989
|
{hydrationError && (
|
|
@@ -887,7 +1008,6 @@ function AgentChatView({
|
|
|
887
1008
|
header={
|
|
888
1009
|
<ChatHeader
|
|
889
1010
|
chat={chat}
|
|
890
|
-
agent={agent}
|
|
891
1011
|
onBranchSwitched={isElectron() ? onBranchTick : undefined}
|
|
892
1012
|
onInspectTask={ctx.inspectTask}
|
|
893
1013
|
tasks={tasks}
|
|
@@ -915,6 +1035,8 @@ function AgentChatView({
|
|
|
915
1035
|
currentTurnTimeline={effectiveCurrentTurnTimeline}
|
|
916
1036
|
currentTurnStartedAtMs={currentTurnStartedAtMs}
|
|
917
1037
|
durationByMessageId={durationByMessageId}
|
|
1038
|
+
turnFilesByMessageId={turnFilesByMessageId}
|
|
1039
|
+
currentTurnFiles={currentTurnFiles}
|
|
918
1040
|
currentTurnChildren={effectiveCurrentTurnChildren}
|
|
919
1041
|
orchestrationChildrenByMessageId={orchestrationChildrenByMessageId}
|
|
920
1042
|
currentRound={currentRound}
|
|
@@ -925,6 +1047,8 @@ function AgentChatView({
|
|
|
925
1047
|
onInspectTask={handleInspectTask}
|
|
926
1048
|
onDismissFinishedTask={dismissFinished}
|
|
927
1049
|
onShare={isElectron() ? handleShare : undefined}
|
|
1050
|
+
suggestedReplies={visibleSuggestedReplies}
|
|
1051
|
+
onSelectSuggestion={handleSelectSuggestion}
|
|
928
1052
|
onOpenSettings={
|
|
929
1053
|
isElectron() ? () => setLlmSettingsOpen(true) : undefined
|
|
930
1054
|
}
|
|
@@ -148,6 +148,7 @@ export function ChatPanelPreviewPage() {
|
|
|
148
148
|
currentTurnStartedAtMs?: number;
|
|
149
149
|
durationByMessageId?: Record<string, number>;
|
|
150
150
|
currentRound: number;
|
|
151
|
+
suggestedReplies?: string[];
|
|
151
152
|
}
|
|
152
153
|
const config = useMemo<SceneConfig>(() => {
|
|
153
154
|
switch (scene) {
|
|
@@ -203,6 +204,7 @@ export function ChatPanelPreviewPage() {
|
|
|
203
204
|
timelineByMsgId: { m2: MOCK_TIMELINE },
|
|
204
205
|
durationByMessageId: { m2: 83_000 },
|
|
205
206
|
currentRound: 1,
|
|
207
|
+
suggestedReplies: ['修一下失败的断言颜色', '把测试再跑一遍', '解释这次失败的原因'],
|
|
206
208
|
};
|
|
207
209
|
}
|
|
208
210
|
}, [scene]);
|
|
@@ -240,7 +242,7 @@ export function ChatPanelPreviewPage() {
|
|
|
240
242
|
onCancel={() => {}}
|
|
241
243
|
className="flex-1"
|
|
242
244
|
header={
|
|
243
|
-
<ChatHeader chat={MOCK_CHAT}
|
|
245
|
+
<ChatHeader chat={MOCK_CHAT} />
|
|
244
246
|
}
|
|
245
247
|
inputPlaceholder="预览模式 — 输入不会真的发送…"
|
|
246
248
|
agents={[MOCK_AGENT]}
|
|
@@ -252,6 +254,8 @@ export function ChatPanelPreviewPage() {
|
|
|
252
254
|
currentTurnStartedAtMs={config.currentTurnStartedAtMs}
|
|
253
255
|
durationByMessageId={config.durationByMessageId}
|
|
254
256
|
currentRound={config.currentRound}
|
|
257
|
+
suggestedReplies={config.suggestedReplies}
|
|
258
|
+
onSelectSuggestion={(text) => console.log('[preview] suggestion:', text)}
|
|
255
259
|
onOpenSettings={() =>
|
|
256
260
|
console.log('[preview] open llm settings (noop in harness)')
|
|
257
261
|
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
2
|
-
import { getElectronBridge, type AskUserPromptRequest } from '@/lib/electron-bridge';
|
|
3
|
-
import { AskUserQuestionMenu } from './AskUserQuestionMenu';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* AskUserModalHost — W8 结构化提问的桌面 UI 半侧。
|
|
7
|
-
*
|
|
8
|
-
* 挂载一次(AgentLayout),订阅主进程/BS 服务器广播的 `ask-user:request`,
|
|
9
|
-
* 以模态渲染自研的 `<AskUserQuestionMenu />`(分步菜单选择框:模型建议
|
|
10
|
-
* 作为选项、底部追加「其他 / 自定义」输入)。答案经 `ask-user:answer`
|
|
11
|
-
* (Electron IPC / BS HTTP)回到主进程,再应答 sidecar 的反向调用。
|
|
12
|
-
*
|
|
13
|
-
* 队列为 FIFO:ask_user 标记了 concurrency_safe=False,sidecar 串行等待
|
|
14
|
-
* 每个应答。「交给我决定」应答空映射——模型收到「用户未作答」后自行
|
|
15
|
-
* 推进(对齐 HostAskUserHandler 的 fail-open 语义)。
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
export function AskUserModalHost() {
|
|
19
|
-
const [queue, setQueue] = useState<AskUserPromptRequest[]>([]);
|
|
20
|
-
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
const bridge = getElectronBridge();
|
|
23
|
-
if (!bridge?.askUser) return;
|
|
24
|
-
return bridge.askUser.onRequest((request) => {
|
|
25
|
-
setQueue((prev) => [...prev, request]);
|
|
26
|
-
});
|
|
27
|
-
}, []);
|
|
28
|
-
|
|
29
|
-
const current = queue[0] ?? null;
|
|
30
|
-
if (!current) return null;
|
|
31
|
-
|
|
32
|
-
const answer = (answers: Record<string, string | string[]>) => {
|
|
33
|
-
const bridge = getElectronBridge();
|
|
34
|
-
setQueue((prev) => prev.slice(1));
|
|
35
|
-
void bridge?.askUser?.answer({ requestId: current.requestId, answers });
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
return (
|
|
39
|
-
<div
|
|
40
|
-
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
|
41
|
-
role="dialog"
|
|
42
|
-
aria-modal="true"
|
|
43
|
-
aria-label="Agent 提问"
|
|
44
|
-
>
|
|
45
|
-
<div className="max-h-[85vh] w-full max-w-lg overflow-auto rounded-agent-lg">
|
|
46
|
-
<AskUserQuestionMenu
|
|
47
|
-
key={current.requestId}
|
|
48
|
-
intro={current.intro}
|
|
49
|
-
questions={current.questions}
|
|
50
|
-
onSubmit={(answers) => answer(answers)}
|
|
51
|
-
onAutoContinue={() => answer({})}
|
|
52
|
-
bottomHint={
|
|
53
|
-
queue.length > 1 ? `还有 ${queue.length - 1} 组问题待回答` : undefined
|
|
54
|
-
}
|
|
55
|
-
/>
|
|
56
|
-
</div>
|
|
57
|
-
</div>
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export default AskUserModalHost;
|