@steerable/agent-shell-web 0.6.48 → 0.6.50
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 +411 -298
- package/src/components/ChatHeader.tsx +74 -4
- 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/PortableSettingsPanel.tsx +493 -0
- package/src/components/settings/PythonRunnerSettingsPanel.tsx +231 -0
- package/src/layouts/AgentLayout.tsx +23 -0
- package/src/lib/electron-bridge.ts +44 -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 +40 -1
- package/src/main.tsx +2 -0
- package/src/pages/AgentPage.tsx +17 -0
- package/src/pages/SettingsPage.tsx +15 -1
- 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 头部的后台任务弹层。
|
|
@@ -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
|
/**
|
|
@@ -110,6 +111,29 @@ export interface AppReleaseSnapshot {
|
|
|
110
111
|
message?: string;
|
|
111
112
|
}
|
|
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
|
+
|
|
113
137
|
export interface HostBridge {
|
|
114
138
|
runtime: 'local';
|
|
115
139
|
platform: NodeJS.Platform;
|
|
@@ -169,6 +193,15 @@ export interface HostBridge {
|
|
|
169
193
|
*/
|
|
170
194
|
onMenuOpenTerminal?: (callback: () => void) => void;
|
|
171
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>;
|
|
172
205
|
/**
|
|
173
206
|
* 异步 AI 标题就绪通知。Backend 在每条 chat 首条助手回复完成后 fire-and-forget
|
|
174
207
|
* 跑 LLM 生成标题;完成时通过这个通道把 `{chatId, title}` 推过来。返回的函数
|
|
@@ -277,6 +310,17 @@ export interface HostBridge {
|
|
|
277
310
|
install: () => Promise<AppReleaseSnapshot>;
|
|
278
311
|
onState: (callback: (snapshot: AppReleaseSnapshot) => void) => () => void;
|
|
279
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
|
+
};
|
|
280
324
|
// 场景包的 invoke 命名空间(如包 preload 贡献的 `<pack>` / `<pack>Mock`)
|
|
281
325
|
// 不进本接口——包用自己的结构化收窄访问器(见各包 web/bridge.ts),
|
|
282
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<{
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 对话包与配置包的渲染层。产品须把 VITE_PORTABLE 打成 'true' 才出现入口。
|
|
3
|
+
* 界面档位和命令权限存在 localStorage,由这里并进配置包;其余段走本地后端。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getElectronBridge } from '@/lib/electron-bridge';
|
|
7
|
+
import { listChats } from '@/lib/local-api';
|
|
8
|
+
import { persistExecPolicy, readStoredExecPolicy, type ExecPolicy } from '@/lib/exec-policy';
|
|
9
|
+
import {
|
|
10
|
+
isThinkingDisplayMode,
|
|
11
|
+
persistThinkingDisplay,
|
|
12
|
+
readThinkingDisplay,
|
|
13
|
+
type ThinkingDisplayMode,
|
|
14
|
+
} from '@/lib/show-thinking-content';
|
|
15
|
+
|
|
16
|
+
export function isPortableEnabled(): boolean {
|
|
17
|
+
return import.meta.env.VITE_PORTABLE === 'true';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PortablePreviewSection {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
detail: string;
|
|
24
|
+
clientOnly: boolean;
|
|
25
|
+
hasSecret: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const PORTABLE_CHATS_CHANGED_EVENT = 'steerable-portable-chats-changed';
|
|
29
|
+
|
|
30
|
+
export interface PortablePreview {
|
|
31
|
+
kind: 'steerable-config' | 'steerable-chat' | 'steerable-chats';
|
|
32
|
+
includeSecrets: boolean;
|
|
33
|
+
sections: PortablePreviewSection[];
|
|
34
|
+
chat?: {
|
|
35
|
+
title: string;
|
|
36
|
+
messageCount: number;
|
|
37
|
+
attachmentCount: number;
|
|
38
|
+
omittedAttachmentCount: number;
|
|
39
|
+
truncated: boolean;
|
|
40
|
+
count?: number;
|
|
41
|
+
projectName?: string;
|
|
42
|
+
projectCount?: number;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PortableConfigDocument {
|
|
47
|
+
kind: 'steerable-config';
|
|
48
|
+
schemaVersion: number;
|
|
49
|
+
exportedAt: string;
|
|
50
|
+
includeSecrets: boolean;
|
|
51
|
+
sections: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PortableImportResult {
|
|
55
|
+
applied: string[];
|
|
56
|
+
skipped: Array<{ id: string; reason: string }>;
|
|
57
|
+
notes: string[];
|
|
58
|
+
missingSkills: string[];
|
|
59
|
+
clientSections: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PortableChatImportResult {
|
|
63
|
+
chatId: string;
|
|
64
|
+
title: string;
|
|
65
|
+
messageCount: number;
|
|
66
|
+
attachmentsSaved: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CLIENT_SECTION_IDS = ['appearance', 'execPolicy'] as const;
|
|
70
|
+
|
|
71
|
+
export function portableErrorMessage(err: unknown): string {
|
|
72
|
+
if (!(err instanceof Error)) return '操作失败';
|
|
73
|
+
const message = err.message.trim();
|
|
74
|
+
if (!message) return '操作失败';
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(message) as { detail?: unknown; error?: unknown };
|
|
77
|
+
if (typeof parsed.detail === 'string' && parsed.detail.trim()) return parsed.detail;
|
|
78
|
+
if (typeof parsed.error === 'string' && parsed.error.trim()) return parsed.error;
|
|
79
|
+
} catch {
|
|
80
|
+
// 后端在浏览器模式直接给出文案;桌面模式把 JSON 放进 message。
|
|
81
|
+
}
|
|
82
|
+
return message;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parsePortableText(text: string): { ok: true; value: unknown } | { ok: false; error: string } {
|
|
86
|
+
try {
|
|
87
|
+
return { ok: true, value: JSON.parse(text) as unknown };
|
|
88
|
+
} catch {
|
|
89
|
+
return { ok: false, error: '文件不是 JSON' };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function safeDownloadName(stem: string, suffix: string): string {
|
|
94
|
+
const cleaned = stem.replace(/[\\/:*?"<>|]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40);
|
|
95
|
+
return `${cleaned || '导出'}-${suffix}.json`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readLocalSections(): { appearance: { thinkingDisplay: ThinkingDisplayMode }; execPolicy: { policy: ExecPolicy } } {
|
|
99
|
+
return {
|
|
100
|
+
appearance: { thinkingDisplay: readThinkingDisplay() },
|
|
101
|
+
execPolicy: { policy: readStoredExecPolicy() },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 把勾选的本机段并进服务端导出的配置包,并删掉没勾的段。 */
|
|
106
|
+
export function finishConfigDocument(
|
|
107
|
+
document: PortableConfigDocument,
|
|
108
|
+
selected: ReadonlySet<string>,
|
|
109
|
+
): PortableConfigDocument {
|
|
110
|
+
const sections: Record<string, unknown> = {};
|
|
111
|
+
for (const [id, value] of Object.entries(document.sections)) {
|
|
112
|
+
if (selected.has(id)) sections[id] = value;
|
|
113
|
+
}
|
|
114
|
+
const local = readLocalSections();
|
|
115
|
+
if (selected.has('appearance')) sections.appearance = local.appearance;
|
|
116
|
+
if (selected.has('execPolicy')) sections.execPolicy = local.execPolicy;
|
|
117
|
+
return { ...document, sections };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function applyClientSections(
|
|
121
|
+
sections: Record<string, unknown>,
|
|
122
|
+
selected: ReadonlySet<string>,
|
|
123
|
+
): void {
|
|
124
|
+
if (selected.has('appearance')) {
|
|
125
|
+
const appearance = sections.appearance as { thinkingDisplay?: unknown } | undefined;
|
|
126
|
+
if (isThinkingDisplayMode(appearance?.thinkingDisplay)) {
|
|
127
|
+
persistThinkingDisplay(appearance.thinkingDisplay);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (selected.has('execPolicy')) {
|
|
131
|
+
const policy = (sections.execPolicy as { policy?: unknown } | undefined)?.policy;
|
|
132
|
+
if (policy === 'workspace' || policy === 'full') persistExecPolicy(policy);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isClientSection(id: string): boolean {
|
|
137
|
+
return (CLIENT_SECTION_IDS as readonly string[]).includes(id);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
141
|
+
const bridge = getElectronBridge();
|
|
142
|
+
if (!bridge) throw new Error('当前环境没有本地后端');
|
|
143
|
+
return bridge.localBackend.request<T>(body === undefined ? { method, path } : { method, path, body });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function fetchConfigDocument(includeSecrets: boolean): Promise<PortableConfigDocument> {
|
|
147
|
+
const flag = includeSecrets ? '1' : '0';
|
|
148
|
+
return request<PortableConfigDocument>('GET', `/api/v2/portable/config?includeSecrets=${flag}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function fetchChatDocument(chatId: string): Promise<unknown> {
|
|
152
|
+
return request('GET', `/api/v2/chats/${encodeURIComponent(chatId)}/portable`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 设置页导出对话用。按更新时间分页拉全,避免只导出第一页。 */
|
|
156
|
+
export async function listPortableChats(): Promise<Array<{ id: string; title: string }>> {
|
|
157
|
+
const rows: Array<{ id: string; title: string }> = [];
|
|
158
|
+
let page = 1;
|
|
159
|
+
for (;;) {
|
|
160
|
+
const res = await listChats({ page, limit: 100 });
|
|
161
|
+
for (const chat of res.chats) {
|
|
162
|
+
rows.push({ id: chat.id, title: chat.title.trim() || '未命名对话' });
|
|
163
|
+
}
|
|
164
|
+
if (!res.pagination.hasMore || page >= 50) break;
|
|
165
|
+
page += 1;
|
|
166
|
+
}
|
|
167
|
+
return rows;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function previewPortableDocument(document: unknown): Promise<PortablePreview> {
|
|
171
|
+
return request<PortablePreview>('POST', '/api/v2/portable/preview', { document });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function importConfigDocument(
|
|
175
|
+
document: unknown,
|
|
176
|
+
sections: readonly string[],
|
|
177
|
+
): Promise<PortableImportResult> {
|
|
178
|
+
return request<PortableImportResult>('POST', '/api/v2/portable/config', { document, sections });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function importChatDocument(document: unknown): Promise<PortableChatImportResult> {
|
|
182
|
+
const result = await request<PortableChatImportResult>('POST', '/api/v2/portable/chats', { document });
|
|
183
|
+
if (typeof window !== 'undefined') {
|
|
184
|
+
window.dispatchEvent(new Event(PORTABLE_CHATS_CHANGED_EVENT));
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function saveJsonFile(filename: string, data: unknown): Promise<string | null> {
|
|
190
|
+
const content = `${JSON.stringify(data, null, 2)}\n`;
|
|
191
|
+
const save = getElectronBridge()?.local?.saveTextFile;
|
|
192
|
+
if (save) {
|
|
193
|
+
const saved = await save({ title: '导出', defaultPath: filename, content });
|
|
194
|
+
if (saved.canceled || !saved.filePath) return null;
|
|
195
|
+
return saved.filePath;
|
|
196
|
+
}
|
|
197
|
+
if (typeof document === 'undefined') return filename;
|
|
198
|
+
const url = URL.createObjectURL(new Blob([content], { type: 'application/json' }));
|
|
199
|
+
const link = document.createElement('a');
|
|
200
|
+
link.href = url;
|
|
201
|
+
link.download = filename;
|
|
202
|
+
link.click();
|
|
203
|
+
URL.revokeObjectURL(url);
|
|
204
|
+
return filename;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function pickJsonFile(): Promise<string | null> {
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
if (typeof document === 'undefined') {
|
|
210
|
+
resolve(null);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const input = document.createElement('input');
|
|
214
|
+
input.type = 'file';
|
|
215
|
+
input.accept = 'application/json,.json';
|
|
216
|
+
input.onchange = () => {
|
|
217
|
+
const file = input.files?.[0];
|
|
218
|
+
if (!file) {
|
|
219
|
+
resolve(null);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const reader = new FileReader();
|
|
223
|
+
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null);
|
|
224
|
+
reader.onerror = () => resolve(null);
|
|
225
|
+
reader.readAsText(file);
|
|
226
|
+
};
|
|
227
|
+
input.click();
|
|
228
|
+
});
|
|
229
|
+
}
|