@steerable/agent-shell-web 0.6.33 → 0.6.35

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.33",
3
+ "version": "0.6.35",
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",
@@ -43,8 +43,8 @@
43
43
  "react-router-dom": "^6.30.0",
44
44
  "rehype-raw": "^7.0.0",
45
45
  "remark-gfm": "^4.0.1",
46
- "@steerable/agent-protocol": "0.6.33",
47
- "@steerable/agent-ui": "0.6.33"
46
+ "@steerable/agent-ui": "0.6.35",
47
+ "@steerable/agent-protocol": "0.6.35"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@tailwindcss/vite": "^4.3.0",
@@ -57,7 +57,7 @@
57
57
  "typescript": "^5.8.3",
58
58
  "vite": "^5.4.21",
59
59
  "vitest": "^3.2.4",
60
- "@steerable/pack-sdk": "0.6.33"
60
+ "@steerable/pack-sdk": "0.6.35"
61
61
  },
62
62
  "scripts": {
63
63
  "gen": "echo agent-shell-web",
@@ -32,7 +32,7 @@ import {
32
32
  } from 'react-icons/lu';
33
33
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
34
34
  import type { ExecPolicy } from '@/lib/exec-policy';
35
- import { getWebChatModes, hostToolChrome, settingsChrome } from '@/lib/host-tools';
35
+ import { getWebChatModes, hostToolCapability, settingsChrome } from '@/lib/host-tools';
36
36
  import type { AttachmentFile } from '@/lib/attachments';
37
37
  import type { SteerOutcome } from '@steerable/agent-ui';
38
38
  import {
@@ -1113,7 +1113,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1113
1113
  const trimmed = value.trim();
1114
1114
  const canSend = (trimmed.length > 0 || actualFiles.length > 0) && !disabled && !isStreaming;
1115
1115
 
1116
- const allowFileAttach = hostToolChrome('local-fs');
1116
+ const allowFileAttach = hostToolCapability('local-fs');
1117
1117
 
1118
1118
  const handleDragOver = (event: React.DragEvent) => {
1119
1119
  event.preventDefault();
@@ -1844,6 +1844,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1844
1844
  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
1845
  title="上传文件(可多选)"
1846
1846
  aria-label="上传文件"
1847
+ data-testid="chat-attach"
1847
1848
  >
1848
1849
  <LuPaperclip className="h-3 w-3" />
1849
1850
  </button>
@@ -21,7 +21,7 @@ import type { LlmSpeedSnapshot } from './process-status';
21
21
  import { SessionTodoList } from './SessionTodoList';
22
22
  import { resolveLatestSessionTodos } from './todo-list-model';
23
23
  import {
24
- isImageFile,
24
+ composeAttachmentUserContent,
25
25
  saveChatAttachments,
26
26
  type AttachmentFile,
27
27
  } from '@/lib/attachments';
@@ -235,16 +235,8 @@ export function LocalChatPanel({
235
235
  // 自行持久化,这里 chatId 为空时原样用源路径。
236
236
  const resolvedFiles =
237
237
  chatId && files.length > 0 ? await saveChatAttachments(chatId, files) : files;
238
-
239
- // Append file references to the user message content
240
- if (resolvedFiles.length > 0) {
241
- const fileRefs = resolvedFiles.map(f => `- \`${f.path}\``).join('\n');
242
- if (trimmed) {
243
- trimmed = `${trimmed}\n\n---\n关联文件:\n${fileRefs}`;
244
- } else {
245
- trimmed = `关联文件:\n${fileRefs}`;
246
- }
247
- }
238
+ const assembled = composeAttachmentUserContent(trimmed, resolvedFiles);
239
+ trimmed = assembled.content;
248
240
 
249
241
  const mentionedAgentIds = mentionReferences
250
242
  .filter((ref) => ref.type === 'agent')
@@ -255,16 +247,13 @@ export function LocalChatPanel({
255
247
  // W6-3: image attachments ride as metadata so the main process reads the
256
248
  // bytes into base64 ImageParts; the text path refs above stay so the
257
249
  // persisted record reflects that an image was attached.
258
- const imageAttachments = resolvedFiles
259
- .filter((f) => isImageFile(f.path))
260
- .map((f) => ({ path: f.path, name: f.name }));
261
250
  const metadata = {
262
251
  ...(mode === 'plan' ? { mode: 'plan' as const } : {}),
263
252
  ...(execPolicy === 'full' ? { execPolicy: 'full' as const } : {}),
264
253
  ...(selectedAgentId ? { agentId: selectedAgentId } : {}),
265
254
  ...(mentionedAgentIds.length > 0 ? { mentionedAgentIds } : {}),
266
255
  ...(referencedChatIds.length > 0 ? { referencedChatIds } : {}),
267
- ...(imageAttachments.length > 0 ? { images: imageAttachments } : {}),
256
+ ...(assembled.images.length > 0 ? { images: assembled.images } : {}),
268
257
  };
269
258
 
270
259
  // Snapshot what we're about to clear so a failed submit can restore it —
@@ -9,7 +9,7 @@
9
9
  * - BS/browser:File 没有路径,这里把字节读成 base64 走 HTTP 端点落盘。
10
10
  * 返回的落盘路径会重写消息正文里的文件引用与图像附件元数据。
11
11
  */
12
- import { getElectronBridge, isElectron } from './electron-bridge';
12
+ import { getElectronBridge } from './electron-bridge';
13
13
 
14
14
  export interface AttachmentFile {
15
15
  name: string;
@@ -39,17 +39,41 @@ export async function fileToBase64(file: File): Promise<string> {
39
39
  return btoa(binary);
40
40
  }
41
41
 
42
+ /** 正文引用:优先落盘路径,浏览器未落盘时退回文件名(避免写成空的 ``)。 */
43
+ export function attachmentRefLabel(file: AttachmentFile): string {
44
+ return file.path || file.name;
45
+ }
46
+
47
+ /**
48
+ * 把附件写进用户消息:正文「关联文件」列表 + 图像 metadata。
49
+ * 只收录有路径或文件名的项。
50
+ */
51
+ export function composeAttachmentUserContent(
52
+ text: string,
53
+ files: AttachmentFile[],
54
+ ): { content: string; images: Array<{ path: string; name: string }> } {
55
+ const usable = files.filter((file) => attachmentRefLabel(file));
56
+ let content = text.trim();
57
+ if (usable.length > 0) {
58
+ const fileRefs = usable.map((file) => `- \`${attachmentRefLabel(file)}\``).join('\n');
59
+ content = content ? `${content}\n\n---\n关联文件:\n${fileRefs}` : `关联文件:\n${fileRefs}`;
60
+ }
61
+ const images = usable
62
+ .filter((file) => isImageFile(file.path || file.name))
63
+ .map((file) => ({ path: attachmentRefLabel(file), name: file.name }));
64
+ return { content, images };
65
+ }
66
+
42
67
  /**
43
68
  * 把一批附件持久化到会话附件目录,返回「提交时应使用」的文件列表:
44
69
  * 成功项用落盘路径,失败项退回原文件(不让用户的选择因单个文件失败而丢失)。
45
- * 非 Electron / 无 attachments 桥 / 空 chatId 时原样返回。
70
+ * 无 attachments 桥 / 空 chatId 时原样返回。
46
71
  */
47
72
  export async function saveChatAttachments(
48
73
  chatId: string | null | undefined,
49
74
  files: AttachmentFile[],
50
75
  ): Promise<AttachmentFile[]> {
51
76
  if (!chatId || files.length === 0) return files;
52
- if (!isElectron()) return files;
53
77
  const bridge = getElectronBridge();
54
78
  if (!bridge?.attachments?.save) return files;
55
79
  try {
@@ -61,6 +61,10 @@ import {
61
61
  setPendingFirstMessage,
62
62
  takePendingFirstMessage,
63
63
  } from "@/lib/pending-first-message";
64
+ import {
65
+ composeAttachmentUserContent,
66
+ saveChatAttachments,
67
+ } from "@/lib/attachments";
64
68
  import { BRAND_NAME, pickDefaultAgentId } from "@/brand";
65
69
 
66
70
  /**
@@ -1299,29 +1303,12 @@ function EmptyChatGate() {
1299
1303
  length: trimmed.length,
1300
1304
  });
1301
1305
 
1302
- // 与 LocalChatPanel.handleSubmit 相同的装配规则:文件路径附到正文,
1303
- // @ 引用进 metadata,plan 模式打 metadata.mode。
1304
- if (files.length > 0) {
1305
- const fileRefs = files.map((f) => `- \`${f.path}\``).join("\n");
1306
- trimmed = trimmed
1307
- ? `${trimmed}\n\n---\n关联文件:\n${fileRefs}`
1308
- : `关联文件:\n${fileRefs}`;
1309
- }
1310
1306
  const mentionedAgentIds = mentionReferences
1311
1307
  .filter((ref) => ref.type === "agent")
1312
1308
  .map((ref) => ref.id);
1313
1309
  const referencedChatIds = mentionReferences
1314
1310
  .filter((ref) => ref.type === "chat")
1315
1311
  .map((ref) => ref.id);
1316
- const metadata = {
1317
- ...(mode === "plan" ? { mode: "plan" as const } : {}),
1318
- ...(execPolicy === "full" ? { execPolicy: "full" as const } : {}),
1319
- ...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
1320
- ...(mentionedAgentIds.length > 0 ? { mentionedAgentIds } : {}),
1321
- ...(referencedChatIds.length > 0 ? { referencedChatIds } : {}),
1322
- ...(modelOverride ? { model: modelOverride } : {}),
1323
- ...(effortOverride ? { reasoningEffort: effortOverride } : {}),
1324
- };
1325
1312
 
1326
1313
  setIsCreating(true);
1327
1314
  setCreateError(null);
@@ -1331,9 +1318,23 @@ function EmptyChatGate() {
1331
1318
  ...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
1332
1319
  });
1333
1320
  if (!id) throw new Error("创建对话失败,请重试");
1321
+ // 落地页提交时还没有 chatId;先建会话再落盘,正文用落盘路径。
1322
+ const resolvedFiles =
1323
+ files.length > 0 ? await saveChatAttachments(id, files) : files;
1324
+ const assembled = composeAttachmentUserContent(trimmed, resolvedFiles);
1325
+ const metadata = {
1326
+ ...(mode === "plan" ? { mode: "plan" as const } : {}),
1327
+ ...(execPolicy === "full" ? { execPolicy: "full" as const } : {}),
1328
+ ...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
1329
+ ...(mentionedAgentIds.length > 0 ? { mentionedAgentIds } : {}),
1330
+ ...(referencedChatIds.length > 0 ? { referencedChatIds } : {}),
1331
+ ...(modelOverride ? { model: modelOverride } : {}),
1332
+ ...(effortOverride ? { reasoningEffort: effortOverride } : {}),
1333
+ ...(assembled.images.length > 0 ? { images: assembled.images } : {}),
1334
+ };
1334
1335
  setPendingFirstMessage({
1335
1336
  chatId: id,
1336
- content: trimmed,
1337
+ content: assembled.content,
1337
1338
  metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
1338
1339
  });
1339
1340
  navigate(`/agent/${id}`);