@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.
@@ -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
+ }
@@ -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<{