@steerable/agent-shell-web 0.6.71 → 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.71",
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.71",
49
- "@steerable/agent-ui": "0.6.71"
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.71"
62
+ "@steerable/pack-sdk": "0.6.73"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
Binary file
Binary file
package/src/brand.ts CHANGED
@@ -3,10 +3,11 @@
3
3
  * (VITE_BRAND_* define:product.json brand 优先,其次激活包 pack.json
4
4
  * brand,3.1);logo 资产由激活包的 web 模块在注册时经
5
5
  * setBrandLogoUrl() 注入(资产静态 import 在包内,只进本产品 bundle)。
6
- * 未注入时一律回落到 shell 默认(Steerable Shell / 舵轮标)。
6
+ * 未注入时一律回落到 shell 默认(Steerable Shell:方形标 + 横版字标)。
7
7
  * 主进程侧对应物见 src/brand.ts。
8
8
  */
9
- import shellLogoUrl from '@/assets/logo-steerable.svg';
9
+ import shellMarkUrl from '@/assets/logo-steerable-mark.png';
10
+ import shellWordmarkUrl from '@/assets/logo-steerable-wordmark.png';
10
11
 
11
12
  /** 当前构建的 flavor(开放字符串,0.3g 起不再是二值联合)。 */
12
13
  export const APP_FLAVOR: string = import.meta.env.VITE_APP_FLAVOR ?? 'generic';
@@ -30,25 +31,35 @@ export const BRAND_TITLE: string =
30
31
  export const BRAND_HOME_HINT: string =
31
32
  import.meta.env.VITE_BRAND_HOME_HINT ?? 'Type a message to start a new chat.';
32
33
 
33
- /** shell 默认 logo(Steerable 舵轮标);包品牌 logo 由包 web 模块注册覆盖。 */
34
- let brandLogoUrl: string = shellLogoUrl;
34
+ /** shell 默认方形标,用于侧栏和 favicon。包只注册一枚 logo 时与字标相同。 */
35
+ let brandMarkUrl: string = shellMarkUrl;
36
+
37
+ /** shell 默认横版字标,用于空对话首屏。包品牌 logo 由包 web 模块注册覆盖。 */
38
+ let brandLogoUrl: string = shellWordmarkUrl;
35
39
 
36
40
  /**
37
41
  * 注册产品品牌 logo(包的 register*Renderer() 在 bootstrap 前调用)。
38
42
  * 重复注册抛错(组装期笔误,fail fast——与 node 侧 setProductBrand 同语义)。
43
+ * 包只提供一枚图,方形标和字标都用它。
39
44
  */
40
45
  export function setBrandLogoUrl(url: string): void {
41
- if (brandLogoUrl !== shellLogoUrl && brandLogoUrl !== url) {
46
+ if (brandLogoUrl !== shellWordmarkUrl && brandLogoUrl !== url) {
42
47
  throw new Error('[brand] logo already set');
43
48
  }
44
49
  brandLogoUrl = url;
50
+ brandMarkUrl = url;
45
51
  }
46
52
 
47
- /** 当前产品品牌 logo(bootstrap 之后读取——包的注册已完成)。 */
53
+ /** 当前产品品牌字标(bootstrap 之后读取——包的注册已完成)。 */
48
54
  export function getBrandLogoUrl(): string {
49
55
  return brandLogoUrl;
50
56
  }
51
57
 
58
+ /** 当前产品方形标。shell 默认是彩色 S;包覆盖后与字标相同。 */
59
+ export function getBrandMarkUrl(): string {
60
+ return brandMarkUrl;
61
+ }
62
+
52
63
  /** 首页 / 新对话未选手动专家时绑定的内置智能体(包品牌可覆盖)。 */
53
64
  export const DEFAULT_AGENT_ID: string =
54
65
  import.meta.env.VITE_DEFAULT_AGENT_ID ?? LOCAL_ASSISTANT_AGENT_ID;
@@ -1,4 +1,4 @@
1
- import { BRAND_NAME, BRAND_TITLE, getBrandLogoUrl } from '@/brand';
1
+ import { BRAND_NAME, BRAND_TITLE, getBrandMarkUrl } from '@/brand';
2
2
 
3
3
  export interface BrandLockupProps {
4
4
  className?: string;
@@ -24,7 +24,7 @@ export function BrandLockup({ className, onClick }: BrandLockupProps = {}) {
24
24
  .join(' ')}
25
25
  >
26
26
  <img
27
- src={getBrandLogoUrl()}
27
+ src={getBrandMarkUrl()}
28
28
  alt={title || BRAND_NAME}
29
29
  className={`${title ? 'h-5' : 'h-6'} w-auto max-w-[10rem] flex-shrink-0 select-none object-contain object-left`}
30
30
  draggable={false}
@@ -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">
@@ -2,7 +2,7 @@ import { BRAND_HOME_HINT, BRAND_NAME, getBrandLogoUrl } from '@/brand';
2
2
  import { t } from '@/i18n';
3
3
 
4
4
  /**
5
- * 新对话落地页与空会话首屏的品牌区:侧栏同一枚 logo,加一句
5
+ * 新对话落地页与空会话首屏的品牌区:横版字标,加一句
6
6
  * 产品可配的副文案(brand.homeHint)。
7
7
  */
8
8
  export function EmptyChatHero() {
@@ -13,7 +13,7 @@ export function EmptyChatHero() {
13
13
  <img
14
14
  src={getBrandLogoUrl()}
15
15
  alt={BRAND_NAME}
16
- className="mx-auto h-12 w-auto max-w-[12rem] select-none object-contain"
16
+ className="mx-auto h-16 w-auto max-w-[18rem] select-none object-contain"
17
17
  draggable={false}
18
18
  />
19
19
  </h1>
@@ -83,17 +83,21 @@ function TaskOutcomeCard({
83
83
 
84
84
  <div className="min-w-0 flex-1">
85
85
  <div
86
- className={
86
+ data-task-outcome-title=""
87
+ className={`line-clamp-2 break-words ${
87
88
  failed
88
89
  ? 'text-red-700 dark:text-red-300'
89
90
  : 'text-emerald-700 dark:text-emerald-300'
90
- }
91
+ }`}
91
92
  >
92
93
  {failed ? t('Background task failed: ') : t('Background task completed: ')}
93
94
  <span className="font-medium">{task.task}</span>
94
95
  </div>
95
96
  {detail && (
96
- <div className="mt-1 line-clamp-3 whitespace-pre-wrap text-agent-muted-foreground">
97
+ <div
98
+ data-task-outcome-detail=""
99
+ className="mt-1 line-clamp-2 break-words text-agent-muted-foreground"
100
+ >
97
101
  {detail}
98
102
  </div>
99
103
  )}
@@ -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;
package/src/main.tsx CHANGED
@@ -7,7 +7,7 @@ import { AgentLayout } from './layouts/AgentLayout';
7
7
  import { AgentPage } from './pages/AgentPage';
8
8
  import { SettingsPage } from './pages/SettingsPage';
9
9
  import { getPackRoutes } from './packs/registry';
10
- import { getBrandLogoUrl, BRAND_NAME } from './brand';
10
+ import { getBrandMarkUrl, BRAND_NAME } from './brand';
11
11
  import { getAppShellGate } from './auth/gate';
12
12
  import { installHostPaste } from './lib/host-paste';
13
13
  import './styles/index.css';
@@ -107,7 +107,11 @@ export async function bootstrap(): Promise<void> {
107
107
  document.documentElement.lang = getLocale() === 'zh' ? 'zh-CN' : getLocale();
108
108
  window.steerableHost?.setLocale?.(getLocale());
109
109
  document.title = BRAND_NAME;
110
- document.querySelector<HTMLLinkElement>('link[rel="icon"]')?.setAttribute('href', getBrandLogoUrl());
110
+ const icon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
111
+ if (icon) {
112
+ icon.type = 'image/png';
113
+ icon.href = getBrandMarkUrl();
114
+ }
111
115
  installHostPaste();
112
116
 
113
117
  const rootEl = document.getElementById('root');
@@ -1,14 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
2
- <circle cx="24" cy="24" r="17" stroke="#5c6bc0" stroke-width="3.5"/>
3
- <circle cx="24" cy="24" r="5" fill="#5c6bc0"/>
4
- <g stroke="#5c6bc0" stroke-width="3.5" stroke-linecap="round">
5
- <line x1="24" y1="2.5" x2="24" y2="12"/>
6
- <line x1="24" y1="36" x2="24" y2="45.5"/>
7
- <line x1="2.5" y1="24" x2="12" y2="24"/>
8
- <line x1="36" y1="24" x2="45.5" y2="24"/>
9
- <line x1="8.8" y1="8.8" x2="15.5" y2="15.5"/>
10
- <line x1="32.5" y1="32.5" x2="39.2" y2="39.2"/>
11
- <line x1="39.2" y1="8.8" x2="32.5" y2="15.5"/>
12
- <line x1="15.5" y1="32.5" x2="8.8" y2="39.2"/>
13
- </g>
14
- </svg>
@@ -1,14 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
2
- <circle cx="24" cy="24" r="17" stroke="#5c6bc0" stroke-width="3.5"/>
3
- <circle cx="24" cy="24" r="5" fill="#5c6bc0"/>
4
- <g stroke="#5c6bc0" stroke-width="3.5" stroke-linecap="round">
5
- <line x1="24" y1="2.5" x2="24" y2="12"/>
6
- <line x1="24" y1="36" x2="24" y2="45.5"/>
7
- <line x1="2.5" y1="24" x2="12" y2="24"/>
8
- <line x1="36" y1="24" x2="45.5" y2="24"/>
9
- <line x1="8.8" y1="8.8" x2="15.5" y2="15.5"/>
10
- <line x1="32.5" y1="32.5" x2="39.2" y2="39.2"/>
11
- <line x1="39.2" y1="8.8" x2="32.5" y2="15.5"/>
12
- <line x1="15.5" y1="32.5" x2="8.8" y2="39.2"/>
13
- </g>
14
- </svg>