@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 +4 -4
- package/src/components/chat/ChatInput.tsx +82 -2
- package/src/components/settings/McpSettingsPanel.tsx +143 -47
- package/src/lib/attachments.ts +8 -0
- package/src/lib/electron-bridge.ts +15 -1
- package/src/lib/host-file-drop.ts +68 -0
- package/src/lib/host-paste.ts +38 -9
- package/src/lib/tauri-bridge.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell-web",
|
|
3
|
-
"version": "0.6.
|
|
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-
|
|
49
|
-
"@steerable/agent-
|
|
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.
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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={
|
|
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 ||
|
|
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.
|
|
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
|
-
|
|
552
|
+
支持 stdio 与 Streamable HTTP。密钥请填写环境变量名,不要写进普通请求头;已启用服务的工具通过 tool_search 提供给助手。
|
|
457
553
|
</p>
|
|
458
554
|
|
|
459
555
|
{error && (
|
package/src/lib/attachments.ts
CHANGED
|
@@ -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 `
|
|
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
|
+
}
|
package/src/lib/host-paste.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WKWebView paste does not carry clipboard text into the page. The
|
|
3
|
-
* shortcut emits `menu:paste`; this module reads the OS pasteboard
|
|
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
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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;
|
package/src/lib/tauri-bridge.ts
CHANGED
|
@@ -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'),
|