@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.
- 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,493 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { LuDownload, LuUpload } from 'react-icons/lu';
|
|
3
|
+
import { hostToolChrome, settingsChrome } from '@/lib/host-tools';
|
|
4
|
+
import { BRAND_NAME } from '@/brand';
|
|
5
|
+
import {
|
|
6
|
+
applyClientSections,
|
|
7
|
+
fetchChatDocument,
|
|
8
|
+
fetchConfigDocument,
|
|
9
|
+
finishConfigDocument,
|
|
10
|
+
importChatDocument,
|
|
11
|
+
importConfigDocument,
|
|
12
|
+
listPortableChats,
|
|
13
|
+
parsePortableText,
|
|
14
|
+
pickJsonFile,
|
|
15
|
+
portableErrorMessage,
|
|
16
|
+
previewPortableDocument,
|
|
17
|
+
safeDownloadName,
|
|
18
|
+
saveJsonFile,
|
|
19
|
+
type PortablePreview,
|
|
20
|
+
} from '@/lib/portable';
|
|
21
|
+
|
|
22
|
+
const EXPORT_SECTIONS: Array<{ id: string; label: string; visible: () => boolean }> = [
|
|
23
|
+
{ id: 'appearance', label: '界面', visible: () => settingsChrome('appearance') },
|
|
24
|
+
{ id: 'execPolicy', label: '命令权限', visible: () => hostToolChrome('local-fs') },
|
|
25
|
+
{ id: 'llm', label: '本地模型', visible: () => settingsChrome('llm') },
|
|
26
|
+
{ id: 'webSearch', label: '网络搜索', visible: () => settingsChrome('web-search') },
|
|
27
|
+
{ id: 'insights', label: '帮助改进产品', visible: () => settingsChrome('insights') },
|
|
28
|
+
{ id: 'telemetry', label: '遥测', visible: () => settingsChrome('telemetry') },
|
|
29
|
+
{ id: 'mcp', label: 'MCP 服务', visible: () => settingsChrome('mcp') },
|
|
30
|
+
{ id: 'agents', label: '智能体', visible: () => settingsChrome('agents') },
|
|
31
|
+
{ id: 'skills', label: '技能引用', visible: () => settingsChrome('skills') },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function projectClause(chat: { projectName?: string; projectCount?: number }): string | null {
|
|
35
|
+
const count = chat.projectCount ?? 0;
|
|
36
|
+
if (count <= 0) return null;
|
|
37
|
+
if (count === 1 && chat.projectName) {
|
|
38
|
+
return `项目「${chat.projectName}」(不含目录里的文件,同名项目沿用本机已有的)`;
|
|
39
|
+
}
|
|
40
|
+
return `${count} 个项目(不含目录里的文件,同名项目沿用本机已有的)`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function describeChat(preview: PortablePreview): string {
|
|
44
|
+
const chat = preview.chat;
|
|
45
|
+
if (!chat) return '将作为新会话导入,不覆盖现有对话。';
|
|
46
|
+
const bits = [`${chat.messageCount} 条消息`];
|
|
47
|
+
if (chat.attachmentCount > 0) bits.push(`${chat.attachmentCount} 个附件`);
|
|
48
|
+
if (chat.omittedAttachmentCount > 0) bits.push(`${chat.omittedAttachmentCount} 个附件因过大未包含`);
|
|
49
|
+
if (chat.truncated) bits.push('消息已达到导出上限');
|
|
50
|
+
const project = projectClause(chat);
|
|
51
|
+
if (project) bits.push(project);
|
|
52
|
+
if ((chat.count ?? 1) > 1) return `将新增 ${chat.count} 条对话,共 ${bits.join(',')}。不覆盖现有会话。`;
|
|
53
|
+
return `将新增对话「${chat.title}」:${bits.join(',')}。不覆盖现有会话。`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function describeImport(preview: PortablePreview): string {
|
|
57
|
+
if (preview.includeSecrets) return '这份配置包含 API Key。导入后会写入本机。';
|
|
58
|
+
return '将按勾选覆盖对应设置。未出现的段保持不动。钥匙默认不在包里,导入后需要自己填写。';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 设置页「备份与迁移」:导出/导入配置和对话。
|
|
63
|
+
* API Key 默认不进配置包。对话一律作为新会话导入。
|
|
64
|
+
*/
|
|
65
|
+
export function PortableSettingsPanel() {
|
|
66
|
+
const visible = EXPORT_SECTIONS.filter((item) => item.visible());
|
|
67
|
+
const [mode, setMode] = useState<'idle' | 'export' | 'export-chats' | 'import' | 'import-chat'>('idle');
|
|
68
|
+
const [selected, setSelected] = useState<Set<string>>(() => new Set(visible.map((item) => item.id)));
|
|
69
|
+
const [chatRows, setChatRows] = useState<Array<{ id: string; title: string }>>([]);
|
|
70
|
+
const [chatIds, setChatIds] = useState<Set<string>>(new Set());
|
|
71
|
+
const [includeSecrets, setIncludeSecrets] = useState(false);
|
|
72
|
+
const [preview, setPreview] = useState<PortablePreview | null>(null);
|
|
73
|
+
const [document, setDocument] = useState<unknown>(null);
|
|
74
|
+
const [busy, setBusy] = useState(false);
|
|
75
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
76
|
+
const [error, setError] = useState<string | null>(null);
|
|
77
|
+
|
|
78
|
+
const toggle = (id: string) => {
|
|
79
|
+
setSelected((current) => {
|
|
80
|
+
const next = new Set(current);
|
|
81
|
+
if (next.has(id)) next.delete(id);
|
|
82
|
+
else next.add(id);
|
|
83
|
+
return next;
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const toggleChat = (id: string) => {
|
|
88
|
+
setChatIds((current) => {
|
|
89
|
+
const next = new Set(current);
|
|
90
|
+
if (next.has(id)) next.delete(id);
|
|
91
|
+
else next.add(id);
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const startExportChats = async () => {
|
|
97
|
+
setError(null);
|
|
98
|
+
setStatus(null);
|
|
99
|
+
setMode('export-chats');
|
|
100
|
+
setBusy(true);
|
|
101
|
+
try {
|
|
102
|
+
const rows = await listPortableChats();
|
|
103
|
+
setChatRows(rows);
|
|
104
|
+
setChatIds(new Set(rows.map((row) => row.id)));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
setError(portableErrorMessage(err));
|
|
107
|
+
setMode('idle');
|
|
108
|
+
} finally {
|
|
109
|
+
setBusy(false);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const confirmExportChats = async () => {
|
|
114
|
+
const chosen = chatRows.filter((row) => chatIds.has(row.id));
|
|
115
|
+
if (chosen.length === 0) {
|
|
116
|
+
setError('请至少选择一条对话');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
setBusy(true);
|
|
120
|
+
setError(null);
|
|
121
|
+
try {
|
|
122
|
+
const chats = [];
|
|
123
|
+
for (const row of chosen) chats.push(await fetchChatDocument(row.id));
|
|
124
|
+
const saved = await saveJsonFile(safeDownloadName(BRAND_NAME, '对话'), {
|
|
125
|
+
kind: 'steerable-chats',
|
|
126
|
+
schemaVersion: 1,
|
|
127
|
+
exportedAt: new Date().toISOString(),
|
|
128
|
+
chats,
|
|
129
|
+
});
|
|
130
|
+
setStatus(saved ? `已保存到 ${saved}` : '已取消');
|
|
131
|
+
if (saved) setMode('idle');
|
|
132
|
+
} catch (err) {
|
|
133
|
+
setError(portableErrorMessage(err));
|
|
134
|
+
} finally {
|
|
135
|
+
setBusy(false);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const startExport = () => {
|
|
140
|
+
setError(null);
|
|
141
|
+
setStatus(null);
|
|
142
|
+
setIncludeSecrets(false);
|
|
143
|
+
setSelected(new Set(visible.map((item) => item.id)));
|
|
144
|
+
setMode('export');
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const confirmExport = async () => {
|
|
148
|
+
setBusy(true);
|
|
149
|
+
setError(null);
|
|
150
|
+
try {
|
|
151
|
+
const fetched = await fetchConfigDocument(includeSecrets);
|
|
152
|
+
const finished = finishConfigDocument(fetched, selected);
|
|
153
|
+
if (Object.keys(finished.sections).length === 0) {
|
|
154
|
+
setError('没有可导出的内容');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const saved = await saveJsonFile(safeDownloadName(BRAND_NAME, '配置'), finished);
|
|
158
|
+
setStatus(saved ? `已保存到 ${saved}` : '已取消');
|
|
159
|
+
if (saved) setMode('idle');
|
|
160
|
+
} catch (err) {
|
|
161
|
+
setError(portableErrorMessage(err));
|
|
162
|
+
} finally {
|
|
163
|
+
setBusy(false);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const startImport = async (target: 'config' | 'chat') => {
|
|
168
|
+
setError(null);
|
|
169
|
+
setStatus(null);
|
|
170
|
+
const text = await pickJsonFile();
|
|
171
|
+
if (!text) return;
|
|
172
|
+
const parsed = parsePortableText(text);
|
|
173
|
+
if (!parsed.ok) {
|
|
174
|
+
setError(parsed.error);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
setBusy(true);
|
|
178
|
+
try {
|
|
179
|
+
const next = await previewPortableDocument(parsed.value);
|
|
180
|
+
const isChat = next.kind === 'steerable-chat' || next.kind === 'steerable-chats';
|
|
181
|
+
if (target === 'chat' && !isChat) {
|
|
182
|
+
setPreview(null);
|
|
183
|
+
setDocument(null);
|
|
184
|
+
setMode('idle');
|
|
185
|
+
setError('这是配置包,请用「导入配置」');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (target === 'config' && isChat) {
|
|
189
|
+
setPreview(null);
|
|
190
|
+
setDocument(null);
|
|
191
|
+
setMode('idle');
|
|
192
|
+
setError('这是对话包,请用「导入对话」');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
setDocument(parsed.value);
|
|
196
|
+
setPreview(next);
|
|
197
|
+
if (isChat) {
|
|
198
|
+
setMode('import-chat');
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
setSelected(new Set(next.sections.map((section) => section.id)));
|
|
202
|
+
setMode('import');
|
|
203
|
+
} catch (err) {
|
|
204
|
+
setError(portableErrorMessage(err));
|
|
205
|
+
} finally {
|
|
206
|
+
setBusy(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const confirmImport = async () => {
|
|
211
|
+
if (!document || !preview) return;
|
|
212
|
+
setBusy(true);
|
|
213
|
+
setError(null);
|
|
214
|
+
try {
|
|
215
|
+
const sections = preview.sections.filter((section) => selected.has(section.id));
|
|
216
|
+
const record = document as { sections?: Record<string, unknown> };
|
|
217
|
+
const serverIds = sections.filter((section) => !section.clientOnly).map((section) => section.id);
|
|
218
|
+
const result = serverIds.length > 0
|
|
219
|
+
? await importConfigDocument(document, serverIds)
|
|
220
|
+
: { applied: [], skipped: [], notes: [], missingSkills: [], clientSections: [] };
|
|
221
|
+
applyClientSections(record.sections ?? {}, selected);
|
|
222
|
+
const appliedLabels = sections
|
|
223
|
+
.filter((section) => section.clientOnly || result.applied.includes(section.id))
|
|
224
|
+
.map((section) => section.label);
|
|
225
|
+
const parts = [`已导入${appliedLabels.length > 0 ? `:${appliedLabels.join('、')}` : ''}`];
|
|
226
|
+
for (const item of result.skipped) parts.push(item.reason);
|
|
227
|
+
for (const note of result.notes) parts.push(note);
|
|
228
|
+
if (result.missingSkills.length > 0) {
|
|
229
|
+
parts.push(`本机没有这些技能:${result.missingSkills.join('、')}`);
|
|
230
|
+
}
|
|
231
|
+
setStatus(parts.join('。'));
|
|
232
|
+
setMode('idle');
|
|
233
|
+
setPreview(null);
|
|
234
|
+
setDocument(null);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
setError(portableErrorMessage(err));
|
|
237
|
+
} finally {
|
|
238
|
+
setBusy(false);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const confirmImportChat = async () => {
|
|
243
|
+
if (!document || !preview) return;
|
|
244
|
+
setBusy(true);
|
|
245
|
+
setError(null);
|
|
246
|
+
try {
|
|
247
|
+
const result = await importChatDocument(document);
|
|
248
|
+
const count = preview.chat?.count ?? 1;
|
|
249
|
+
setStatus(count > 1 ? `已导入 ${count} 条对话` : `已导入对话「${result.title}」`);
|
|
250
|
+
setMode('idle');
|
|
251
|
+
setPreview(null);
|
|
252
|
+
setDocument(null);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
setError(portableErrorMessage(err));
|
|
255
|
+
} finally {
|
|
256
|
+
setBusy(false);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const cancel = () => {
|
|
261
|
+
setMode('idle');
|
|
262
|
+
setPreview(null);
|
|
263
|
+
setDocument(null);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div
|
|
268
|
+
className="space-y-2 rounded-agent-md border border-agent-border bg-agent-card p-2.5"
|
|
269
|
+
data-testid="portable-settings-panel"
|
|
270
|
+
>
|
|
271
|
+
<p className="text-xs leading-relaxed text-agent-muted-foreground">
|
|
272
|
+
导出配置或对话记录,带到另一台电脑后再导入。对话会连同所属项目一起导出,不包含项目目录里的文件。对话作为新会话导入,不覆盖现有记录。API Key 默认不包含。
|
|
273
|
+
</p>
|
|
274
|
+
<div className="flex flex-wrap gap-1.5">
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
onClick={startExport}
|
|
278
|
+
className="inline-flex h-7 items-center gap-1 rounded-full border border-agent-border px-2.5 text-xs text-agent-foreground hover:bg-agent-foreground/5"
|
|
279
|
+
data-testid="portable-export-config"
|
|
280
|
+
>
|
|
281
|
+
<LuDownload className="h-3.5 w-3.5" />
|
|
282
|
+
导出配置
|
|
283
|
+
</button>
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
onClick={() => void startExportChats()}
|
|
287
|
+
disabled={busy}
|
|
288
|
+
className="inline-flex h-7 items-center gap-1 rounded-full border border-agent-border px-2.5 text-xs text-agent-foreground hover:bg-agent-foreground/5 disabled:opacity-60"
|
|
289
|
+
data-testid="portable-export-chats"
|
|
290
|
+
>
|
|
291
|
+
<LuDownload className="h-3.5 w-3.5" />
|
|
292
|
+
导出对话
|
|
293
|
+
</button>
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
onClick={() => void startImport('config')}
|
|
297
|
+
disabled={busy}
|
|
298
|
+
className="inline-flex h-7 items-center gap-1 rounded-full border border-agent-border px-2.5 text-xs text-agent-foreground hover:bg-agent-foreground/5 disabled:opacity-60"
|
|
299
|
+
data-testid="portable-import-config"
|
|
300
|
+
>
|
|
301
|
+
<LuUpload className="h-3.5 w-3.5" />
|
|
302
|
+
导入配置
|
|
303
|
+
</button>
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
onClick={() => void startImport('chat')}
|
|
307
|
+
disabled={busy}
|
|
308
|
+
className="inline-flex h-7 items-center gap-1 rounded-full border border-agent-border px-2.5 text-xs text-agent-foreground hover:bg-agent-foreground/5 disabled:opacity-60"
|
|
309
|
+
data-testid="portable-import-chats"
|
|
310
|
+
>
|
|
311
|
+
<LuUpload className="h-3.5 w-3.5" />
|
|
312
|
+
导入对话
|
|
313
|
+
</button>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
{mode === 'export' && (
|
|
317
|
+
<fieldset className="space-y-1.5" data-testid="portable-export-form">
|
|
318
|
+
<legend className="text-xs font-medium text-agent-foreground">导出哪些段</legend>
|
|
319
|
+
{visible.map((item) => (
|
|
320
|
+
<label key={item.id} className="flex items-center gap-1.5 text-xs text-agent-foreground">
|
|
321
|
+
<input
|
|
322
|
+
type="checkbox"
|
|
323
|
+
checked={selected.has(item.id)}
|
|
324
|
+
onChange={() => toggle(item.id)}
|
|
325
|
+
data-testid={`portable-section-${item.id}`}
|
|
326
|
+
/>
|
|
327
|
+
{item.label}
|
|
328
|
+
</label>
|
|
329
|
+
))}
|
|
330
|
+
<label className="flex items-center gap-1.5 text-xs text-agent-foreground">
|
|
331
|
+
<input
|
|
332
|
+
type="checkbox"
|
|
333
|
+
checked={includeSecrets}
|
|
334
|
+
onChange={(event) => setIncludeSecrets(event.target.checked)}
|
|
335
|
+
data-testid="portable-include-secrets"
|
|
336
|
+
/>
|
|
337
|
+
包含 API Key
|
|
338
|
+
</label>
|
|
339
|
+
<p className="text-[11px] leading-relaxed text-agent-muted-foreground">
|
|
340
|
+
勾选后文件等同于钥匙,不要发给别人,也不要提交到仓库。
|
|
341
|
+
</p>
|
|
342
|
+
<div className="flex gap-1.5">
|
|
343
|
+
<button
|
|
344
|
+
type="button"
|
|
345
|
+
disabled={busy}
|
|
346
|
+
onClick={() => void confirmExport()}
|
|
347
|
+
className="h-7 rounded-full bg-agent-foreground px-2.5 text-xs text-agent-canvas disabled:opacity-60"
|
|
348
|
+
data-testid="portable-export-confirm"
|
|
349
|
+
>
|
|
350
|
+
{busy ? '导出中' : '保存文件'}
|
|
351
|
+
</button>
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
onClick={cancel}
|
|
355
|
+
className="h-7 rounded-full px-2.5 text-xs text-agent-muted-foreground hover:bg-agent-foreground/5"
|
|
356
|
+
>
|
|
357
|
+
取消
|
|
358
|
+
</button>
|
|
359
|
+
</div>
|
|
360
|
+
</fieldset>
|
|
361
|
+
)}
|
|
362
|
+
|
|
363
|
+
{mode === 'export-chats' && (
|
|
364
|
+
<fieldset className="space-y-1.5" data-testid="portable-export-chats-form">
|
|
365
|
+
<legend className="text-xs font-medium text-agent-foreground">导出哪些对话</legend>
|
|
366
|
+
<p className="text-[11px] leading-relaxed text-agent-muted-foreground">
|
|
367
|
+
这些对话所属的项目会一起写进文件,只保留名称和目录位置,不打包目录里的文件。
|
|
368
|
+
</p>
|
|
369
|
+
{busy && chatRows.length === 0 ? (
|
|
370
|
+
<p className="text-xs text-agent-muted-foreground">正在读取对话…</p>
|
|
371
|
+
) : chatRows.length === 0 ? (
|
|
372
|
+
<p className="text-xs text-agent-muted-foreground">还没有对话。</p>
|
|
373
|
+
) : (
|
|
374
|
+
<div className="max-h-48 space-y-1 overflow-y-auto">
|
|
375
|
+
<label className="flex items-center gap-1.5 text-xs text-agent-foreground">
|
|
376
|
+
<input
|
|
377
|
+
type="checkbox"
|
|
378
|
+
checked={chatIds.size === chatRows.length}
|
|
379
|
+
onChange={() => {
|
|
380
|
+
setChatIds(chatIds.size === chatRows.length ? new Set() : new Set(chatRows.map((row) => row.id)));
|
|
381
|
+
}}
|
|
382
|
+
data-testid="portable-chats-all"
|
|
383
|
+
/>
|
|
384
|
+
全部({chatRows.length})
|
|
385
|
+
</label>
|
|
386
|
+
{chatRows.map((row) => (
|
|
387
|
+
<label key={row.id} className="flex items-center gap-1.5 text-xs text-agent-foreground">
|
|
388
|
+
<input
|
|
389
|
+
type="checkbox"
|
|
390
|
+
checked={chatIds.has(row.id)}
|
|
391
|
+
onChange={() => toggleChat(row.id)}
|
|
392
|
+
data-testid={`portable-chat-${row.id}`}
|
|
393
|
+
/>
|
|
394
|
+
<span className="min-w-0 truncate">{row.title}</span>
|
|
395
|
+
</label>
|
|
396
|
+
))}
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
<div className="flex gap-1.5">
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
disabled={busy || chatRows.length === 0}
|
|
403
|
+
onClick={() => void confirmExportChats()}
|
|
404
|
+
className="h-7 rounded-full bg-agent-foreground px-2.5 text-xs text-agent-canvas disabled:opacity-60"
|
|
405
|
+
data-testid="portable-export-chats-confirm"
|
|
406
|
+
>
|
|
407
|
+
{busy ? '导出中' : '保存文件'}
|
|
408
|
+
</button>
|
|
409
|
+
<button
|
|
410
|
+
type="button"
|
|
411
|
+
onClick={cancel}
|
|
412
|
+
className="h-7 rounded-full px-2.5 text-xs text-agent-muted-foreground hover:bg-agent-foreground/5"
|
|
413
|
+
>
|
|
414
|
+
取消
|
|
415
|
+
</button>
|
|
416
|
+
</div>
|
|
417
|
+
</fieldset>
|
|
418
|
+
)}
|
|
419
|
+
|
|
420
|
+
{mode === 'import-chat' && preview && (
|
|
421
|
+
<div className="space-y-1.5" data-testid="portable-import-chat-form">
|
|
422
|
+
<p className="text-xs leading-relaxed text-agent-foreground">{describeChat(preview)}</p>
|
|
423
|
+
<div className="flex gap-1.5">
|
|
424
|
+
<button
|
|
425
|
+
type="button"
|
|
426
|
+
disabled={busy}
|
|
427
|
+
onClick={() => void confirmImportChat()}
|
|
428
|
+
className="h-7 rounded-full bg-agent-foreground px-2.5 text-xs text-agent-canvas disabled:opacity-60"
|
|
429
|
+
data-testid="portable-import-chat-confirm"
|
|
430
|
+
>
|
|
431
|
+
{busy ? '导入中' : '导入对话'}
|
|
432
|
+
</button>
|
|
433
|
+
<button
|
|
434
|
+
type="button"
|
|
435
|
+
onClick={cancel}
|
|
436
|
+
className="h-7 rounded-full px-2.5 text-xs text-agent-muted-foreground hover:bg-agent-foreground/5"
|
|
437
|
+
>
|
|
438
|
+
取消
|
|
439
|
+
</button>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
)}
|
|
443
|
+
|
|
444
|
+
{mode === 'import' && preview && (
|
|
445
|
+
<fieldset className="space-y-1.5" data-testid="portable-import-form">
|
|
446
|
+
<legend className="text-xs font-medium text-agent-foreground">导入哪些段</legend>
|
|
447
|
+
<p className="text-[11px] leading-relaxed text-agent-muted-foreground">{describeImport(preview)}</p>
|
|
448
|
+
{preview.sections.map((section) => (
|
|
449
|
+
<label key={section.id} className="flex items-center gap-1.5 text-xs text-agent-foreground">
|
|
450
|
+
<input
|
|
451
|
+
type="checkbox"
|
|
452
|
+
checked={selected.has(section.id)}
|
|
453
|
+
onChange={() => toggle(section.id)}
|
|
454
|
+
data-testid={`portable-import-section-${section.id}`}
|
|
455
|
+
/>
|
|
456
|
+
<span>{section.label}</span>
|
|
457
|
+
<span className="text-agent-muted-foreground">{section.detail}</span>
|
|
458
|
+
</label>
|
|
459
|
+
))}
|
|
460
|
+
<div className="flex gap-1.5">
|
|
461
|
+
<button
|
|
462
|
+
type="button"
|
|
463
|
+
disabled={busy}
|
|
464
|
+
onClick={() => void confirmImport()}
|
|
465
|
+
className="h-7 rounded-full bg-agent-foreground px-2.5 text-xs text-agent-canvas disabled:opacity-60"
|
|
466
|
+
data-testid="portable-import-confirm"
|
|
467
|
+
>
|
|
468
|
+
{busy ? '导入中' : '导入配置'}
|
|
469
|
+
</button>
|
|
470
|
+
<button
|
|
471
|
+
type="button"
|
|
472
|
+
onClick={cancel}
|
|
473
|
+
className="h-7 rounded-full px-2.5 text-xs text-agent-muted-foreground hover:bg-agent-foreground/5"
|
|
474
|
+
>
|
|
475
|
+
取消
|
|
476
|
+
</button>
|
|
477
|
+
</div>
|
|
478
|
+
</fieldset>
|
|
479
|
+
)}
|
|
480
|
+
|
|
481
|
+
{status && (
|
|
482
|
+
<p className="text-xs text-agent-muted-foreground" data-testid="portable-status">
|
|
483
|
+
{status}
|
|
484
|
+
</p>
|
|
485
|
+
)}
|
|
486
|
+
{error && (
|
|
487
|
+
<p className="text-xs text-agent-destructive" data-testid="portable-error">
|
|
488
|
+
{error}
|
|
489
|
+
</p>
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
);
|
|
493
|
+
}
|