@steerable/agent-shell-web 0.6.51 → 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/brand.ts +7 -0
- package/src/components/AgentSidebar.tsx +284 -188
- package/src/components/ConfirmDialog.tsx +90 -0
- package/src/components/CreateProjectModal.tsx +32 -16
- package/src/components/chat/ApprovalModal.tsx +2 -5
- package/src/components/chat/ApprovalPromptProvider.tsx +38 -22
- package/src/components/chat/AskUserPromptProvider.tsx +1 -32
- package/src/components/chat/ChatInput.tsx +129 -59
- package/src/components/chat/ChatProjectBadge.tsx +1 -1
- package/src/components/chat/EmptyChatHero.tsx +22 -0
- package/src/components/chat/ExecPolicyPicker.tsx +2 -2
- package/src/components/chat/ExecutedActionsCard.tsx +22 -12
- package/src/components/chat/LocalChatPanel.tsx +25 -19
- package/src/components/chat/MessageList.tsx +77 -5
- package/src/components/chat/ModelPicker.tsx +21 -1
- package/src/components/chat/OrchestrationFlowCard.tsx +13 -3
- package/src/components/chat/SessionOrchestrationFlow.tsx +66 -19
- package/src/components/chat/SessionTodoList.tsx +12 -3
- package/src/components/chat/UserMessage.tsx +116 -25
- package/src/components/chat/composer-status-stack.tsx +79 -0
- package/src/components/chat/orchestration-flow-model.ts +108 -43
- package/src/components/chat/prompt-chat-scope.ts +33 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +33 -22
- package/src/components/settings/McpSettingsPanel.tsx +144 -48
- package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
- package/src/hooks/useProjects.ts +82 -0
- package/src/lib/attachments.ts +8 -0
- package/src/lib/electron-bridge.ts +16 -1
- package/src/lib/exec-policy.ts +1 -1
- package/src/lib/host-file-drop.ts +68 -0
- package/src/lib/host-paste.ts +38 -9
- package/src/lib/local-api.ts +1 -1
- package/src/lib/tauri-bridge.ts +5 -0
- package/src/pages/AgentPage.tsx +114 -64
- package/src/pages/SettingsPage.tsx +131 -46
- package/src/vite-env.d.ts +2 -0
- package/vite.base.js +1 -0
|
@@ -15,7 +15,7 @@ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
|
|
|
15
15
|
* McpSettingsPanel — MCP 服务管理面板(JSON 导入 / 手动增改 / 启停 / 测试 /
|
|
16
16
|
* 删除)。
|
|
17
17
|
*
|
|
18
|
-
* 渲染在 `/settings?section=
|
|
18
|
+
* 渲染在 `/settings?section=plugins` 的 MCP 分类(AgentLayout 右侧内容区)。
|
|
19
19
|
*
|
|
20
20
|
* 数据与状态完全自管理:挂载时拉一次列表,操作后刷新。后端走
|
|
21
21
|
* local-backend REST(`/api/v2/mcp/servers*`),注册表持久化在 main 进程的
|
|
@@ -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 && (
|
|
@@ -11,7 +11,7 @@ import { getPackHiddenSlashSkills } from '@/packs/registry';
|
|
|
11
11
|
/**
|
|
12
12
|
* SkillsSettingsPanel — 本地技能管理面板(导入目录 / 列表 / 卸载)。
|
|
13
13
|
*
|
|
14
|
-
* 渲染在 `/settings?section=
|
|
14
|
+
* 渲染在 `/settings?section=plugins` 的 Skills 分类(AgentLayout 右侧内容区)。
|
|
15
15
|
*
|
|
16
16
|
* 数据与状态完全自管理:挂载时拉一次列表,操作后刷新。后端走
|
|
17
17
|
* local-backend REST(`GET/POST /api/v2/chat-agents/skills*`),无专用 IPC。
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧栏项目分组和输入框「关联到项目」共用这一份列表。
|
|
3
|
+
* 任一侧新建、改名或改目录后调用 refresh,其它订阅者一起更新。
|
|
4
|
+
*/
|
|
5
|
+
import { useCallback, useEffect, useSyncExternalStore } from 'react';
|
|
6
|
+
import { isElectron } from '@/lib/electron-bridge';
|
|
7
|
+
import { hostToolChrome } from '@/lib/host-tools';
|
|
8
|
+
import { listProjects, type LocalProject } from '@/lib/local-api';
|
|
9
|
+
|
|
10
|
+
type ProjectsSnapshot = {
|
|
11
|
+
projects: LocalProject[];
|
|
12
|
+
error: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const EMPTY_SNAPSHOT: ProjectsSnapshot = { projects: [], error: null };
|
|
16
|
+
|
|
17
|
+
let snapshot: ProjectsSnapshot = EMPTY_SNAPSHOT;
|
|
18
|
+
let requestSeq = 0;
|
|
19
|
+
const listeners = new Set<() => void>();
|
|
20
|
+
|
|
21
|
+
function emit() {
|
|
22
|
+
for (const listener of listeners) listener();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function subscribeProjects(listener: () => void): () => void {
|
|
26
|
+
listeners.add(listener);
|
|
27
|
+
return () => {
|
|
28
|
+
listeners.delete(listener);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getProjectsSnapshot(): ProjectsSnapshot {
|
|
33
|
+
return snapshot;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 重新拉取项目列表并通知所有订阅者。非 Electron 或关掉项目入口时清空。 */
|
|
37
|
+
export async function refreshProjects(): Promise<void> {
|
|
38
|
+
const seq = ++requestSeq;
|
|
39
|
+
if (!isElectron() || !hostToolChrome('projects')) {
|
|
40
|
+
if (snapshot.projects.length > 0 || snapshot.error) {
|
|
41
|
+
snapshot = EMPTY_SNAPSHOT;
|
|
42
|
+
emit();
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const res = await listProjects();
|
|
48
|
+
if (seq !== requestSeq) return;
|
|
49
|
+
snapshot = { projects: res.projects ?? [], error: null };
|
|
50
|
+
emit();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (seq !== requestSeq) return;
|
|
53
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
54
|
+
console.error('获取项目列表失败:', err);
|
|
55
|
+
snapshot = { projects: snapshot.projects, error: message };
|
|
56
|
+
emit();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useProjects(): {
|
|
61
|
+
projects: LocalProject[];
|
|
62
|
+
error: string | null;
|
|
63
|
+
refresh: () => Promise<void>;
|
|
64
|
+
} {
|
|
65
|
+
const current = useSyncExternalStore(
|
|
66
|
+
subscribeProjects,
|
|
67
|
+
getProjectsSnapshot,
|
|
68
|
+
getProjectsSnapshot,
|
|
69
|
+
);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
void refreshProjects();
|
|
72
|
+
}, []);
|
|
73
|
+
const refresh = useCallback(() => refreshProjects(), []);
|
|
74
|
+
return { projects: current.projects, error: current.error, refresh };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 测试隔离:丢掉上一个用例留下的列表,并忽略还在飞的请求。 */
|
|
78
|
+
export function resetProjectsStoreForTests(): void {
|
|
79
|
+
requestSeq += 1;
|
|
80
|
+
snapshot = EMPTY_SNAPSHOT;
|
|
81
|
+
emit();
|
|
82
|
+
}
|
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 会二次校验)。 */
|
|
@@ -48,6 +48,7 @@ export interface ApprovalPromptRequest {
|
|
|
48
48
|
mode: string;
|
|
49
49
|
category: string;
|
|
50
50
|
round: number;
|
|
51
|
+
/** 发起审批的对话。缺省时卡片不绑定会话(旧请求)。 */
|
|
51
52
|
chatId?: string;
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -196,10 +197,24 @@ export interface HostBridge {
|
|
|
196
197
|
/**
|
|
197
198
|
* App menu IPC — `Cmd+V` / 编辑 → 粘贴 fires `menu:paste`.
|
|
198
199
|
* WKWebView does not expose that pasteboard to the page, so the renderer
|
|
199
|
-
* reads it through `
|
|
200
|
+
* reads it through `readClipboard`.
|
|
200
201
|
*/
|
|
201
202
|
onMenuPaste?: (callback: () => void) => void;
|
|
202
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
|
+
}>;
|
|
203
218
|
/** Plain text from the OS pasteboard. Empty when the clipboard has no text. */
|
|
204
219
|
readClipboardText?: () => Promise<string>;
|
|
205
220
|
/**
|
package/src/lib/exec-policy.ts
CHANGED
|
@@ -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/local-api.ts
CHANGED
|
@@ -387,7 +387,7 @@ export interface LocalProject {
|
|
|
387
387
|
id: string;
|
|
388
388
|
name: string;
|
|
389
389
|
folderPath: string;
|
|
390
|
-
/**
|
|
390
|
+
/** 附加源文件夹(可读写,含其子目录)。缺省空。 */
|
|
391
391
|
sourceFolders?: string[];
|
|
392
392
|
/** W6-5: 信任后该项目目录里的规则文件才会注入模型上下文。缺省 false。 */
|
|
393
393
|
trusted?: boolean;
|
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'),
|