@steerable/agent-shell-web 0.6.40 → 0.6.42

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 CHANGED
@@ -14,9 +14,14 @@ export default createProductViteConfig({ productDir, flavor });
14
14
 
15
15
  The product's Vite build compiles this package's `src/` into the product's own
16
16
  bundle; there is no prebuilt `dist/` here. See
17
- [`docs/spec/architecture.md`](https://github.com/pathlyapp/opensteerable/blob/develop/docs/spec/architecture.md)
17
+ [`docs/spec/architecture.md`](https://github.com/pathlyapp/steerable/blob/develop/docs/spec/architecture.md)
18
18
  for the tier model.
19
19
 
20
+ Renderer code reads native and backend capabilities through `HostBridge`.
21
+ Browser mode uses the HTTP/SSE adapter; Tauri mode layers native commands and
22
+ menu events over that same adapter. `ElectronBridge` and
23
+ `getElectronBridge()` remain deprecated compatibility aliases.
24
+
20
25
  Products that require authentication can register an `AppShellGate` from
21
26
  `@steerable/agent-shell-web/auth/gate` before calling `bootstrap()`. An
22
27
  enabled gate renders before the application router and calls
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.40",
3
+ "version": "0.6.42",
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",
7
7
  "homepage": "https://steerableframework.com/",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/pathlyapp/opensteerable.git",
10
+ "url": "git+https://github.com/pathlyapp/steerable.git",
11
11
  "directory": "packages/agent-shell/web"
12
12
  },
13
13
  "bugs": {
14
- "url": "https://github.com/pathlyapp/opensteerable/issues"
14
+ "url": "https://github.com/pathlyapp/steerable/issues"
15
15
  },
16
16
  "exports": {
17
17
  "./auth/gate": "./src/auth/gate.ts",
@@ -32,10 +32,12 @@
32
32
  "provenance": true
33
33
  },
34
34
  "dependencies": {
35
+ "@tauri-apps/api": "^2.11.1",
35
36
  "@xterm/addon-fit": "^0.11.0",
36
37
  "@xterm/addon-web-links": "^0.12.0",
37
38
  "@xterm/xterm": "^6.0.0",
38
39
  "framer-motion": "^12.38.0",
40
+ "html2canvas": "^1.4.1",
39
41
  "react": "^18.3.1",
40
42
  "react-dom": "^18.3.1",
41
43
  "react-icons": "^5.5.0",
@@ -43,8 +45,8 @@
43
45
  "react-router-dom": "^6.30.0",
44
46
  "rehype-raw": "^7.0.0",
45
47
  "remark-gfm": "^4.0.1",
46
- "@steerable/agent-protocol": "0.6.40",
47
- "@steerable/agent-ui": "0.6.40"
48
+ "@steerable/agent-ui": "0.6.42",
49
+ "@steerable/agent-protocol": "0.6.42"
48
50
  },
49
51
  "devDependencies": {
50
52
  "@tailwindcss/vite": "^4.3.0",
@@ -57,7 +59,7 @@
57
59
  "typescript": "^5.8.3",
58
60
  "vite": "^5.4.21",
59
61
  "vitest": "^3.2.4",
60
- "@steerable/pack-sdk": "0.6.40"
62
+ "@steerable/pack-sdk": "0.6.42"
61
63
  },
62
64
  "scripts": {
63
65
  "gen": "echo agent-shell-web",
@@ -13,16 +13,53 @@ import { getElectronBridge, type AskUserPromptRequest } from '@/lib/electron-bri
13
13
  type AnswerValue = string | string[];
14
14
 
15
15
  interface AskUserPromptContextValue {
16
+ queue: AskUserPromptRequest[];
17
+ answer: (requestId: string, answers: Record<string, AnswerValue>) => void;
18
+ }
19
+
20
+ interface AskUserPromptView {
16
21
  current: AskUserPromptRequest | null;
17
22
  pendingCount: number;
18
23
  answer: (answers: Record<string, AnswerValue>) => void;
19
24
  }
20
25
 
26
+ /**
27
+ * Chat id from `#/agent/:chatId` (hash router) or `/agent/:chatId`.
28
+ * The running server build drops `chatId` on the ask-user payload.
29
+ */
30
+ function chatIdFromLocation(location: { pathname: string; hash: string }): string | null {
31
+ const hashed = location.hash.startsWith('#') ? location.hash.slice(1) : '';
32
+ const raw = hashed || location.pathname;
33
+ const match = raw.match(/\/agent\/([^/?#]+)/);
34
+ if (!match?.[1]) return null;
35
+ try {
36
+ return decodeURIComponent(match[1]);
37
+ } catch {
38
+ return match[1];
39
+ }
40
+ }
41
+
42
+ function bindPromptToActiveChat(request: AskUserPromptRequest): AskUserPromptRequest {
43
+ if (request.chatId) return request;
44
+ const chatId = chatIdFromLocation(window.location);
45
+ if (!chatId) return request;
46
+ return { ...request, chatId };
47
+ }
48
+
49
+ /** A prompt with a chat id belongs only to that chat. A prompt without one stays visible so an older request is still answerable. */
50
+ function promptVisibleInChat(
51
+ request: AskUserPromptRequest,
52
+ chatId: string | null | undefined,
53
+ ): boolean {
54
+ if (!request.chatId) return !chatId;
55
+ return request.chatId === chatId;
56
+ }
57
+
21
58
  const AskUserPromptContext = createContext<AskUserPromptContextValue | null>(null);
22
59
 
23
60
  /**
24
- * Keeps structured user prompts alive across chat navigation while allowing
25
- * the active composer to present the current prompt.
61
+ * Keeps structured user prompts alive across chat navigation. Each composer
62
+ * presents only the prompt that belongs to its chat.
26
63
  */
27
64
  export function AskUserPromptProvider({ children }: { children: ReactNode }) {
28
65
  const [queue, setQueue] = useState<AskUserPromptRequest[]>([]);
@@ -38,10 +75,12 @@ export function AskUserPromptProvider({ children }: { children: ReactNode }) {
38
75
  ) => {
39
76
  setQueue((previous) => {
40
77
  const known = new Set(previous.map(({ requestId }) => requestId));
41
- const additions = requests.filter(
42
- ({ requestId }) =>
43
- !known.has(requestId) && !answeredRequestIds.current.has(requestId),
44
- );
78
+ const additions = requests
79
+ .filter(
80
+ ({ requestId }) =>
81
+ !known.has(requestId) && !answeredRequestIds.current.has(requestId),
82
+ )
83
+ .map(bindPromptToActiveChat);
45
84
  if (additions.length === 0) return previous;
46
85
  return placement === 'prepend'
47
86
  ? [...additions, ...previous]
@@ -65,25 +104,22 @@ export function AskUserPromptProvider({ children }: { children: ReactNode }) {
65
104
  };
66
105
  }, []);
67
106
 
68
- const current = queue[0] ?? null;
69
107
  const answer = useCallback(
70
- (answers: Record<string, AnswerValue>) => {
71
- if (!current) return;
108
+ (requestId: string, answers: Record<string, AnswerValue>) => {
72
109
  const bridge = getElectronBridge();
73
- answeredRequestIds.current.add(current.requestId);
74
- setQueue((previous) => previous.slice(1));
75
- void bridge?.askUser?.answer({ requestId: current.requestId, answers });
110
+ answeredRequestIds.current.add(requestId);
111
+ setQueue((previous) => previous.filter((request) => request.requestId !== requestId));
112
+ void bridge?.askUser?.answer({ requestId, answers });
76
113
  },
77
- [current],
114
+ [],
78
115
  );
79
116
 
80
117
  const value = useMemo(
81
118
  () => ({
82
- current,
83
- pendingCount: Math.max(0, queue.length - 1),
119
+ queue,
84
120
  answer,
85
121
  }),
86
- [answer, current, queue.length],
122
+ [answer, queue],
87
123
  );
88
124
 
89
125
  return (
@@ -93,7 +129,20 @@ export function AskUserPromptProvider({ children }: { children: ReactNode }) {
93
129
  );
94
130
  }
95
131
 
96
- /** Returns the structured prompt currently assigned to the chat composer. */
97
- export function useAskUserPrompt(): AskUserPromptContextValue | null {
98
- return useContext(AskUserPromptContext);
132
+ /** Returns the structured prompt for this composer’s chat, if one is waiting. */
133
+ export function useAskUserPrompt(chatId?: string | null): AskUserPromptView | null {
134
+ const context = useContext(AskUserPromptContext);
135
+ return useMemo(() => {
136
+ if (!context) return null;
137
+ const mine = context.queue.filter((request) => promptVisibleInChat(request, chatId));
138
+ const current = mine[0] ?? null;
139
+ return {
140
+ current,
141
+ pendingCount: Math.max(0, mine.length - 1),
142
+ answer: (answers) => {
143
+ if (!current) return;
144
+ context.answer(current.requestId, answers);
145
+ },
146
+ };
147
+ }, [chatId, context]);
99
148
  }
@@ -139,7 +139,7 @@ function Footer({
139
139
  className="inline-flex items-center gap-1 rounded-agent-md px-2.5 py-1.5 text-xs font-medium text-agent-muted-foreground transition hover:bg-agent-muted hover:text-agent-foreground"
140
140
  >
141
141
  <LuWand size={12} />
142
- 交给我决定
142
+ 交给AI决定
143
143
  </button>
144
144
  )}
145
145
  </div>
@@ -588,9 +588,11 @@ export function AskUserQuestionMenu({
588
588
  {bottomHint ? ` · ${bottomHint}` : ''}
589
589
  </p>
590
590
  </div>
591
- <span className="rounded-full border border-agent-border px-2 py-0.5 text-[10px] font-medium text-agent-muted-foreground">
592
- {current.type === 'password' ? '密码输入' : current.multiSelect ? '可多选' : '菜单选择'}
593
- </span>
591
+ {current.type === 'password' || current.multiSelect ? (
592
+ <span className="rounded-full border border-agent-border px-2 py-0.5 text-[10px] font-medium text-agent-muted-foreground">
593
+ {current.type === 'password' ? '密码输入' : '可多选'}
594
+ </span>
595
+ ) : null}
594
596
  </div>
595
597
 
596
598
  {current.type === 'select' ? (
@@ -162,6 +162,8 @@ export interface ChatInputProps {
162
162
  skills?: SkillItem[];
163
163
  /** MCP tools for slash command autocomplete and chip rendering. */
164
164
  mcpTools?: McpToolItem[];
165
+ /** Chat this composer belongs to. Ask-user cards for other chats stay hidden. */
166
+ chatId?: string | null;
165
167
  }
166
168
 
167
169
  export type MentionReference = {
@@ -677,6 +679,84 @@ function getMentionRanges(
677
679
  return ranges;
678
680
  }
679
681
 
682
+ /** 截图粘贴常见的空名 / `image.png`,同一秒再贴会按文件名被去重掉。 */
683
+ const GENERIC_CLIPBOARD_IMAGE_NAME =
684
+ /^(image|clipboard|pasted-image|blob)(\.[a-z0-9]+)?$/i;
685
+
686
+ const IMAGE_MIME_EXT: Record<string, string> = {
687
+ 'image/png': 'png',
688
+ 'image/jpeg': 'jpg',
689
+ 'image/gif': 'gif',
690
+ 'image/webp': 'webp',
691
+ 'image/bmp': 'bmp',
692
+ };
693
+
694
+ function clipboardFiles(data: DataTransfer | null): File[] {
695
+ if (!data) return [];
696
+ const fromItems: File[] = [];
697
+ for (const item of Array.from(data.items ?? [])) {
698
+ if (item.kind !== 'file') continue;
699
+ const file = item.getAsFile();
700
+ if (file) fromItems.push(file);
701
+ }
702
+ if (fromItems.length > 0) return fromItems;
703
+ return Array.from(data.files ?? []);
704
+ }
705
+
706
+ function namePastedImage(file: File, serial: number, now: Date): File {
707
+ if (!file.type.startsWith('image/')) return file;
708
+ if (file.name.trim() && !GENERIC_CLIPBOARD_IMAGE_NAME.test(file.name.trim())) return file;
709
+ const ext = IMAGE_MIME_EXT[file.type.toLowerCase()] ?? 'png';
710
+ const pad = (n: number) => String(n).padStart(2, '0');
711
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
712
+ return new File([file], `pasted-${stamp}-${serial}.${ext}`, {
713
+ type: file.type,
714
+ lastModified: file.lastModified,
715
+ });
716
+ }
717
+
718
+ function AttachmentChip({
719
+ file,
720
+ onRemove,
721
+ }: {
722
+ file: AttachmentFile;
723
+ onRemove: () => void;
724
+ }) {
725
+ const [previewUrl, setPreviewUrl] = useState<string | null>(null);
726
+ useEffect(() => {
727
+ const blob = file.file;
728
+ if (!blob?.type.startsWith('image/')) {
729
+ setPreviewUrl(null);
730
+ return;
731
+ }
732
+ const url = URL.createObjectURL(blob);
733
+ setPreviewUrl(url);
734
+ return () => URL.revokeObjectURL(url);
735
+ }, [file.file]);
736
+
737
+ return (
738
+ <div
739
+ className="group flex items-center gap-1.5 rounded border border-agent-border bg-agent-muted/60 px-2.5 py-1 text-xs text-agent-foreground transition-all duration-150 select-none hover:bg-agent-muted max-w-xs"
740
+ title={file.path || file.name}
741
+ >
742
+ {previewUrl ? (
743
+ <img src={previewUrl} alt="" className="h-8 w-8 shrink-0 rounded object-cover" />
744
+ ) : (
745
+ <LuFile className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
746
+ )}
747
+ <span className="max-w-[160px] truncate font-medium">{file.name}</span>
748
+ <button
749
+ type="button"
750
+ onClick={onRemove}
751
+ className="flex h-4 w-4 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/10 hover:text-agent-foreground"
752
+ aria-label={`移除文件 ${file.name}`}
753
+ >
754
+ <LuX className="h-3 w-3" />
755
+ </button>
756
+ </div>
757
+ );
758
+ }
759
+
680
760
  export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
681
761
  function ChatInput(
682
762
  {
@@ -709,10 +789,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
709
789
  onMentionReferencesChange,
710
790
  skills: propSkills,
711
791
  mcpTools: propMcpTools,
792
+ chatId,
712
793
  },
713
794
  ref,
714
795
  ) {
715
- const askUserPrompt = useAskUserPrompt();
796
+ const askUserPrompt = useAskUserPrompt(chatId);
716
797
  const approvalPrompt = useApprovalPrompt();
717
798
  const editorRef = useRef<HTMLDivElement>(null);
718
799
  const agentMenuRef = useRef<HTMLDivElement>(null);
@@ -741,6 +822,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
741
822
  const compositionEndAtRef = useRef<number>(0);
742
823
 
743
824
  const fileInputRef = useRef<HTMLInputElement>(null);
825
+ const pasteSerialRef = useRef(0);
744
826
  // 工具栏"空间不足时优先隐藏快捷键提示"的测量 refs,见下方 useLayoutEffect。
745
827
  const toolbarRowRef = useRef<HTMLDivElement>(null);
746
828
  const toolbarLeftRef = useRef<HTMLDivElement>(null);
@@ -1339,7 +1421,19 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1339
1421
 
1340
1422
  const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
1341
1423
  event.preventDefault();
1342
- replaceSelection(event.clipboardData.getData('text/plain'));
1424
+ const data = event.clipboardData;
1425
+ const pasted = !disabled && allowFileAttach ? clipboardFiles(data) : [];
1426
+ if (pasted.length > 0) {
1427
+ const now = new Date();
1428
+ addFiles(
1429
+ pasted.map((file) => {
1430
+ pasteSerialRef.current += 1;
1431
+ return toAttachment(namePastedImage(file, pasteSerialRef.current, now));
1432
+ }),
1433
+ );
1434
+ }
1435
+ const text = data?.getData('text/plain') ?? '';
1436
+ if (text) replaceSelection(text);
1343
1437
  };
1344
1438
 
1345
1439
  const handleEditorScroll = () => {
@@ -1791,24 +1885,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1791
1885
  {actualFiles.length > 0 && (
1792
1886
  <div className="flex flex-wrap gap-1.5 px-2.5 pb-1.5 pt-0.5">
1793
1887
  {actualFiles.map((file, idx) => (
1794
- <div
1795
- key={idx}
1796
- className="group flex items-center gap-1.5 rounded bg-agent-muted/60 hover:bg-agent-muted px-2.5 py-1 text-xs border border-agent-border text-agent-foreground select-none max-w-xs transition-all duration-150"
1797
- title={file.path}
1798
- >
1799
- <LuFile className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
1800
- <span className="truncate max-w-[160px] font-medium">
1801
- {file.name}
1802
- </span>
1803
- <button
1804
- type="button"
1805
- onClick={() => handleRemoveFile(idx)}
1806
- className="flex h-4 w-4 items-center justify-center rounded-full text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground transition-colors"
1807
- aria-label={`移除文件 ${file.name}`}
1808
- >
1809
- <LuX className="h-3 w-3" />
1810
- </button>
1811
- </div>
1888
+ <AttachmentChip
1889
+ key={`${file.path || file.name}-${idx}`}
1890
+ file={file}
1891
+ onRemove={() => handleRemoveFile(idx)}
1892
+ />
1812
1893
  ))}
1813
1894
  </div>
1814
1895
  )}
@@ -1842,7 +1923,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1842
1923
  onClick={handlePickFiles}
1843
1924
  disabled={disabled || isStreaming}
1844
1925
  className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
1845
- title="上传文件(可多选)"
1926
+ title="上传文件,或在输入框粘贴图片"
1846
1927
  aria-label="上传文件"
1847
1928
  data-testid="chat-attach"
1848
1929
  >
@@ -402,6 +402,7 @@ export function LocalChatPanel({
402
402
  if (attachmentError) setAttachmentError(null);
403
403
  }}
404
404
  onMentionReferencesChange={setMentionReferences}
405
+ chatId={chatId}
405
406
  />
406
407
  </>
407
408
  );
@@ -2,7 +2,7 @@
2
2
  * `useChatsAndAgents` — sidebar state (paginated chats + agent catalog + the
3
3
  * "agent picked for next new chat" selection) for the Agent shell.
4
4
  *
5
- * Wave 1 of the steerable-framework refactor moved the generic pagination /
5
+ * Wave 1 of the Steerable refactor moved the generic pagination /
6
6
  * agent-selection / title-patching machinery into `@steerable/agent-ui/state`
7
7
  * as `useChatList<TChat, TAgent>`. This file is now an Electron-flavoured
8
8
  * adapter:
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * 会话附件(文件上传)的 renderer 侧助手。
3
3
  *
4
- * ChatInput 只负责「收集」文件——通过隐藏的 `<input type="file" multiple>`
5
- * 或拖拽拿到 File,产出 `AttachmentFile`({name, path, file?})交给父组件。
4
+ * ChatInput 只负责「收集」文件——通过隐藏的 `<input type="file" multiple>`、
5
+ * 拖拽,或在输入框粘贴图片拿到 File,产出 `AttachmentFile`({name, path, file?})交给父组件。
6
6
  * 真正把文件落进会话空间(`<userDataDir>/attachments/<chatId>/`)由
7
7
  * `saveChatAttachments` 在提交时完成:
8
8
  * - Electron:File 对象带真实 `path`,走 `attachments:save` IPC 拷贝;
@@ -28,7 +28,7 @@
28
28
  * Framework `SSEEvent` (packages/agent-protocol/ts/src/generated/SSEEvent.ts)
29
29
  * is the canonical normalisation target. The legacy-envelope mapping lives in
30
30
  * `@steerable/agent-ui/state`'s `bridgeLegacySSE` — this file used to keep its
31
- * own copy; wave 1 of the steerable-framework refactor consolidated them.
31
+ * own copy; wave 1 of the Steerable refactor consolidated them.
32
32
  *
33
33
  * We do still keep one app-specific guard: if any content has already
34
34
  * streamed and the backend ends with `budget_exhausted`, we downgrade that
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Typed view of `window.electron`, the bridge that Electron's preload script
3
- * (src/preload.ts in the main project) exposes via `contextBridge`.
2
+ * Host-neutral bridge used by the renderer in desktop and browser modes.
4
3
  *
5
4
  * This file MUST stay a subset / aligned shape with `ElectronAPI` in
6
5
  * `../../../src/preload.ts`. We intentionally don't TypeScript-reference that
@@ -8,13 +7,14 @@
8
7
  * into the renderer's compile graph. Treat this as a contract surface and
9
8
  * extend it lazily as apps/web starts using new bridge methods.
10
9
  *
11
- * Outside Electron (e.g. plain `vite dev` in a browser tab without preload),
12
- * `window.electron` is undefined. Always read it via `getElectronBridge()`
13
- * and handle the `null` case so the SPA stays demoable in a normal browser.
10
+ * `window.electron` and `getElectronBridge()` remain compatibility aliases for
11
+ * existing products. New integrations should use `window.steerableHost` and
12
+ * `getHostBridge()`.
14
13
  */
15
14
 
16
15
  import type { SSEEvent } from '@steerable/agent-protocol';
17
16
  import { getHttpBridge } from './http-bridge';
17
+ import { getTauriBridge } from './tauri-bridge';
18
18
 
19
19
  export type LocalBackendRequestInput = {
20
20
  method: string;
@@ -61,6 +61,8 @@ export interface AskUserPromptRequest {
61
61
  requestId: string;
62
62
  intro: string;
63
63
  questions: Array<Record<string, unknown>>;
64
+ /** 发起提问的对话。缺省时卡片不绑定会话(旧请求)。 */
65
+ chatId?: string;
64
66
  }
65
67
 
66
68
  /**
@@ -86,7 +88,7 @@ export interface TerminalSpawnOptions {
86
88
  rows?: number;
87
89
  }
88
90
 
89
- export interface ElectronBridge {
91
+ export interface HostBridge {
90
92
  runtime: 'local';
91
93
  platform: NodeJS.Platform;
92
94
  local?: {
@@ -249,9 +251,14 @@ export interface ElectronBridge {
249
251
  // shell 桥接口保持产品中立(3.1)。
250
252
  }
251
253
 
254
+ /** @deprecated Use `HostBridge`. */
255
+ export type ElectronBridge = HostBridge;
256
+
252
257
  declare global {
253
258
  interface Window {
259
+ steerableHost?: HostBridge;
254
260
  electron?: ElectronBridge;
261
+ __TAURI_INTERNALS__?: object;
255
262
  /**
256
263
  * BS server(src/server/)托管 index.html 时注入的引导信息;存在即代表
257
264
  * 当前跑在浏览器-服务器模式,getElectronBridge() 会返回 HTTP 实现。
@@ -264,13 +271,30 @@ declare global {
264
271
  }
265
272
  }
266
273
 
267
- export function getElectronBridge(): ElectronBridge | null {
274
+ export function getHostBridge(): HostBridge | null {
268
275
  if (typeof window === 'undefined') return null;
276
+ if (window.steerableHost) return window.steerableHost;
269
277
  if (window.electron) return window.electron;
278
+ if (window.__TAURI_INTERNALS__ && window.__DEEPPATH_BS__) {
279
+ return getTauriBridge();
280
+ }
270
281
  if (window.__DEEPPATH_BS__) return getHttpBridge();
271
282
  return null;
272
283
  }
273
284
 
285
+ /** @deprecated Use `getHostBridge`. */
286
+ export const getElectronBridge = getHostBridge;
287
+
288
+ export function isDesktopHost(): boolean {
289
+ if (typeof window === 'undefined') return false;
290
+ return Boolean(
291
+ window.steerableHost ||
292
+ window.electron ||
293
+ (window.__TAURI_INTERNALS__ && window.__DEEPPATH_BS__),
294
+ );
295
+ }
296
+
297
+ /** @deprecated Use `isDesktopHost` when desktop-only behavior is required. */
274
298
  export function isElectron(): boolean {
275
- return getElectronBridge() !== null;
299
+ return getHostBridge() !== null;
276
300
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Canonical renderer host contract.
3
+ *
4
+ * The legacy `electron-bridge` module remains available so existing product
5
+ * sources can migrate without a synchronized breaking release.
6
+ */
7
+ export * from './electron-bridge';
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Tauri desktop adapter.
3
+ *
4
+ * Agent, terminal, approval, attachment, and pack traffic continues through
5
+ * the shared BS HTTP/SSE host. Only native desktop operations are overridden
6
+ * with narrow Tauri commands and events.
7
+ */
8
+ import { invoke } from '@tauri-apps/api/core';
9
+ import { listen, type UnlistenFn } from '@tauri-apps/api/event';
10
+ import html2canvas from 'html2canvas';
11
+ import type { HostBridge } from './electron-bridge';
12
+ import { createHttpBridge } from './http-bridge';
13
+
14
+ type VoidCallback = () => void;
15
+
16
+ function createMenuSubscription(event: string): {
17
+ on(callback: VoidCallback): void;
18
+ off(): void;
19
+ } {
20
+ const subscriptions = new Map<VoidCallback, Promise<UnlistenFn>>();
21
+ return {
22
+ on(callback) {
23
+ if (subscriptions.has(callback)) return;
24
+ const subscription = listen(event, () => callback());
25
+ subscriptions.set(callback, subscription);
26
+ },
27
+ off() {
28
+ const active = Array.from(subscriptions.values());
29
+ subscriptions.clear();
30
+ for (const subscription of active) {
31
+ void subscription.then((unlisten) => unlisten());
32
+ }
33
+ },
34
+ };
35
+ }
36
+
37
+ export function createTauriBridge(): HostBridge {
38
+ const bridge = createHttpBridge();
39
+ const newChatMenu = createMenuSubscription('menu:new-chat');
40
+ const terminalMenu = createMenuSubscription('menu:open-terminal');
41
+
42
+ return {
43
+ ...bridge,
44
+ local: {
45
+ ...bridge.local,
46
+ selectDirectory: (options) =>
47
+ invoke('host_select_directory', { options: options ?? {} }),
48
+ saveTextFile: (options) =>
49
+ invoke('host_save_text_file', { options }),
50
+ captureScreenshot: async (rect) => {
51
+ try {
52
+ const x = Math.max(0, (rect?.x ?? 0) + window.scrollX);
53
+ const y = Math.max(0, (rect?.y ?? 0) + window.scrollY);
54
+ const width = Math.max(1, rect?.width ?? window.innerWidth);
55
+ const height = Math.max(1, rect?.height ?? window.innerHeight);
56
+ const canvas = await html2canvas(document.body, {
57
+ x,
58
+ y,
59
+ width,
60
+ height,
61
+ scale: window.devicePixelRatio,
62
+ useCORS: true,
63
+ logging: false,
64
+ });
65
+ const pngBase64 = canvas.toDataURL('image/png').split(',', 2)[1];
66
+ if (!pngBase64) {
67
+ return { success: false, error: 'Failed to encode screenshot' };
68
+ }
69
+ return await invoke('host_capture_screenshot', {
70
+ image: {
71
+ pngBase64,
72
+ width: canvas.width,
73
+ height: canvas.height,
74
+ },
75
+ });
76
+ } catch (error) {
77
+ return {
78
+ success: false,
79
+ error: error instanceof Error ? error.message : String(error),
80
+ };
81
+ }
82
+ },
83
+ },
84
+ onMenuNewChat: newChatMenu.on,
85
+ offMenuNewChat: newChatMenu.off,
86
+ onMenuOpenTerminal: terminalMenu.on,
87
+ offMenuOpenTerminal: terminalMenu.off,
88
+ };
89
+ }
90
+
91
+ let singleton: HostBridge | null = null;
92
+
93
+ export function getTauriBridge(): HostBridge {
94
+ singleton ??= createTauriBridge();
95
+ return singleton;
96
+ }