@steerable/agent-shell-web 0.6.47 → 0.6.49
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/AgentSidebar.tsx +423 -299
- package/src/components/ChatHeader.tsx +74 -4
- package/src/components/SidebarRelease.tsx +67 -0
- package/src/components/chat/ApprovalModal.tsx +8 -0
- package/src/components/chat/ApprovalPromptProvider.tsx +16 -1
- package/src/components/chat/AskUserPromptProvider.tsx +14 -0
- package/src/components/chat/AssistantMessage.tsx +1 -1
- package/src/components/chat/ChatInput.tsx +82 -31
- package/src/components/chat/SuggestedReplies.tsx +5 -2
- package/src/components/chat/TurnFilesCard.tsx +417 -69
- package/src/components/chat/turn-files.ts +273 -0
- package/src/components/settings/AppUpdateSettingsPanel.tsx +56 -0
- package/src/components/settings/PortableSettingsPanel.tsx +493 -0
- package/src/components/settings/PythonRunnerSettingsPanel.tsx +231 -0
- package/src/layouts/AgentLayout.tsx +23 -0
- package/src/lib/app-release.ts +66 -0
- package/src/lib/electron-bridge.ts +75 -0
- package/src/lib/host-paste.ts +85 -0
- package/src/lib/local-api.ts +18 -0
- package/src/lib/portable.ts +229 -0
- package/src/lib/tauri-bridge.ts +61 -1
- package/src/main.tsx +2 -0
- package/src/pages/AgentPage.tsx +17 -0
- package/src/pages/SettingsPage.tsx +19 -2
- package/src/styles/index.css +29 -0
- package/src/vite-env.d.ts +2 -0
- package/vite.base.js +1 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { LuCodeXml } from 'react-icons/lu';
|
|
3
|
+
import {
|
|
4
|
+
getElectronBridge,
|
|
5
|
+
type PythonRunnerSnapshot,
|
|
6
|
+
} from '@/lib/electron-bridge';
|
|
7
|
+
|
|
8
|
+
type Source = PythonRunnerSnapshot['source'];
|
|
9
|
+
|
|
10
|
+
function formatBytes(value?: number): string {
|
|
11
|
+
if (value == null) return '';
|
|
12
|
+
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function PythonRunnerSettingsPanel() {
|
|
16
|
+
const [snapshot, setSnapshot] = useState<PythonRunnerSnapshot | null>(null);
|
|
17
|
+
const [source, setSource] = useState<Source>('default');
|
|
18
|
+
const [url, setUrl] = useState('');
|
|
19
|
+
const [localPath, setLocalPath] = useState('');
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const runner = getElectronBridge()?.pythonRunner;
|
|
23
|
+
if (!runner) return;
|
|
24
|
+
let alive = true;
|
|
25
|
+
void runner.snapshot().then((next) => {
|
|
26
|
+
if (!alive) return;
|
|
27
|
+
setSnapshot(next);
|
|
28
|
+
setSource(next.source);
|
|
29
|
+
setUrl(next.configuredUrl ?? '');
|
|
30
|
+
}).catch(() => {});
|
|
31
|
+
const off = runner.onState((next) => {
|
|
32
|
+
if (alive) setSnapshot(next);
|
|
33
|
+
});
|
|
34
|
+
return () => {
|
|
35
|
+
alive = false;
|
|
36
|
+
off();
|
|
37
|
+
};
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
if (!snapshot?.supported) return null;
|
|
41
|
+
const runner = getElectronBridge()?.pythonRunner;
|
|
42
|
+
const busy =
|
|
43
|
+
snapshot.phase === 'downloading'
|
|
44
|
+
|| snapshot.phase === 'verifying'
|
|
45
|
+
|| snapshot.phase === 'extracting';
|
|
46
|
+
|
|
47
|
+
const update = (task: Promise<PythonRunnerSnapshot>) => {
|
|
48
|
+
void task.then(setSnapshot).catch((error: unknown) => {
|
|
49
|
+
setSnapshot({
|
|
50
|
+
...snapshot,
|
|
51
|
+
phase: 'error',
|
|
52
|
+
message: error instanceof Error ? error.message : String(error),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const chooseLocal = () => {
|
|
58
|
+
if (!runner) return;
|
|
59
|
+
void runner.pickLocal().then((path) => {
|
|
60
|
+
if (path) setLocalPath(path);
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const run = () => {
|
|
65
|
+
if (!runner || busy) return;
|
|
66
|
+
if (source === 'local') {
|
|
67
|
+
update(runner.useLocal(localPath));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
update(runner.download(source === 'url' ? url : undefined));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const actionLabel =
|
|
74
|
+
source === 'local'
|
|
75
|
+
? '使用此 Python'
|
|
76
|
+
: source === 'url'
|
|
77
|
+
? '从此地址下载'
|
|
78
|
+
: '下载默认运行器';
|
|
79
|
+
const phaseLabel =
|
|
80
|
+
snapshot.phase === 'verifying'
|
|
81
|
+
? '正在校验'
|
|
82
|
+
: snapshot.phase === 'extracting'
|
|
83
|
+
? '正在解压'
|
|
84
|
+
: '正在下载';
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<section className="space-y-2" data-testid="settings-section-python-runner">
|
|
88
|
+
<h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
|
|
89
|
+
<LuCodeXml className="h-3.5 w-3.5 text-agent-muted-foreground" />
|
|
90
|
+
Python 代码运行器
|
|
91
|
+
</h2>
|
|
92
|
+
<div className="space-y-3 rounded-agent-md border border-agent-border bg-agent-card p-3 text-xs">
|
|
93
|
+
<p className="leading-relaxed text-agent-muted-foreground">
|
|
94
|
+
run_code 使用独立的 Python 解释器。下载和配置不会阻塞聊天,变更在重启应用后生效。
|
|
95
|
+
</p>
|
|
96
|
+
|
|
97
|
+
<div className="flex flex-wrap gap-3">
|
|
98
|
+
{([
|
|
99
|
+
['default', '默认地址'],
|
|
100
|
+
['url', '自定义地址'],
|
|
101
|
+
['local', '本地 Python'],
|
|
102
|
+
] as const).map(([value, label]) => (
|
|
103
|
+
<label key={value} className="flex items-center gap-1.5 text-agent-foreground">
|
|
104
|
+
<input
|
|
105
|
+
type="radio"
|
|
106
|
+
name="python-runner-source"
|
|
107
|
+
value={value}
|
|
108
|
+
checked={source === value}
|
|
109
|
+
disabled={busy}
|
|
110
|
+
onChange={() => setSource(value)}
|
|
111
|
+
/>
|
|
112
|
+
{label}
|
|
113
|
+
</label>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{source === 'default' ? (
|
|
118
|
+
<p className="break-all text-agent-muted-foreground" data-testid="python-runner-default-url">
|
|
119
|
+
{snapshot.defaultUrl}
|
|
120
|
+
</p>
|
|
121
|
+
) : null}
|
|
122
|
+
|
|
123
|
+
{source === 'url' ? (
|
|
124
|
+
<div className="space-y-1.5">
|
|
125
|
+
<input
|
|
126
|
+
type="url"
|
|
127
|
+
value={url}
|
|
128
|
+
disabled={busy}
|
|
129
|
+
placeholder="https://example.com/python-runner.tar.gz"
|
|
130
|
+
className="h-8 w-full rounded-agent-sm border border-agent-border bg-agent-canvas px-2 text-agent-foreground outline-none focus:border-agent-foreground/40"
|
|
131
|
+
onChange={(event) => setUrl(event.target.value)}
|
|
132
|
+
/>
|
|
133
|
+
<p className="text-agent-destructive">
|
|
134
|
+
自定义地址不会校验文件完整性,请仅使用可信来源。
|
|
135
|
+
</p>
|
|
136
|
+
</div>
|
|
137
|
+
) : null}
|
|
138
|
+
|
|
139
|
+
{source === 'local' ? (
|
|
140
|
+
<div className="flex gap-2">
|
|
141
|
+
<input
|
|
142
|
+
type="text"
|
|
143
|
+
value={localPath}
|
|
144
|
+
disabled={busy}
|
|
145
|
+
placeholder="Python 可执行文件的绝对路径"
|
|
146
|
+
className="h-8 min-w-0 flex-1 rounded-agent-sm border border-agent-border bg-agent-canvas px-2 text-agent-foreground outline-none focus:border-agent-foreground/40"
|
|
147
|
+
onChange={(event) => setLocalPath(event.target.value)}
|
|
148
|
+
/>
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
disabled={busy}
|
|
152
|
+
className="h-8 shrink-0 rounded-agent-sm border border-agent-border px-3 text-agent-foreground hover:bg-agent-foreground/5 disabled:opacity-50"
|
|
153
|
+
onClick={chooseLocal}
|
|
154
|
+
>
|
|
155
|
+
选择…
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
) : null}
|
|
159
|
+
|
|
160
|
+
{busy ? (
|
|
161
|
+
<div className="space-y-2" data-testid="python-runner-progress">
|
|
162
|
+
<div className="h-1.5 overflow-hidden rounded-full bg-agent-foreground/10">
|
|
163
|
+
<div
|
|
164
|
+
className="h-full rounded-full bg-agent-foreground transition-[width]"
|
|
165
|
+
style={{ width: `${snapshot.percent ?? 0}%` }}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
<div className="flex items-center justify-between text-agent-muted-foreground">
|
|
169
|
+
<span>
|
|
170
|
+
{phaseLabel}
|
|
171
|
+
{snapshot.percent != null ? ` ${snapshot.percent}%` : ''}
|
|
172
|
+
{snapshot.downloadedBytes != null
|
|
173
|
+
? ` · ${formatBytes(snapshot.downloadedBytes)}${snapshot.totalBytes ? ` / ${formatBytes(snapshot.totalBytes)}` : ''}`
|
|
174
|
+
: ''}
|
|
175
|
+
</span>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
className="text-agent-foreground hover:underline"
|
|
179
|
+
onClick={() => runner && update(runner.cancel())}
|
|
180
|
+
>
|
|
181
|
+
取消
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
) : (
|
|
186
|
+
<div className="flex gap-2">
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
data-testid="python-runner-action"
|
|
190
|
+
disabled={
|
|
191
|
+
(source === 'url' && !url.trim())
|
|
192
|
+
|| (source === 'local' && !localPath.trim())
|
|
193
|
+
}
|
|
194
|
+
className="h-8 rounded-agent-sm border border-agent-border px-3 text-agent-foreground hover:bg-agent-foreground/5 disabled:opacity-50"
|
|
195
|
+
onClick={run}
|
|
196
|
+
>
|
|
197
|
+
{actionLabel}
|
|
198
|
+
</button>
|
|
199
|
+
{snapshot.restartRequired ? (
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
data-testid="python-runner-restart"
|
|
203
|
+
className="h-8 rounded-agent-sm bg-agent-foreground px-3 font-medium text-agent-canvas hover:opacity-90"
|
|
204
|
+
onClick={() => void runner?.restart()}
|
|
205
|
+
>
|
|
206
|
+
重启应用
|
|
207
|
+
</button>
|
|
208
|
+
) : null}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{snapshot.message ? (
|
|
213
|
+
<p
|
|
214
|
+
className={snapshot.phase === 'error' ? 'text-agent-destructive' : 'text-agent-muted-foreground'}
|
|
215
|
+
role={snapshot.phase === 'error' ? 'alert' : undefined}
|
|
216
|
+
>
|
|
217
|
+
{snapshot.message}
|
|
218
|
+
</p>
|
|
219
|
+
) : null}
|
|
220
|
+
{snapshot.restartRequired ? (
|
|
221
|
+
<p className="font-medium text-agent-foreground">已保存,重启应用后生效。</p>
|
|
222
|
+
) : null}
|
|
223
|
+
{snapshot.activeRunner ? (
|
|
224
|
+
<p className="break-all text-agent-muted-foreground">
|
|
225
|
+
当前会话:{snapshot.activeRunner}
|
|
226
|
+
</p>
|
|
227
|
+
) : null}
|
|
228
|
+
</div>
|
|
229
|
+
</section>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -10,6 +10,7 @@ import { InsightsConsentBanner } from '@/components/settings/InsightsSettingsPan
|
|
|
10
10
|
import { trackBehavior } from '@/lib/insights';
|
|
11
11
|
import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
|
|
12
12
|
import { deleteChatIfEmpty, pruneEmptyChats } from '@/lib/local-api';
|
|
13
|
+
import { PORTABLE_CHATS_CHANGED_EVENT } from '@/lib/portable';
|
|
13
14
|
import { getPackChatSlots, type PackChatSlotContribution } from '@/packs/registry';
|
|
14
15
|
import {
|
|
15
16
|
useChatsAndAgents,
|
|
@@ -186,6 +187,13 @@ function AgentLayoutContent() {
|
|
|
186
187
|
});
|
|
187
188
|
return unsubscribe;
|
|
188
189
|
}, []);
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
const onImported = () => {
|
|
192
|
+
void refreshChatsRef.current();
|
|
193
|
+
};
|
|
194
|
+
window.addEventListener(PORTABLE_CHATS_CHANGED_EVENT, onImported);
|
|
195
|
+
return () => window.removeEventListener(PORTABLE_CHATS_CHANGED_EVENT, onImported);
|
|
196
|
+
}, []);
|
|
189
197
|
|
|
190
198
|
// 空会话不进侧栏:启动时清掉从未发过消息的残骸;离开一段空对话时丢掉它。
|
|
191
199
|
// except / 离开检测都避开当前打开的 chatId,避免和首页首条发送抢跑。
|
|
@@ -402,6 +410,21 @@ function AgentLayoutContent() {
|
|
|
402
410
|
[setRightPanel],
|
|
403
411
|
);
|
|
404
412
|
|
|
413
|
+
// Cmd+T from the app menu — wired in src/main.ts:createMenu (sends
|
|
414
|
+
// `menu:open-terminal`). The terminal is a toggleable panel beside the chat,
|
|
415
|
+
// so this flips the layout state owned by AgentLayout.
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
if (!hostToolChrome('terminal')) return;
|
|
418
|
+
const bridge = getElectronBridge();
|
|
419
|
+
if (!bridge?.onMenuOpenTerminal) return;
|
|
420
|
+
bridge.onMenuOpenTerminal(() => {
|
|
421
|
+
toggleRightPanel('terminal');
|
|
422
|
+
});
|
|
423
|
+
return () => {
|
|
424
|
+
bridge.offMenuOpenTerminal?.();
|
|
425
|
+
};
|
|
426
|
+
}, [toggleRightPanel]);
|
|
427
|
+
|
|
405
428
|
// dock 里当前显示的任务(null = 显示终端)。recentTask 在切回终端后仍
|
|
406
429
|
// 保留,终端头部的「后台任务」按钮据此切回来——任务的挑选入口只有一个,
|
|
407
430
|
// 就是 chat 头部的后台任务弹层。
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AppReleasePhase, AppReleaseSnapshot } from './electron-bridge';
|
|
2
|
+
|
|
3
|
+
export interface AppReleaseView {
|
|
4
|
+
version: string | null;
|
|
5
|
+
actionLabel: string | null;
|
|
6
|
+
actionTitle?: string;
|
|
7
|
+
clickable: boolean;
|
|
8
|
+
/** 已下载、可以重启安装。 */
|
|
9
|
+
emphasize: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sidebarUpdateClickable(phase: AppReleasePhase): boolean {
|
|
13
|
+
return phase === 'idle' || phase === 'error' || phase === 'ready';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sidebarUpdateLabel(
|
|
17
|
+
snap: AppReleaseSnapshot,
|
|
18
|
+
confirmedCurrent: boolean,
|
|
19
|
+
): string | null {
|
|
20
|
+
if (!snap.enabled || snap.phase === 'disabled') return null;
|
|
21
|
+
switch (snap.phase) {
|
|
22
|
+
case 'checking':
|
|
23
|
+
return '检查中';
|
|
24
|
+
case 'downloading':
|
|
25
|
+
return typeof snap.percent === 'number' ? `下载 ${Math.round(snap.percent)}%` : '下载中';
|
|
26
|
+
case 'ready':
|
|
27
|
+
return snap.availableVersion ? `升级 ${snap.availableVersion}` : '升级';
|
|
28
|
+
case 'installing':
|
|
29
|
+
return '正在安装';
|
|
30
|
+
case 'error':
|
|
31
|
+
return '重试';
|
|
32
|
+
case 'idle':
|
|
33
|
+
if (snap.availableVersion) return `发现 ${snap.availableVersion}`;
|
|
34
|
+
if (snap.message) return snap.message;
|
|
35
|
+
return confirmedCurrent ? '已是最新' : '检查更新';
|
|
36
|
+
default:
|
|
37
|
+
return '检查更新';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sidebarUpdateTitle(snap: AppReleaseSnapshot): string | undefined {
|
|
42
|
+
if (snap.phase === 'error' || (snap.phase === 'idle' && snap.availableVersion)) {
|
|
43
|
+
return snap.message;
|
|
44
|
+
}
|
|
45
|
+
if (snap.phase === 'ready' && snap.availableVersion) {
|
|
46
|
+
return `重启并安装 ${snap.availableVersion}`;
|
|
47
|
+
}
|
|
48
|
+
if (snap.phase === 'downloading') return '正在下载更新';
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function sidebarReleaseView(
|
|
53
|
+
snap: AppReleaseSnapshot | null,
|
|
54
|
+
confirmedCurrent: boolean,
|
|
55
|
+
): AppReleaseView {
|
|
56
|
+
if (!snap?.version) {
|
|
57
|
+
return { version: null, actionLabel: null, clickable: false, emphasize: false };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
version: snap.version,
|
|
61
|
+
actionLabel: sidebarUpdateLabel(snap, confirmedCurrent),
|
|
62
|
+
actionTitle: sidebarUpdateTitle(snap),
|
|
63
|
+
clickable: snap.enabled && sidebarUpdateClickable(snap.phase),
|
|
64
|
+
emphasize: snap.phase === 'ready',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -48,6 +48,7 @@ export interface ApprovalPromptRequest {
|
|
|
48
48
|
mode: string;
|
|
49
49
|
category: string;
|
|
50
50
|
round: number;
|
|
51
|
+
chatId?: string;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
@@ -88,6 +89,51 @@ export interface TerminalSpawnOptions {
|
|
|
88
89
|
rows?: number;
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
export type AppReleasePhase =
|
|
93
|
+
| 'disabled'
|
|
94
|
+
| 'idle'
|
|
95
|
+
| 'checking'
|
|
96
|
+
| 'downloading'
|
|
97
|
+
| 'ready'
|
|
98
|
+
| 'installing'
|
|
99
|
+
| 'error';
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 桌面版本与更新。`version` 是当前安装版本,`availableVersion` 是待安装版本。
|
|
103
|
+
* 与主进程 `AppReleaseSnapshot` 对齐。
|
|
104
|
+
*/
|
|
105
|
+
export interface AppReleaseSnapshot {
|
|
106
|
+
version: string;
|
|
107
|
+
enabled: boolean;
|
|
108
|
+
phase: AppReleasePhase;
|
|
109
|
+
availableVersion?: string;
|
|
110
|
+
percent?: number;
|
|
111
|
+
message?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type PythonRunnerPhase =
|
|
115
|
+
| 'idle'
|
|
116
|
+
| 'downloading'
|
|
117
|
+
| 'verifying'
|
|
118
|
+
| 'extracting'
|
|
119
|
+
| 'ready'
|
|
120
|
+
| 'error';
|
|
121
|
+
|
|
122
|
+
export interface PythonRunnerSnapshot {
|
|
123
|
+
supported: boolean;
|
|
124
|
+
source: 'default' | 'url' | 'local';
|
|
125
|
+
phase: PythonRunnerPhase;
|
|
126
|
+
percent?: number;
|
|
127
|
+
downloadedBytes?: number;
|
|
128
|
+
totalBytes?: number;
|
|
129
|
+
message?: string;
|
|
130
|
+
defaultUrl?: string;
|
|
131
|
+
configuredUrl?: string;
|
|
132
|
+
activeRunner?: string;
|
|
133
|
+
configuredRunner?: string;
|
|
134
|
+
restartRequired: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
91
137
|
export interface HostBridge {
|
|
92
138
|
runtime: 'local';
|
|
93
139
|
platform: NodeJS.Platform;
|
|
@@ -147,6 +193,15 @@ export interface HostBridge {
|
|
|
147
193
|
*/
|
|
148
194
|
onMenuOpenTerminal?: (callback: () => void) => void;
|
|
149
195
|
offMenuOpenTerminal?: () => void;
|
|
196
|
+
/**
|
|
197
|
+
* App menu IPC — `Cmd+V` / 编辑 → 粘贴 fires `menu:paste`.
|
|
198
|
+
* WKWebView does not expose that pasteboard to the page, so the renderer
|
|
199
|
+
* reads it through `readClipboardText`.
|
|
200
|
+
*/
|
|
201
|
+
onMenuPaste?: (callback: () => void) => void;
|
|
202
|
+
offMenuPaste?: () => void;
|
|
203
|
+
/** Plain text from the OS pasteboard. Empty when the clipboard has no text. */
|
|
204
|
+
readClipboardText?: () => Promise<string>;
|
|
150
205
|
/**
|
|
151
206
|
* 异步 AI 标题就绪通知。Backend 在每条 chat 首条助手回复完成后 fire-and-forget
|
|
152
207
|
* 跑 LLM 生成标题;完成时通过这个通道把 `{chatId, title}` 推过来。返回的函数
|
|
@@ -246,6 +301,26 @@ export interface HostBridge {
|
|
|
246
301
|
}) => void,
|
|
247
302
|
) => () => void;
|
|
248
303
|
};
|
|
304
|
+
/**
|
|
305
|
+
* 桌面版本与更新。Electron / Tauri 提供;浏览器预览没有这一段。
|
|
306
|
+
*/
|
|
307
|
+
app?: {
|
|
308
|
+
snapshot: () => Promise<AppReleaseSnapshot>;
|
|
309
|
+
check: () => Promise<AppReleaseSnapshot>;
|
|
310
|
+
install: () => Promise<AppReleaseSnapshot>;
|
|
311
|
+
onState: (callback: (snapshot: AppReleaseSnapshot) => void) => () => void;
|
|
312
|
+
};
|
|
313
|
+
/** Desktop-only Python child interpreter management for run_code. */
|
|
314
|
+
pythonRunner?: {
|
|
315
|
+
snapshot: () => Promise<PythonRunnerSnapshot>;
|
|
316
|
+
download: (url?: string) => Promise<PythonRunnerSnapshot>;
|
|
317
|
+
cancel: () => Promise<PythonRunnerSnapshot>;
|
|
318
|
+
pickLocal: () => Promise<string | null>;
|
|
319
|
+
useLocal: (path: string) => Promise<PythonRunnerSnapshot>;
|
|
320
|
+
useDefault: () => Promise<PythonRunnerSnapshot>;
|
|
321
|
+
restart: () => Promise<void>;
|
|
322
|
+
onState: (callback: (snapshot: PythonRunnerSnapshot) => void) => () => void;
|
|
323
|
+
};
|
|
249
324
|
// 场景包的 invoke 命名空间(如包 preload 贡献的 `<pack>` / `<pack>Mock`)
|
|
250
325
|
// 不进本接口——包用自己的结构化收窄访问器(见各包 web/bridge.ts),
|
|
251
326
|
// shell 桥接口保持产品中立(3.1)。
|
|
@@ -0,0 +1,85 @@
|
|
|
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.
|
|
5
|
+
*/
|
|
6
|
+
import { getHostBridge } from './electron-bridge';
|
|
7
|
+
|
|
8
|
+
let pending = false;
|
|
9
|
+
let lastEditable: HTMLElement | null = null;
|
|
10
|
+
let installed = false;
|
|
11
|
+
|
|
12
|
+
/** Remember the field that should receive a menu paste after focus moves. */
|
|
13
|
+
export function installHostPaste(): void {
|
|
14
|
+
if (installed || typeof document === 'undefined') return;
|
|
15
|
+
installed = true;
|
|
16
|
+
document.addEventListener('focusin', (event) => {
|
|
17
|
+
const target = event.target;
|
|
18
|
+
if (isEditable(target)) lastEditable = target;
|
|
19
|
+
});
|
|
20
|
+
const bridge = getHostBridge();
|
|
21
|
+
bridge?.onMenuPaste?.(() => {
|
|
22
|
+
requestHostPaste();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Read the OS pasteboard and insert it into `target` or the focused field. */
|
|
27
|
+
export function requestHostPaste(target?: HTMLElement | null): void {
|
|
28
|
+
const read = getHostBridge()?.readClipboardText;
|
|
29
|
+
if (!read || pending) return;
|
|
30
|
+
pending = true;
|
|
31
|
+
void read()
|
|
32
|
+
.then((text) => {
|
|
33
|
+
if (text) insertHostText(text, target ?? null);
|
|
34
|
+
})
|
|
35
|
+
.catch((error: unknown) => {
|
|
36
|
+
console.warn('[host-paste] clipboard read failed', error);
|
|
37
|
+
})
|
|
38
|
+
.finally(() => {
|
|
39
|
+
pending = false;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function hostClipboardAvailable(): boolean {
|
|
44
|
+
return typeof getHostBridge()?.readClipboardText === 'function';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isEditable(target: EventTarget | null): target is HTMLElement {
|
|
48
|
+
return (
|
|
49
|
+
target instanceof HTMLInputElement ||
|
|
50
|
+
target instanceof HTMLTextAreaElement ||
|
|
51
|
+
(target instanceof HTMLElement && target.isContentEditable)
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function focusedEditable(): HTMLElement | null {
|
|
56
|
+
if (isEditable(document.activeElement)) return document.activeElement;
|
|
57
|
+
if (lastEditable?.isConnected) return lastEditable;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function insertHostText(text: string, preferred: HTMLElement | null): void {
|
|
62
|
+
const el = preferred?.isConnected ? preferred : focusedEditable();
|
|
63
|
+
if (!el) return;
|
|
64
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
65
|
+
if (el.readOnly || el.disabled) return;
|
|
66
|
+
writeFormValue(el, text);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') {
|
|
70
|
+
el.dispatchEvent(new CustomEvent('hostpaste', { detail: text }));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeFormValue(el: HTMLInputElement | HTMLTextAreaElement, text: string): void {
|
|
75
|
+
const start = el.selectionStart ?? el.value.length;
|
|
76
|
+
const end = el.selectionEnd ?? start;
|
|
77
|
+
const next = `${el.value.slice(0, start)}${text}${el.value.slice(end)}`;
|
|
78
|
+
const caret = start + text.length;
|
|
79
|
+
const prototype =
|
|
80
|
+
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
81
|
+
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
|
|
82
|
+
setter?.call(el, next);
|
|
83
|
+
el.setSelectionRange(caret, caret);
|
|
84
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
85
|
+
}
|
package/src/lib/local-api.ts
CHANGED
|
@@ -25,6 +25,10 @@ export interface LocalChat {
|
|
|
25
25
|
isPinned: boolean;
|
|
26
26
|
systemPrompt: string | null;
|
|
27
27
|
pinnedRefs: unknown;
|
|
28
|
+
/** 是否正在流式生成/对话中。 */
|
|
29
|
+
isStreaming?: boolean;
|
|
30
|
+
/** 是否存在需要用户回答或审批的问题。 */
|
|
31
|
+
needsUserInput?: boolean;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
/** 智能体的工具准入策略;`all` = 不限制。 */
|
|
@@ -133,6 +137,20 @@ export async function deleteChat(chatId: string) {
|
|
|
133
137
|
});
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
/** 置顶或取消置顶指定会话。 */
|
|
141
|
+
export async function setChatPinned(chatId: string, isPinned: boolean) {
|
|
142
|
+
return bridge().localBackend.request<{
|
|
143
|
+
success: boolean;
|
|
144
|
+
chatId: string;
|
|
145
|
+
isPinned: boolean;
|
|
146
|
+
message: string;
|
|
147
|
+
}>({
|
|
148
|
+
method: 'PUT',
|
|
149
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}/pin`,
|
|
150
|
+
body: { isPinned },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
136
154
|
/** 没有消息的会话才删;已有内容或会话不存在都返回 `deleted: false`。 */
|
|
137
155
|
export async function deleteChatIfEmpty(chatId: string) {
|
|
138
156
|
return bridge().localBackend.request<{
|