@steerable/agent-shell-web 0.6.72 → 0.6.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.72",
3
+ "version": "0.6.73",
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",
@@ -45,8 +45,8 @@
45
45
  "react-router-dom": "^6.30.0",
46
46
  "rehype-raw": "^7.0.0",
47
47
  "remark-gfm": "^4.0.1",
48
- "@steerable/agent-protocol": "0.6.72",
49
- "@steerable/agent-ui": "0.6.72"
48
+ "@steerable/agent-protocol": "0.6.73",
49
+ "@steerable/agent-ui": "0.6.73"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tailwindcss/vite": "^4.3.0",
@@ -59,7 +59,7 @@
59
59
  "typescript": "^5.8.3",
60
60
  "vite": "^5.4.21",
61
61
  "vitest": "^3.2.4",
62
- "@steerable/pack-sdk": "0.6.72"
62
+ "@steerable/pack-sdk": "0.6.73"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
@@ -442,7 +442,7 @@ export function AssistantMessage({
442
442
  回合收尾才有数据,流式期间天然为空。 */}
443
443
  {!isStreaming && turnFiles && turnFiles.length > 0 && (
444
444
  <div className="mt-2.5 mb-1">
445
- <TurnFilesCard files={turnFiles} executedActions={executedActions} />
445
+ <TurnFilesCard files={turnFiles} executedActions={executedActions} chatId={chatId} />
446
446
  </div>
447
447
  )}
448
448
  <div className="mt-2 flex items-center gap-2 text-[11px] text-agent-muted-foreground/80">
@@ -3,6 +3,7 @@ import {
3
3
  LuCheck,
4
4
  LuChevronDown,
5
5
  LuCopy,
6
+ LuDownload,
6
7
  LuExternalLink,
7
8
  LuFile,
8
9
  LuFileDiff,
@@ -14,7 +15,8 @@ import {
14
15
  LuPresentation,
15
16
  LuUndo2,
16
17
  } from 'react-icons/lu';
17
- import { openLocalPath } from '@/lib/local-api';
18
+ import { getLocalFileDownload, openLocalPath } from '@/lib/local-api';
19
+ import { saveBase64AsFile } from '@/lib/download';
18
20
  import { hostToolChrome } from '@/lib/host-tools';
19
21
  import { t } from '@/i18n';
20
22
  import {
@@ -46,11 +48,15 @@ export interface ExecutedActionLike {
46
48
  interface TurnFilesCardProps {
47
49
  files: TurnFile[];
48
50
  executedActions?: ExecutedActionLike[];
51
+ /** 当前会话:浏览器模式下载回合产物必须带 chatId 交给后端做归属校验。 */
52
+ chatId?: string | null;
49
53
  }
50
54
 
51
- export function TurnFilesCard({ files, executedActions }: TurnFilesCardProps) {
55
+ export function TurnFilesCard({ files, executedActions, chatId }: TurnFilesCardProps) {
52
56
  const [openingPath, setOpeningPath] = useState<string | null>(null);
53
57
  const [openError, setOpenError] = useState<{ path: string; message: string } | null>(null);
58
+ const [downloadingPath, setDownloadingPath] = useState<string | null>(null);
59
+ const [downloadError, setDownloadError] = useState<{ path: string; message: string } | null>(null);
54
60
  const allowOpenPath = hostToolChrome('local-fs');
55
61
 
56
62
  const { deliverables, intermediates, totalAdditions, totalDeletions } = groupTurnFiles(
@@ -80,6 +86,27 @@ export function TurnFilesCard({ files, executedActions }: TurnFilesCardProps) {
80
86
  }
81
87
  };
82
88
 
89
+ const handleDownload = async (filePath: string) => {
90
+ if (!chatId) {
91
+ setDownloadError({ path: filePath, message: t('Chat is not ready for download') });
92
+ return;
93
+ }
94
+ if (downloadingPath) return;
95
+ setDownloadingPath(filePath);
96
+ setDownloadError(null);
97
+ try {
98
+ const file = await getLocalFileDownload(chatId, filePath);
99
+ saveBase64AsFile(file);
100
+ } catch (err) {
101
+ setDownloadError({
102
+ path: filePath,
103
+ message: err instanceof Error ? err.message : String(err),
104
+ });
105
+ } finally {
106
+ setDownloadingPath(null);
107
+ }
108
+ };
109
+
83
110
  const handleReveal = async (filePath: string) => {
84
111
  const { dir } = splitTurnFilePath(filePath);
85
112
  const targetDir = dir || filePath;
@@ -96,10 +123,14 @@ export function TurnFilesCard({ files, executedActions }: TurnFilesCardProps) {
96
123
  key={file.path}
97
124
  file={file}
98
125
  onOpen={() => void handleOpen(file.path)}
126
+ onDownload={() => void handleDownload(file.path)}
99
127
  onReveal={() => void handleReveal(file.path)}
100
128
  busy={openingPath === file.path}
129
+ downloading={downloadingPath === file.path}
101
130
  error={openError?.path === file.path ? openError.message : null}
131
+ downloadError={downloadError?.path === file.path ? downloadError.message : null}
102
132
  allowOpen={allowOpenPath}
133
+ allowDownload={Boolean(chatId)}
103
134
  />
104
135
  ))}
105
136
  </div>
@@ -128,19 +159,27 @@ export function TurnFilesCard({ files, executedActions }: TurnFilesCardProps) {
128
159
  interface DeliverableCardProps {
129
160
  file: TurnFile;
130
161
  onOpen: () => void;
162
+ onDownload: () => void;
131
163
  onReveal: () => void;
132
164
  busy: boolean;
165
+ downloading: boolean;
133
166
  error: string | null;
167
+ downloadError: string | null;
134
168
  allowOpen: boolean;
169
+ allowDownload: boolean;
135
170
  }
136
171
 
137
172
  function DeliverableCard({
138
173
  file,
139
174
  onOpen,
175
+ onDownload,
140
176
  onReveal,
141
177
  busy,
178
+ downloading,
142
179
  error,
180
+ downloadError,
143
181
  allowOpen,
182
+ allowDownload,
144
183
  }: DeliverableCardProps) {
145
184
  const [dropdownOpen, setDropdownOpen] = useState(false);
146
185
  const [copied, setCopied] = useState(false);
@@ -209,8 +248,23 @@ function DeliverableCard({
209
248
  </div>
210
249
  </div>
211
250
 
212
- {/* 右侧:Open in ⌵ 动作按钮 */}
213
- <div className="relative shrink-0" ref={menuRef}>
251
+ {/* 右侧:下载 + Open in ⌵ 动作按钮 */}
252
+ <div className="relative flex shrink-0 items-center gap-1.5" ref={menuRef}>
253
+ <button
254
+ type="button"
255
+ onClick={onDownload}
256
+ disabled={!allowDownload || downloading}
257
+ title={file.path}
258
+ className="flex h-[26px] items-center gap-1 rounded-agent-md border border-agent-border bg-agent-canvas px-2 text-xs font-medium text-agent-foreground shadow-xs transition-colors hover:border-agent-border/80 hover:bg-agent-muted/50 disabled:opacity-50"
259
+ >
260
+ {downloading ? (
261
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin text-agent-muted-foreground" />
262
+ ) : (
263
+ <LuDownload className="h-3.5 w-3.5 text-agent-muted-foreground" />
264
+ )}
265
+ <span>{t('Download')}</span>
266
+ </button>
267
+
214
268
  <div className="flex items-center rounded-agent-md border border-agent-border bg-agent-canvas shadow-xs hover:border-agent-border/80 transition-colors">
215
269
  <button
216
270
  type="button"
@@ -285,6 +339,11 @@ function DeliverableCard({
285
339
  {t('Could not open: {error}', { error })}
286
340
  </div>
287
341
  )}
342
+ {downloadError && (
343
+ <div className="mt-2 px-1 text-[11px] text-agent-destructive" role="status">
344
+ {t('Could not download: {error}', { error: downloadError })}
345
+ </div>
346
+ )}
288
347
  </div>
289
348
  );
290
349
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 渲染层下载工具。宿主已经把文件读成 base64,这里只负责解码、落 Blob,
3
+ * 触发浏览器的 <a download> —— 桌面端点“用系统应用打开”没问题,但团队版
4
+ * / 浏览器模式没有本地文件系统入口,下载必须走这条纯浏览器路径。
5
+ */
6
+
7
+ export interface Base64Download {
8
+ fileName: string;
9
+ base64: string;
10
+ /** 缺省 `application/octet-stream`,浏览器仍按文件名保存。 */
11
+ mime?: string;
12
+ }
13
+
14
+ /** base64 → 字节。分块解码,避免大文件一次性 `charCodeAt` 阻塞太久。 */
15
+ export function base64ToBytes(base64: string) {
16
+ const binary = atob(base64);
17
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length));
18
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
19
+ return bytes;
20
+ }
21
+
22
+ /** 把 base64 内容落成浏览器下载。调用方负责文件大小与授权校验。 */
23
+ export function saveBase64AsFile(input: Base64Download): void {
24
+ const bytes = base64ToBytes(input.base64);
25
+ const blob = new Blob([bytes], { type: input.mime || 'application/octet-stream' });
26
+ const url = URL.createObjectURL(blob);
27
+ const anchor = document.createElement('a');
28
+ anchor.href = url;
29
+ anchor.download = input.fileName || 'download';
30
+ document.body.appendChild(anchor);
31
+ anchor.click();
32
+ anchor.remove();
33
+ // Safari 需要等一拍再释放 ObjectURL,否则可能取消尚未开始的下载。
34
+ setTimeout(() => URL.revokeObjectURL(url), 0);
35
+ }
@@ -416,6 +416,25 @@ export async function openLocalPath(targetPath: string) {
416
416
  });
417
417
  }
418
418
 
419
+ /** 回合产物下载内容:base64 字节 + 浏览器下载所需文件名与 MIME。 */
420
+ export interface LocalFileDownload {
421
+ fileName: string;
422
+ mime: string;
423
+ size: number;
424
+ base64: string;
425
+ }
426
+
427
+ /**
428
+ * 读取本会话回合产物文件,交给浏览器落盘下载。CS(Tauri)与 BS(团队版)
429
+ * 两种宿主都经 localBackend 通道,浏览器模式不再依赖“系统应用打开”。
430
+ */
431
+ export async function getLocalFileDownload(chatId: string, targetPath: string) {
432
+ return bridge().localBackend.request<LocalFileDownload>({
433
+ method: 'GET',
434
+ path: `/api/v2/local/download?chatId=${encodeURIComponent(chatId)}&path=${encodeURIComponent(targetPath)}`,
435
+ });
436
+ }
437
+
419
438
  /** 后端确认存在的路径:`candidate` 是正文里的原字面量,`path` 是落地绝对路径。 */
420
439
  export interface ResolvedLocalPath {
421
440
  candidate: string;