@steerable/agent-shell-web 0.6.48 → 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.
@@ -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
+ }
@@ -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
+ }