@steerable/agent-shell-web 0.6.52 → 0.6.53

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.52",
3
+ "version": "0.6.53",
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.52",
49
- "@steerable/agent-ui": "0.6.52"
48
+ "@steerable/agent-ui": "0.6.53",
49
+ "@steerable/agent-protocol": "0.6.53"
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.52"
62
+ "@steerable/pack-sdk": "0.6.53"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
@@ -32,8 +32,9 @@ import {
32
32
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
33
33
  import type { ExecPolicy } from '@/lib/exec-policy';
34
34
  import { getWebChatModes, hostToolCapability, settingsChrome } from '@/lib/host-tools';
35
- import { hostClipboardAvailable, requestHostPaste } from '@/lib/host-paste';
36
- import type { AttachmentFile } from '@/lib/attachments';
35
+ import { hostClipboardAvailable, requestHostPaste, type HostClipboardFile } from '@/lib/host-paste';
36
+ import { attachmentFromPath, type AttachmentFile } from '@/lib/attachments';
37
+ import { dropPointHitsRect, hostFileDropAvailable, listenHostFileDrop } from '@/lib/host-file-drop';
37
38
  import type { SteerOutcome } from '@steerable/agent-ui';
38
39
  import {
39
40
  isHiddenSlashSkill,
@@ -714,6 +715,23 @@ function clipboardOf(event: ClipboardEvent<HTMLDivElement>): DataTransfer | null
714
715
  return native.clipboardData ?? event.clipboardData ?? null;
715
716
  }
716
717
 
718
+ function fileFromHostClipboard(file: HostClipboardFile): File | null {
719
+ if (!file.dataBase64) return null;
720
+ let binary: string;
721
+ try {
722
+ binary = atob(file.dataBase64);
723
+ } catch {
724
+ return null;
725
+ }
726
+ const bytes = new Uint8Array(binary.length);
727
+ for (let index = 0; index < binary.length; index += 1) {
728
+ bytes[index] = binary.charCodeAt(index);
729
+ }
730
+ return new File([bytes], file.name || 'image.png', {
731
+ type: file.mime || 'application/octet-stream',
732
+ });
733
+ }
734
+
717
735
  function namePastedImage(file: File, serial: number, now: Date): File {
718
736
  if (!file.type.startsWith('image/')) return file;
719
737
  if (file.name.trim() && !GENERIC_CLIPBOARD_IMAGE_NAME.test(file.name.trim())) return file;
@@ -835,7 +853,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
835
853
  const compositionEndAtRef = useRef<number>(0);
836
854
 
837
855
  const fileInputRef = useRef<HTMLInputElement>(null);
856
+ const dropBoxRef = useRef<HTMLDivElement>(null);
838
857
  const pasteSerialRef = useRef(0);
858
+ const attachDroppedRef = useRef<(paths: string[]) => void>(() => {});
859
+ const dragHoverRef = useRef<(hovering: boolean) => void>(() => {});
860
+ const hostPasteFilesRef = useRef<(files: HostClipboardFile[]) => void>(() => {});
839
861
  const adoptPastedTextRef = useRef<(text: string) => void>(() => {});
840
862
  // 工具栏"空间不足时优先隐藏快捷键提示"的测量 refs,见下方 useLayoutEffect。
841
863
  const toolbarRowRef = useRef<HTMLDivElement>(null);
@@ -1240,6 +1262,33 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1240
1262
  path: (file as { path?: string }).path || '',
1241
1263
  file,
1242
1264
  });
1265
+ attachDroppedRef.current = (paths) => {
1266
+ if (disabled || !allowFileAttach) return;
1267
+ addFiles(paths.map(attachmentFromPath));
1268
+ };
1269
+ dragHoverRef.current = (hovering) => {
1270
+ if (disabled || !allowFileAttach) {
1271
+ setIsDragging(false);
1272
+ return;
1273
+ }
1274
+ setIsDragging(hovering);
1275
+ };
1276
+ hostPasteFilesRef.current = (incoming) => {
1277
+ if (disabled || !allowFileAttach) return;
1278
+ const now = new Date();
1279
+ const next: AttachmentFile[] = [];
1280
+ for (const item of incoming) {
1281
+ if (item.path) {
1282
+ next.push(attachmentFromPath(item.path));
1283
+ continue;
1284
+ }
1285
+ const blob = fileFromHostClipboard(item);
1286
+ if (!blob) continue;
1287
+ pasteSerialRef.current += 1;
1288
+ next.push(toAttachment(namePastedImage(blob, pasteSerialRef.current, now)));
1289
+ }
1290
+ if (next.length > 0) addFiles(next);
1291
+ };
1243
1292
 
1244
1293
  // 按钮点击 → 触发隐藏的 <input type="file" multiple>,由浏览器/Electron
1245
1294
  // 直接弹出系统文件选择框(不依赖 IPC,避免「点击无反应」)。
@@ -1449,14 +1498,44 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1449
1498
  event.preventDefault();
1450
1499
  adoptPastedTextRef.current(text);
1451
1500
  };
1501
+ const onHostPasteFiles = (event: Event) => {
1502
+ const files = (event as CustomEvent<HostClipboardFile[]>).detail;
1503
+ if (!Array.isArray(files) || files.length === 0) return;
1504
+ event.preventDefault();
1505
+ hostPasteFilesRef.current(files);
1506
+ };
1452
1507
  editor.addEventListener('compositionstart', onCompositionStart, true);
1453
1508
  editor.addEventListener('hostpaste', onHostPaste);
1509
+ editor.addEventListener('hostpastefiles', onHostPasteFiles);
1454
1510
  return () => {
1455
1511
  editor.removeEventListener('compositionstart', onCompositionStart, true);
1456
1512
  editor.removeEventListener('hostpaste', onHostPaste);
1513
+ editor.removeEventListener('hostpastefiles', onHostPasteFiles);
1457
1514
  };
1458
1515
  }, []);
1459
1516
 
1517
+ useEffect(() => {
1518
+ if (!hostFileDropAvailable()) return undefined;
1519
+ return listenHostFileDrop((event) => {
1520
+ const box = dropBoxRef.current;
1521
+ if (event.type === 'leave' || !box || event.x == null || event.y == null) {
1522
+ dragHoverRef.current(false);
1523
+ return;
1524
+ }
1525
+ const hit = dropPointHitsRect(
1526
+ { x: event.x, y: event.y },
1527
+ box.getBoundingClientRect(),
1528
+ window.devicePixelRatio,
1529
+ );
1530
+ if (event.type === 'drop') {
1531
+ dragHoverRef.current(false);
1532
+ if (hit) attachDroppedRef.current(event.paths);
1533
+ return;
1534
+ }
1535
+ dragHoverRef.current(hit);
1536
+ });
1537
+ }, []);
1538
+
1460
1539
  const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
1461
1540
  // Read before preventDefault. WebKit clears clipboardData once the paste is canceled.
1462
1541
  const data = clipboardOf(event);
@@ -1862,6 +1941,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1862
1941
  </div>
1863
1942
  )}
1864
1943
  <div
1944
+ ref={dropBoxRef}
1865
1945
  className={`chat-input-box flex flex-col rounded-agent-lg border bg-agent-canvas shadow-sm transition-all duration-200 ${
1866
1946
  isDragging
1867
1947
  ? 'border-blue-500 ring-2 ring-blue-500/20'
@@ -26,10 +26,15 @@ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
26
26
  export interface McpServerInfo {
27
27
  id: string;
28
28
  name: string;
29
- command: string;
30
- args: string[];
31
- env: Record<string, string>;
29
+ transport: 'stdio' | 'streamable-http';
30
+ command?: string;
31
+ args?: string[];
32
+ envKeys?: string[];
32
33
  cwd?: string;
34
+ url?: string;
35
+ headerNames?: string[];
36
+ headersFromEnv?: Record<string, string>;
37
+ bearerTokenEnvVar?: string;
33
38
  enabled: boolean;
34
39
  serverKey: string;
35
40
  toolCount: number;
@@ -46,11 +51,16 @@ export function McpSettingsPanel() {
46
51
  const [mcpImportStatus, setMcpImportStatus] = useState<string | null>(null);
47
52
  const [mcpFormOpen, setMcpFormOpen] = useState(false);
48
53
  const [mcpEditingId, setMcpEditingId] = useState<string | null>(null);
54
+ const [mcpFormTransport, setMcpFormTransport] = useState<'stdio' | 'streamable-http'>('stdio');
49
55
  const [mcpFormName, setMcpFormName] = useState('');
50
56
  const [mcpFormCommand, setMcpFormCommand] = useState('');
51
57
  const [mcpFormArgs, setMcpFormArgs] = useState('');
52
58
  const [mcpFormEnv, setMcpFormEnv] = useState('');
53
59
  const [mcpFormCwd, setMcpFormCwd] = useState('');
60
+ const [mcpFormUrl, setMcpFormUrl] = useState('');
61
+ const [mcpFormHeaders, setMcpFormHeaders] = useState('');
62
+ const [mcpFormHeadersFromEnv, setMcpFormHeadersFromEnv] = useState('');
63
+ const [mcpFormBearerEnv, setMcpFormBearerEnv] = useState('');
54
64
  const [mcpSaving, setMcpSaving] = useState(false);
55
65
  const [mcpTestingId, setMcpTestingId] = useState<string | null>(null);
56
66
  const [mcpDeletingId, setMcpDeletingId] = useState<string | null>(null);
@@ -80,11 +90,16 @@ export function McpSettingsPanel() {
80
90
 
81
91
  const resetMcpForm = () => {
82
92
  setMcpEditingId(null);
93
+ setMcpFormTransport('stdio');
83
94
  setMcpFormName('');
84
95
  setMcpFormCommand('');
85
96
  setMcpFormArgs('');
86
97
  setMcpFormEnv('');
87
98
  setMcpFormCwd('');
99
+ setMcpFormUrl('');
100
+ setMcpFormHeaders('');
101
+ setMcpFormHeadersFromEnv('');
102
+ setMcpFormBearerEnv('');
88
103
  };
89
104
 
90
105
  const parseEnvLines = (raw: string): Record<string, string> => {
@@ -104,13 +119,27 @@ export function McpSettingsPanel() {
104
119
  setMcpSaving(true);
105
120
  setError(null);
106
121
  try {
107
- const body = {
108
- name: mcpFormName.trim(),
109
- command: mcpFormCommand.trim(),
110
- args: mcpFormArgs.split('\n').map((s) => s.trim()).filter(Boolean),
111
- env: parseEnvLines(mcpFormEnv),
112
- cwd: mcpFormCwd.trim() || undefined,
113
- };
122
+ const body = mcpFormTransport === 'stdio'
123
+ ? {
124
+ name: mcpFormName.trim(),
125
+ transport: 'stdio',
126
+ command: mcpFormCommand.trim(),
127
+ args: mcpFormArgs.split('\n').map((s) => s.trim()).filter(Boolean),
128
+ ...(!mcpEditingId || mcpFormEnv.trim()
129
+ ? { env: parseEnvLines(mcpFormEnv) }
130
+ : {}),
131
+ cwd: mcpFormCwd.trim() || undefined,
132
+ }
133
+ : {
134
+ name: mcpFormName.trim(),
135
+ transport: 'streamable-http',
136
+ url: mcpFormUrl.trim(),
137
+ ...(!mcpEditingId || mcpFormHeaders.trim()
138
+ ? { headers: parseEnvLines(mcpFormHeaders) }
139
+ : {}),
140
+ headersFromEnv: parseEnvLines(mcpFormHeadersFromEnv),
141
+ bearerTokenEnvVar: mcpFormBearerEnv.trim() || undefined,
142
+ };
114
143
  if (mcpEditingId) {
115
144
  await getElectronBridge()!.localBackend.request({
116
145
  method: 'PUT',
@@ -136,15 +165,22 @@ export function McpSettingsPanel() {
136
165
 
137
166
  const handleEditMcpServer = (server: McpServerInfo) => {
138
167
  setMcpEditingId(server.id);
168
+ setMcpFormTransport(server.transport ?? 'stdio');
139
169
  setMcpFormName(server.name);
140
- setMcpFormCommand(server.command);
170
+ setMcpFormCommand(server.command ?? '');
141
171
  setMcpFormArgs((server.args || []).join('\n'));
142
- setMcpFormEnv(
143
- Object.entries(server.env || {})
144
- .map(([k, v]) => `${k}=${v}`)
172
+ // Stored values never return through the settings API. Blank preserves
173
+ // the existing env map; entering lines replaces it deliberately.
174
+ setMcpFormEnv('');
175
+ setMcpFormCwd(server.cwd || '');
176
+ setMcpFormUrl(server.url || '');
177
+ setMcpFormHeaders('');
178
+ setMcpFormHeadersFromEnv(
179
+ Object.entries(server.headersFromEnv ?? {})
180
+ .map(([header, variable]) => `${header}=${variable}`)
145
181
  .join('\n'),
146
182
  );
147
- setMcpFormCwd(server.cwd || '');
183
+ setMcpFormBearerEnv(server.bearerTokenEnvVar || '');
148
184
  setMcpFormOpen(true);
149
185
  };
150
186
 
@@ -275,6 +311,16 @@ export function McpSettingsPanel() {
275
311
  <h4 className="text-xs font-semibold text-agent-foreground">
276
312
  {mcpEditingId ? '编辑 MCP 服务' : '手动添加 MCP 服务'}
277
313
  </h4>
314
+ <select
315
+ value={mcpFormTransport}
316
+ onChange={(event) =>
317
+ setMcpFormTransport(event.target.value as 'stdio' | 'streamable-http')
318
+ }
319
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
320
+ >
321
+ <option value="stdio">本地进程(stdio)</option>
322
+ <option value="streamable-http">远程服务(Streamable HTTP)</option>
323
+ </select>
278
324
  <div className="grid grid-cols-2 gap-2">
279
325
  <input
280
326
  type="text"
@@ -283,35 +329,73 @@ export function McpSettingsPanel() {
283
329
  placeholder="名称,如 filesystem"
284
330
  className="h-8 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
285
331
  />
286
- <input
287
- type="text"
288
- value={mcpFormCommand}
289
- onChange={(e) => setMcpFormCommand(e.target.value)}
290
- placeholder="启动命令,如 npx / uvx / node"
291
- className="h-8 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
292
- />
332
+ {mcpFormTransport === 'stdio' ? (
333
+ <input
334
+ type="text"
335
+ value={mcpFormCommand}
336
+ onChange={(e) => setMcpFormCommand(e.target.value)}
337
+ placeholder="启动命令,如 npx / uvx / node"
338
+ className="h-8 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
339
+ />
340
+ ) : (
341
+ <input
342
+ type="url"
343
+ value={mcpFormUrl}
344
+ onChange={(e) => setMcpFormUrl(e.target.value)}
345
+ placeholder="https://example.com/mcp"
346
+ className="h-8 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
347
+ />
348
+ )}
293
349
  </div>
294
- <textarea
295
- value={mcpFormArgs}
296
- onChange={(e) => setMcpFormArgs(e.target.value)}
297
- placeholder="参数(每行一个),如:&#10;-y&#10;@modelcontextprotocol/server-filesystem&#10;C:/"
298
- rows={3}
299
- className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
300
- />
301
- <textarea
302
- value={mcpFormEnv}
303
- onChange={(e) => setMcpFormEnv(e.target.value)}
304
- placeholder="环境变量(每行一个 KEY=VALUE,可留空)"
305
- rows={2}
306
- className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
307
- />
308
- <input
309
- type="text"
310
- value={mcpFormCwd}
311
- onChange={(e) => setMcpFormCwd(e.target.value)}
312
- placeholder="工作目录(可选)"
313
- className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
314
- />
350
+ {mcpFormTransport === 'stdio' ? (
351
+ <>
352
+ <textarea
353
+ value={mcpFormArgs}
354
+ onChange={(e) => setMcpFormArgs(e.target.value)}
355
+ placeholder="参数(每行一个)"
356
+ rows={3}
357
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
358
+ />
359
+ <textarea
360
+ value={mcpFormEnv}
361
+ onChange={(e) => setMcpFormEnv(e.target.value)}
362
+ placeholder={mcpEditingId ? '环境变量;留空保留现有值' : '环境变量(每行 KEY=VALUE)'}
363
+ rows={2}
364
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
365
+ />
366
+ <input
367
+ type="text"
368
+ value={mcpFormCwd}
369
+ onChange={(e) => setMcpFormCwd(e.target.value)}
370
+ placeholder="工作目录(可选)"
371
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
372
+ />
373
+ </>
374
+ ) : (
375
+ <>
376
+ <textarea
377
+ value={mcpFormHeaders}
378
+ onChange={(e) => setMcpFormHeaders(e.target.value)}
379
+ placeholder={mcpEditingId ? '普通请求头;留空保留现有值' : '普通请求头(每行 Header=Value)'}
380
+ rows={2}
381
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
382
+ />
383
+ <textarea
384
+ value={mcpFormHeadersFromEnv}
385
+ onChange={(e) => setMcpFormHeadersFromEnv(e.target.value)}
386
+ placeholder="环境变量请求头(每行 Header=ENV_NAME)"
387
+ rows={2}
388
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
389
+ />
390
+ <input
391
+ type="text"
392
+ value={mcpFormBearerEnv}
393
+ onChange={(e) => setMcpFormBearerEnv(e.target.value)}
394
+ placeholder="Bearer token 环境变量名(可选)"
395
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
396
+ />
397
+ </>
398
+ )}
315
399
  <div className="flex justify-end gap-2">
316
400
  <button
317
401
  type="button"
@@ -323,9 +407,19 @@ export function McpSettingsPanel() {
323
407
  <button
324
408
  type="button"
325
409
  onClick={handleSaveMcpServer}
326
- disabled={mcpSaving || !mcpFormName.trim() || !mcpFormCommand.trim()}
410
+ disabled={
411
+ mcpSaving ||
412
+ !mcpFormName.trim() ||
413
+ (mcpFormTransport === 'stdio'
414
+ ? !mcpFormCommand.trim()
415
+ : !mcpFormUrl.trim())
416
+ }
327
417
  className={`h-8 px-4 rounded-full text-xs font-medium transition-all ${
328
- mcpSaving || !mcpFormName.trim() || !mcpFormCommand.trim()
418
+ mcpSaving ||
419
+ !mcpFormName.trim() ||
420
+ (mcpFormTransport === 'stdio'
421
+ ? !mcpFormCommand.trim()
422
+ : !mcpFormUrl.trim())
329
423
  ? 'bg-agent-muted text-agent-muted-foreground cursor-not-allowed'
330
424
  : 'bg-agent-foreground text-agent-canvas hover:opacity-90'
331
425
  }`}
@@ -386,7 +480,9 @@ export function McpSettingsPanel() {
386
480
  )}
387
481
  </div>
388
482
  <p className="text-[11px] text-agent-muted-foreground font-mono truncate">
389
- {server.command} {(server.args || []).join(' ')}
483
+ {server.transport === 'streamable-http'
484
+ ? server.url
485
+ : `${server.command ?? ''} ${(server.args ?? []).join(' ')}`}
390
486
  </p>
391
487
  {server.lastError && (
392
488
  <p className="text-[10px] text-agent-destructive line-clamp-2">
@@ -453,7 +549,7 @@ export function McpSettingsPanel() {
453
549
  )}
454
550
  </div>
455
551
  <p className="text-[10px] text-agent-muted-foreground">
456
- 已启用服务的工具会以 mcp__服务名__工具名 的形式直接提供给助手调用;新导入或修改后点「测试连接」即可生效。
552
+ 支持 stdio 与 Streamable HTTP。密钥请填写环境变量名,不要写进普通请求头;已启用服务的工具通过 tool_search 提供给助手。
457
553
  </p>
458
554
 
459
555
  {error && (
@@ -19,6 +19,14 @@ export interface AttachmentFile {
19
19
  file?: File;
20
20
  }
21
21
 
22
+ /** 宿主拖放 / 粘贴只给出绝对路径时,用最后一段作为展示名。 */
23
+ export function attachmentFromPath(path: string): AttachmentFile {
24
+ const trimmed = path.trim();
25
+ const parts = trimmed.split(/[/\\]/).filter((part) => part.length > 0);
26
+ const name = parts[parts.length - 1] ?? trimmed;
27
+ return { name, path: trimmed };
28
+ }
29
+
22
30
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
23
31
 
24
32
  /** 扩展名是否为图像(renderer 侧预过滤,后端 image-attachment 会二次校验)。 */
@@ -197,10 +197,24 @@ export interface HostBridge {
197
197
  /**
198
198
  * App menu IPC — `Cmd+V` / 编辑 → 粘贴 fires `menu:paste`.
199
199
  * WKWebView does not expose that pasteboard to the page, so the renderer
200
- * reads it through `readClipboardText`.
200
+ * reads it through `readClipboard`.
201
201
  */
202
202
  onMenuPaste?: (callback: () => void) => void;
203
203
  offMenuPaste?: () => void;
204
+ /**
205
+ * OS pasteboard. `files` are copied paths or a screenshot PNG.
206
+ * Text is empty when the pasteboard holds files, so the path is not
207
+ * inserted into the composer as well.
208
+ */
209
+ readClipboard?: () => Promise<{
210
+ text: string;
211
+ files: Array<{
212
+ name: string;
213
+ path?: string;
214
+ dataBase64?: string;
215
+ mime?: string;
216
+ }>;
217
+ }>;
204
218
  /** Plain text from the OS pasteboard. Empty when the clipboard has no text. */
205
219
  readClipboardText?: () => Promise<string>;
206
220
  /**
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tauri consumes OS file drags before they become HTML5 drop events.
3
+ * The webview reports paths through `onDragDropEvent` instead.
4
+ *
5
+ * macOS labels AppKit points as a physical position. Windows reports device
6
+ * pixels. Checking both against the composer rectangle accepts the drop on
7
+ * either scale.
8
+ */
9
+ import { getCurrentWebview, type DragDropEvent } from '@tauri-apps/api/webview';
10
+
11
+ export interface HostFileDrop {
12
+ type: 'enter' | 'over' | 'drop' | 'leave';
13
+ paths: string[];
14
+ x?: number;
15
+ y?: number;
16
+ }
17
+
18
+ export function hostFileDropAvailable(): boolean {
19
+ return typeof window !== 'undefined' && window.__TAURI_INTERNALS__ != null;
20
+ }
21
+
22
+ export function dropPointHitsRect(
23
+ position: { x: number; y: number },
24
+ rect: { left: number; right: number; top: number; bottom: number },
25
+ devicePixelRatio: number,
26
+ ): boolean {
27
+ const scale = devicePixelRatio > 0 ? devicePixelRatio : 1;
28
+ return (
29
+ contains(rect, position.x, position.y) ||
30
+ contains(rect, position.x / scale, position.y / scale)
31
+ );
32
+ }
33
+
34
+ /** Subscribe while a composer is mounted. The returned function removes it. */
35
+ export function listenHostFileDrop(onEvent: (event: HostFileDrop) => void): () => void {
36
+ if (!hostFileDropAvailable()) return () => {};
37
+ let unlisten: (() => void) | undefined;
38
+ let cancelled = false;
39
+ const pending = getCurrentWebview().onDragDropEvent((event) => {
40
+ onEvent(hostFileDropFrom(event.payload));
41
+ });
42
+ void pending.then((stop) => {
43
+ if (cancelled) stop();
44
+ else unlisten = stop;
45
+ });
46
+ return () => {
47
+ cancelled = true;
48
+ unlisten?.();
49
+ };
50
+ }
51
+
52
+ function contains(
53
+ rect: { left: number; right: number; top: number; bottom: number },
54
+ x: number,
55
+ y: number,
56
+ ): boolean {
57
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
58
+ }
59
+
60
+ function hostFileDropFrom(payload: DragDropEvent): HostFileDrop {
61
+ if (payload.type === 'leave') return { type: 'leave', paths: [] };
62
+ return {
63
+ type: payload.type,
64
+ paths: payload.type === 'over' ? [] : payload.paths,
65
+ x: payload.position.x,
66
+ y: payload.position.y,
67
+ };
68
+ }
@@ -1,8 +1,20 @@
1
1
  /**
2
- * WKWebView paste does not carry clipboard text into the page. The Edit menu
3
- * shortcut emits `menu:paste`; this module reads the OS pasteboard and writes
4
- * it into the focused field.
2
+ * WKWebView paste does not carry clipboard text or files into the page. The
3
+ * Edit menu shortcut emits `menu:paste`; this module reads the OS pasteboard
4
+ * and writes it into the focused field.
5
5
  */
6
+
7
+ export interface HostClipboardFile {
8
+ name: string;
9
+ path?: string;
10
+ dataBase64?: string;
11
+ mime?: string;
12
+ }
13
+
14
+ export interface HostClipboard {
15
+ text: string;
16
+ files: HostClipboardFile[];
17
+ }
6
18
  import { getHostBridge } from './electron-bridge';
7
19
 
8
20
  let pending = false;
@@ -25,12 +37,18 @@ export function installHostPaste(): void {
25
37
 
26
38
  /** Read the OS pasteboard and insert it into `target` or the focused field. */
27
39
  export function requestHostPaste(target?: HTMLElement | null): void {
28
- const read = getHostBridge()?.readClipboardText;
29
- if (!read || pending) return;
40
+ const bridge = getHostBridge();
41
+ const readClipboard = bridge?.readClipboard;
42
+ const readText = bridge?.readClipboardText;
43
+ if ((!readClipboard && !readText) || pending) return;
30
44
  pending = true;
31
- void read()
32
- .then((text) => {
33
- if (text) insertHostText(text, target ?? null);
45
+ const read = readClipboard
46
+ ? readClipboard()
47
+ : readText!().then((text) => ({ text, files: [] as HostClipboardFile[] }));
48
+ void read
49
+ .then((clip) => {
50
+ if (clip.files.length > 0) insertHostFiles(clip.files, target ?? null);
51
+ if (clip.text) insertHostText(clip.text, target ?? null);
34
52
  })
35
53
  .catch((error: unknown) => {
36
54
  console.warn('[host-paste] clipboard read failed', error);
@@ -41,7 +59,10 @@ export function requestHostPaste(target?: HTMLElement | null): void {
41
59
  }
42
60
 
43
61
  export function hostClipboardAvailable(): boolean {
44
- return typeof getHostBridge()?.readClipboardText === 'function';
62
+ const bridge = getHostBridge();
63
+ return (
64
+ typeof bridge?.readClipboard === 'function' || typeof bridge?.readClipboardText === 'function'
65
+ );
45
66
  }
46
67
 
47
68
  function isEditable(target: EventTarget | null): target is HTMLElement {
@@ -58,6 +79,14 @@ function focusedEditable(): HTMLElement | null {
58
79
  return null;
59
80
  }
60
81
 
82
+ function insertHostFiles(files: HostClipboardFile[], preferred: HTMLElement | null): void {
83
+ const el = preferred?.isConnected ? preferred : focusedEditable();
84
+ if (!el) return;
85
+ if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') {
86
+ el.dispatchEvent(new CustomEvent('hostpastefiles', { detail: files, cancelable: true }));
87
+ }
88
+ }
89
+
61
90
  function insertHostText(text: string, preferred: HTMLElement | null): void {
62
91
  const el = preferred?.isConnected ? preferred : focusedEditable();
63
92
  if (!el) return;
@@ -92,6 +92,11 @@ export function createTauriBridge(): HostBridge {
92
92
  offMenuOpenTerminal: terminalMenu.off,
93
93
  onMenuPaste: pasteMenu.on,
94
94
  offMenuPaste: pasteMenu.off,
95
+ readClipboard: () =>
96
+ invoke<{
97
+ text: string;
98
+ files: Array<{ name: string; path?: string; dataBase64?: string; mime?: string }>;
99
+ }>('host_read_clipboard'),
95
100
  readClipboardText: () => invoke<string>('host_read_clipboard_text'),
96
101
  app: {
97
102
  snapshot: () => invoke<AppReleaseSnapshot>('app_release_snapshot'),