@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,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
+ }
@@ -8,7 +8,11 @@
8
8
  import { invoke } from '@tauri-apps/api/core';
9
9
  import { listen, type UnlistenFn } from '@tauri-apps/api/event';
10
10
  import html2canvas from 'html2canvas';
11
- import type { HostBridge } from './electron-bridge';
11
+ import type {
12
+ AppReleaseSnapshot,
13
+ HostBridge,
14
+ PythonRunnerSnapshot,
15
+ } from './electron-bridge';
12
16
  import { createHttpBridge } from './http-bridge';
13
17
 
14
18
  type VoidCallback = () => void;
@@ -38,6 +42,7 @@ export function createTauriBridge(): HostBridge {
38
42
  const bridge = createHttpBridge();
39
43
  const newChatMenu = createMenuSubscription('menu:new-chat');
40
44
  const terminalMenu = createMenuSubscription('menu:open-terminal');
45
+ const pasteMenu = createMenuSubscription('menu:paste');
41
46
 
42
47
  return {
43
48
  ...bridge,
@@ -85,6 +90,61 @@ export function createTauriBridge(): HostBridge {
85
90
  offMenuNewChat: newChatMenu.off,
86
91
  onMenuOpenTerminal: terminalMenu.on,
87
92
  offMenuOpenTerminal: terminalMenu.off,
93
+ onMenuPaste: pasteMenu.on,
94
+ offMenuPaste: pasteMenu.off,
95
+ readClipboardText: () => invoke<string>('host_read_clipboard_text'),
96
+ app: {
97
+ snapshot: () => invoke<AppReleaseSnapshot>('app_release_snapshot'),
98
+ check: () => invoke<AppReleaseSnapshot>('app_release_check'),
99
+ install: () => invoke<AppReleaseSnapshot>('app_release_install'),
100
+ onState(callback) {
101
+ let unlisten: UnlistenFn | undefined;
102
+ let cancelled = false;
103
+ const pending = listen<AppReleaseSnapshot>('app-update-state', (event) => {
104
+ callback(event.payload);
105
+ });
106
+ void pending.then((stop) => {
107
+ if (cancelled) stop();
108
+ else unlisten = stop;
109
+ });
110
+ return () => {
111
+ cancelled = true;
112
+ if (unlisten) unlisten();
113
+ else void pending.then((stop) => stop());
114
+ };
115
+ },
116
+ },
117
+ pythonRunner: {
118
+ snapshot: () => invoke<PythonRunnerSnapshot>('python_runner_snapshot'),
119
+ download: (url) =>
120
+ invoke<PythonRunnerSnapshot>('python_runner_download', {
121
+ url: url ?? null,
122
+ }),
123
+ cancel: () => invoke<PythonRunnerSnapshot>('python_runner_cancel'),
124
+ pickLocal: () => invoke<string | null>('python_runner_pick_local'),
125
+ useLocal: (path) =>
126
+ invoke<PythonRunnerSnapshot>('python_runner_use_local', { path }),
127
+ useDefault: () =>
128
+ invoke<PythonRunnerSnapshot>('python_runner_use_default'),
129
+ restart: () => invoke<void>('python_runner_restart'),
130
+ onState(callback) {
131
+ let unlisten: UnlistenFn | undefined;
132
+ let cancelled = false;
133
+ const pending = listen<PythonRunnerSnapshot>(
134
+ 'python-runner-state',
135
+ (event) => callback(event.payload),
136
+ );
137
+ void pending.then((stop) => {
138
+ if (cancelled) stop();
139
+ else unlisten = stop;
140
+ });
141
+ return () => {
142
+ cancelled = true;
143
+ if (unlisten) unlisten();
144
+ else void pending.then((stop) => stop());
145
+ };
146
+ },
147
+ },
88
148
  };
89
149
  }
90
150
 
package/src/main.tsx CHANGED
@@ -8,6 +8,7 @@ import { SettingsPage } from './pages/SettingsPage';
8
8
  import { getPackRoutes } from './packs/registry';
9
9
  import { getBrandLogoUrl, BRAND_NAME } from './brand';
10
10
  import { getAppShellGate } from './auth/gate';
11
+ import { installHostPaste } from './lib/host-paste';
11
12
  import './styles/index.css';
12
13
 
13
14
  // 浏览器 dev mock 的安装在 bootstrap() 开头(动态 import + DEV 门)。
@@ -98,6 +99,7 @@ export async function bootstrap(): Promise<void> {
98
99
  // (品牌 logo 由包经 setBrandLogoUrl 注入,见 brand.ts)。
99
100
  document.title = BRAND_NAME;
100
101
  document.querySelector<HTMLLinkElement>('link[rel="icon"]')?.setAttribute('href', getBrandLogoUrl());
102
+ installHostPaste();
101
103
 
102
104
  const rootEl = document.getElementById('root');
103
105
  if (!rootEl) {
@@ -1042,6 +1042,22 @@ function AgentChatView({
1042
1042
  ];
1043
1043
  }, [messages, remoteContent, chatId]);
1044
1044
  const effectiveIsStreaming = isStreaming || remoteStreaming;
1045
+
1046
+ useEffect(() => {
1047
+ if (!chatId) return;
1048
+ window.dispatchEvent(
1049
+ new CustomEvent("chat:streaming-change", {
1050
+ detail: { chatId, isStreaming: effectiveIsStreaming },
1051
+ }),
1052
+ );
1053
+ return () => {
1054
+ window.dispatchEvent(
1055
+ new CustomEvent("chat:streaming-change", {
1056
+ detail: { chatId, isStreaming: false },
1057
+ }),
1058
+ );
1059
+ };
1060
+ }, [chatId, effectiveIsStreaming]);
1045
1061
  const effectiveCurrentTurnActions: ExecutedAction[] = remoteStreaming
1046
1062
  ? ((liveStream.executedActions as ExecutedAction[] | undefined) ?? [])
1047
1063
  : currentTurnActions;
@@ -1101,6 +1117,7 @@ function AgentChatView({
1101
1117
  chatSlots={ctx.chatSlots}
1102
1118
  rightPanel={ctx.rightPanel}
1103
1119
  onToggleChatSlot={ctx.onToggleChatSlot}
1120
+ onToggleRightPanel={ctx.onToggleChatSlot}
1104
1121
  />
1105
1122
  }
1106
1123
  onRegenerate={
@@ -1,9 +1,12 @@
1
1
  import { useRef, useState } from 'react';
2
- import { LuActivity, LuBlocks, LuBot, LuChartBar, LuMonitor, LuNetwork, LuPlug, LuSearch, LuSettings, LuShieldCheck } from 'react-icons/lu';
2
+ import { LuActivity, LuBlocks, LuBot, LuChartBar, LuDownload, LuMonitor, LuNetwork, LuPlug, LuSearch, LuSettings, LuShieldCheck } from 'react-icons/lu';
3
3
  import { useOutletContext, useSearchParams } from 'react-router-dom';
4
4
  import { isElectron } from '@/lib/electron-bridge';
5
5
  import { AgentsSettingsPanel } from '@/components/settings/AgentsSettingsPanel';
6
6
  import { AppearanceSettingsPanel } from '@/components/settings/AppearanceSettingsPanel';
7
+ import { AppUpdateSettingsPanel } from '@/components/settings/AppUpdateSettingsPanel';
8
+ import { PortableSettingsPanel } from '@/components/settings/PortableSettingsPanel';
9
+ import { PythonRunnerSettingsPanel } from '@/components/settings/PythonRunnerSettingsPanel';
7
10
  import { DiagnoseSettingsPanel } from '@/components/settings/DiagnoseSettingsPanel';
8
11
  import { InsightsSettingsPanel } from '@/components/settings/InsightsSettingsPanel';
9
12
  import {
@@ -20,6 +23,7 @@ import { UsagePanel } from '@/components/settings/UsagePanel';
20
23
  import { WebSearchSettingsPanel } from '@/components/settings/WebSearchSettingsPanel';
21
24
  import { getPackSettingsPanels } from '@/packs/registry';
22
25
  import { settingsChrome } from '@/lib/host-tools';
26
+ import { isPortableEnabled } from '@/lib/portable';
23
27
  import type { AgentOutletContext } from '@/layouts/AgentLayout';
24
28
 
25
29
  type SettingsSection = 'skills' | 'mcp' | 'agents' | 'general';
@@ -36,7 +40,7 @@ function resolveSection(raw: string | null): SettingsSection {
36
40
  * - `skills` 侧栏「Skill 设置」
37
41
  * - `mcp` 侧栏「MCP 设置」
38
42
  * - `agents` 侧栏「智能体管理」
39
- * - 缺省/`general` 侧栏底「设置」(界面 / 模型 / 搜索 / 用量 / 安全 / 洞察 / 遥测)
43
+ * - 缺省/`general` 侧栏底「设置」(界面 / 模型 / 搜索 / 用量 / 安全 / 洞察 / 遥测 / 关于)
40
44
  *
41
45
  * Panel 数据自管理(挂载即拉取),页面本身不持有后端状态。
42
46
  */
@@ -216,6 +220,19 @@ export function SettingsPage() {
216
220
  <panel.Component />
217
221
  </section>
218
222
  ))}
223
+
224
+ {isPortableEnabled() && (
225
+ <section className="space-y-2" data-testid="settings-section-portable">
226
+ <h2 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
227
+ <LuDownload className="h-3.5 w-3.5 text-agent-muted-foreground" />
228
+ 备份与迁移
229
+ </h2>
230
+ <PortableSettingsPanel />
231
+ </section>
232
+ )}
233
+
234
+ <PythonRunnerSettingsPanel />
235
+ <AppUpdateSettingsPanel />
219
236
  </>
220
237
  )}
221
238
  </div>
@@ -164,3 +164,32 @@ body {
164
164
  .chat-input-surface:has(.chat-input-editor[data-composing]) .chat-input-mirror {
165
165
  visibility: hidden;
166
166
  }
167
+
168
+ /*
169
+ * Thin scrollbars everywhere. The system "always show" bar is about 15px.
170
+ * Kept in the base layer so a utility can still hide a scrollbar.
171
+ */
172
+ @layer base {
173
+ * {
174
+ scrollbar-width: thin;
175
+ scrollbar-color: color-mix(in oklab, var(--color-agent-foreground) 28%, transparent) transparent;
176
+ }
177
+
178
+ *::-webkit-scrollbar {
179
+ width: 6px;
180
+ height: 6px;
181
+ }
182
+
183
+ *::-webkit-scrollbar-track {
184
+ background: transparent;
185
+ }
186
+
187
+ *::-webkit-scrollbar-thumb {
188
+ background: color-mix(in oklab, var(--color-agent-foreground) 28%, transparent);
189
+ border-radius: 999px;
190
+ }
191
+
192
+ *::-webkit-scrollbar-thumb:hover {
193
+ background: color-mix(in oklab, var(--color-agent-foreground) 45%, transparent);
194
+ }
195
+ }
package/src/vite-env.d.ts CHANGED
@@ -10,6 +10,8 @@ interface ImportMetaEnv {
10
10
  readonly VITE_APPROVAL?: string;
11
11
  readonly VITE_CHAT_MODES?: string;
12
12
  readonly VITE_SETTINGS?: string;
13
+ /** `'true'` 才打开对话与配置的导出/导入。缺省关。 */
14
+ readonly VITE_PORTABLE?: string;
13
15
  }
14
16
 
15
17
  interface ImportMeta {
package/vite.base.js CHANGED
@@ -58,6 +58,7 @@ export function createProductViteConfig(options) {
58
58
  'import.meta.env.VITE_APPROVAL': JSON.stringify(product.approval ?? 'host'),
59
59
  'import.meta.env.VITE_CHAT_MODES': JSON.stringify(JSON.stringify(product.chatModes ?? ['agent', 'plan'])),
60
60
  'import.meta.env.VITE_SETTINGS': JSON.stringify(JSON.stringify(product.settings ?? {})),
61
+ 'import.meta.env.VITE_PORTABLE': JSON.stringify(product.portable === true ? 'true' : 'false'),
61
62
  },
62
63
  resolve: {
63
64
  alias: {