@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.
@@ -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
- export async function bootstrap(): Promise<void> {
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
- createRoot(rootEl).render(
99
- <StrictMode>
100
- <RouterProvider router={router} />
101
- </StrictMode>,
102
- );
124
+ renderApplication(root);
103
125
  }
@@ -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
- setInitialMessages(chronological(response.messages));
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} agent={MOCK_AGENT} />
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;