@steerable/agent-shell-web 0.6.15

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.
Files changed (121) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +20 -0
  3. package/package.json +69 -0
  4. package/public/favicon.ico +0 -0
  5. package/public/favicon.png +0 -0
  6. package/src/AppShell.tsx +24 -0
  7. package/src/assets/logo-generic.png +0 -0
  8. package/src/brand.test.ts +22 -0
  9. package/src/brand.ts +48 -0
  10. package/src/components/AgentSidebar.test.tsx +127 -0
  11. package/src/components/AgentSidebar.tsx +1010 -0
  12. package/src/components/ChatHeader.test.tsx +230 -0
  13. package/src/components/ChatHeader.tsx +436 -0
  14. package/src/components/DockPanelHeader.tsx +69 -0
  15. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  16. package/src/components/LocalLlmSettingsModal.tsx +72 -0
  17. package/src/components/TerminalPanel.tsx +68 -0
  18. package/src/components/TerminalView.tsx +228 -0
  19. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  20. package/src/components/chat/ApprovalModal.tsx +229 -0
  21. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  22. package/src/components/chat/AskUserModalHost.tsx +61 -0
  23. package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
  24. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  25. package/src/components/chat/AssistantMessage.tsx +469 -0
  26. package/src/components/chat/ChatInput.test.tsx +609 -0
  27. package/src/components/chat/ChatInput.tsx +2068 -0
  28. package/src/components/chat/ChatProjectBadge.tsx +362 -0
  29. package/src/components/chat/EmptyChat.tsx +18 -0
  30. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  31. package/src/components/chat/ExecPolicyPicker.tsx +120 -0
  32. package/src/components/chat/ExecutedActionsCard.tsx +216 -0
  33. package/src/components/chat/InterruptedTurnCard.tsx +53 -0
  34. package/src/components/chat/LocalChatPanel.tsx +408 -0
  35. package/src/components/chat/Markdown.test.tsx +170 -0
  36. package/src/components/chat/Markdown.tsx +432 -0
  37. package/src/components/chat/MessageList.tsx +332 -0
  38. package/src/components/chat/ModelPicker.test.tsx +149 -0
  39. package/src/components/chat/ModelPicker.tsx +289 -0
  40. package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
  41. package/src/components/chat/PlanTodoList.tsx +121 -0
  42. package/src/components/chat/ProjectTrustBanner.tsx +91 -0
  43. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  44. package/src/components/chat/SessionTreeModal.tsx +221 -0
  45. package/src/components/chat/StreamingStatus.tsx +95 -0
  46. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  47. package/src/components/chat/TaskOutcomeCards.tsx +128 -0
  48. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  49. package/src/components/chat/TaskPanelModal.tsx +310 -0
  50. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  51. package/src/components/chat/TaskProcessPanel.tsx +208 -0
  52. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  53. package/src/components/chat/TurnProcessGroup.tsx +193 -0
  54. package/src/components/chat/UnifiedDiff.tsx +31 -0
  55. package/src/components/chat/UserMessage.tsx +155 -0
  56. package/src/components/chat/elapsed.test.ts +42 -0
  57. package/src/components/chat/elapsed.ts +45 -0
  58. package/src/components/chat/executed-actions-model.test.ts +134 -0
  59. package/src/components/chat/executed-actions-model.ts +143 -0
  60. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  61. package/src/components/chat/orchestration-children-model.ts +104 -0
  62. package/src/components/chat/timestamp.ts +24 -0
  63. package/src/components/chat/turn-timeline.test.ts +97 -0
  64. package/src/components/chat/turn-timeline.ts +132 -0
  65. package/src/components/chat/useChatTasks.test.ts +136 -0
  66. package/src/components/chat/useChatTasks.ts +134 -0
  67. package/src/components/chat/useCopy.ts +62 -0
  68. package/src/components/settings/AgentCapabilityFields.tsx +343 -0
  69. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  70. package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
  71. package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
  72. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  73. package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
  74. package/src/components/settings/LlmSettingsPanel.tsx +875 -0
  75. package/src/components/settings/McpSettingsPanel.tsx +466 -0
  76. package/src/components/settings/ModelCapabilityChips.tsx +46 -0
  77. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  78. package/src/components/settings/ModelIdCombobox.tsx +185 -0
  79. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  80. package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
  81. package/src/components/settings/SettingsSaveButton.tsx +35 -0
  82. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  83. package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
  84. package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
  85. package/src/components/settings/UsagePanel.tsx +196 -0
  86. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  87. package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
  88. package/src/components/settings/compat-flags-model.test.ts +71 -0
  89. package/src/components/settings/compat-flags-model.ts +57 -0
  90. package/src/components/settings/llm-vendors.test.ts +171 -0
  91. package/src/components/settings/llm-vendors.ts +275 -0
  92. package/src/components/settings/model-capabilities.test.ts +88 -0
  93. package/src/components/settings/model-capabilities.ts +123 -0
  94. package/src/components/settings/preset-choice-model.test.ts +96 -0
  95. package/src/components/settings/preset-choice-model.ts +75 -0
  96. package/src/fixtures/browser-dev-data.json +315 -0
  97. package/src/hooks/useChatsAndAgents.ts +182 -0
  98. package/src/layouts/AgentLayout.tsx +574 -0
  99. package/src/lib/attachments.ts +78 -0
  100. package/src/lib/browser-dev-electron-mock.ts +640 -0
  101. package/src/lib/chat-title.ts +33 -0
  102. package/src/lib/chat-transport.ts +346 -0
  103. package/src/lib/date-groups.ts +34 -0
  104. package/src/lib/demo-flag.ts +15 -0
  105. package/src/lib/electron-bridge.ts +270 -0
  106. package/src/lib/exec-policy.ts +25 -0
  107. package/src/lib/http-bridge.ts +293 -0
  108. package/src/lib/insights.ts +17 -0
  109. package/src/lib/local-api.ts +714 -0
  110. package/src/lib/pending-first-message.test.ts +17 -0
  111. package/src/lib/pending-first-message.ts +36 -0
  112. package/src/lib/slash-sources.ts +191 -0
  113. package/src/main.tsx +103 -0
  114. package/src/packs/registry.ts +64 -0
  115. package/src/pages/AgentPage.tsx +1226 -0
  116. package/src/pages/ChatPanelPreviewPage.tsx +264 -0
  117. package/src/pages/SettingsPage.test.tsx +102 -0
  118. package/src/pages/SettingsPage.tsx +197 -0
  119. package/src/styles/index.css +156 -0
  120. package/src/vite-env.d.ts +1 -0
  121. package/vite.base.ts +94 -0
@@ -0,0 +1,875 @@
1
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
2
+ import {
3
+ LuLoaderCircle,
4
+ LuRefreshCw,
5
+ } from 'react-icons/lu';
6
+ import {
7
+ getLlmSettings,
8
+ setLlmSettings,
9
+ getCompatFlags,
10
+ getProviderPresets,
11
+ getCatalogProviders,
12
+ getLlmModels,
13
+ resolveProviderPreset,
14
+ type CompatFlagDescriptor,
15
+ type GatewayModelEntry,
16
+ type LlmSettings,
17
+ type ProviderPresetDescriptor,
18
+ type ProviderPresetOverride,
19
+ } from '@/lib/local-api';
20
+ import {
21
+ FALLBACK_VENDORS,
22
+ defaultModelForVendor,
23
+ inferVendorId,
24
+ llmProviderFromWireKind,
25
+ mergeVendorOptions,
26
+ modelPickerRows,
27
+ usesOpenAiCompatExtras,
28
+ type LiveCatalogStatus,
29
+ type VendorOption,
30
+ } from '@/components/settings/llm-vendors';
31
+ import { ModelCapabilityChips } from '@/components/settings/ModelCapabilityChips';
32
+ import { modelCapabilityChips } from '@/components/settings/model-capabilities';
33
+ import { ModelIdCombobox } from '@/components/settings/ModelIdCombobox';
34
+ import { SettingsSaveButton } from '@/components/settings/SettingsSaveButton';
35
+ import { isElectron } from '@/lib/electron-bridge';
36
+ import {
37
+ COMPAT_AUTO,
38
+ formStateFromOverrides,
39
+ overridesFromFormState,
40
+ type CompatFormState,
41
+ } from '@/components/settings/compat-flags-model';
42
+ import {
43
+ choiceFromPresetMode,
44
+ descriptorLabel,
45
+ overrideFromDescriptor,
46
+ overridesEqual,
47
+ presetModeFromChoice,
48
+ summarizePreset,
49
+ type PresetMode,
50
+ } from '@/components/settings/preset-choice-model';
51
+
52
+ export interface LlmSettingsPanelHandle {
53
+ save: () => Promise<void>;
54
+ }
55
+
56
+ export interface LlmSaveUi {
57
+ saving: boolean;
58
+ savedOk: boolean;
59
+ loading: boolean;
60
+ }
61
+
62
+ interface LlmSettingsPanelProps {
63
+ onSaved?: (settings: LlmSettings) => void;
64
+ onSaveUiChange?: (ui: LlmSaveUi) => void;
65
+ showFooterSave?: boolean;
66
+ }
67
+
68
+ // ─── 厂商参数预制(框架 llm.presets)────────────────────────────────────────
69
+ // 注册表与匹配规则都在框架侧(presets.describe / presets.resolve RPC),这里
70
+ // 只渲染与持久化选择;表单态转换在 settings/preset-choice-model.ts(可单测)。
71
+
72
+ /**
73
+ * LlmSettingsPanel — 本地模型设置表单(服务商 / URL / Key / 模型 / 预制 / 超时)。
74
+ *
75
+ * 两处复用:设置页「设置」分区,以及输入区齿轮打开的 LocalLlmSettingsModal。
76
+ * 挂载时自取当前配置;保存走 local-api `setLlmSettings`。
77
+ */
78
+ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPanelProps>(
79
+ function LlmSettingsPanel(
80
+ { onSaved, onSaveUiChange, showFooterSave = true },
81
+ ref,
82
+ ) {
83
+ const [settings, setSettings] = useState<LlmSettings>({
84
+ provider: 'openai-compat',
85
+ vendorId: 'deepseek',
86
+ model: 'deepseek-chat',
87
+ baseUrl: 'https://api.deepseek.com',
88
+ apiKey: '',
89
+ // temperature 缺省 = 自动(命中厂商预制用预制值,否则不下发)。
90
+ maxTotalTokens: 60000,
91
+ });
92
+ const [vendors, setVendors] = useState<VendorOption[]>(() => mergeVendorOptions(FALLBACK_VENDORS));
93
+ const [liveEntries, setLiveEntries] = useState<GatewayModelEntry[]>([]);
94
+ const [liveCatalogStatus, setLiveCatalogStatus] = useState<LiveCatalogStatus>('idle');
95
+ const [modelsRefreshing, setModelsRefreshing] = useState(false);
96
+ const [keyTest, setKeyTest] = useState<
97
+ | { status: 'idle' }
98
+ | { status: 'testing' }
99
+ | { status: 'ok'; count: number }
100
+ | { status: 'fail'; detail: string }
101
+ >({ status: 'idle' });
102
+ const [loading, setLoading] = useState(false);
103
+ const [saving, setSaving] = useState(false);
104
+ const [savedOk, setSavedOk] = useState(false);
105
+ const savedOkTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
106
+ const [error, setError] = useState<string | null>(null);
107
+
108
+ // 超时设置(秒)。字符串存储以支持"留空 = 用默认值"。
109
+ // execTimeoutSeconds 随 LlmSettings 持久化;场景包的超时项由包自带的
110
+ // 设置面板承载(3.1 起 shell 设置页无包内联区块)。
111
+ const [execTimeoutInput, setExecTimeoutInput] = useState('');
112
+
113
+ // W1.3.2 compat 旗标:词汇表由 sidecar compat.describe 服务化(框架是
114
+ // 单一真源);表单三态字符串,'auto' = 不覆盖、走框架 URL 自动探测。
115
+ const [compatFlags, setCompatFlags] = useState<CompatFlagDescriptor[]>([]);
116
+ const [compatForm, setCompatForm] = useState<CompatFormState>({});
117
+
118
+ // 厂商参数预制:注册表由 sidecar presets.describe 服务化;resolvedPreset
119
+ // 是「自动」档下当前 baseUrl+model 的命中预览(presets.resolve)。
120
+ const [presetMode, setPresetMode] = useState<PresetMode>('auto');
121
+ const [presetList, setPresetList] = useState<ProviderPresetDescriptor[]>([]);
122
+ const [presetPinnedIdx, setPresetPinnedIdx] = useState(-1);
123
+ const [resolvedPreset, setResolvedPreset] = useState<ProviderPresetOverride | null>(null);
124
+
125
+ const reload = useCallback(async () => {
126
+ if (!isElectron()) {
127
+ setError('需要在桌面客户端中打开');
128
+ return;
129
+ }
130
+ setLoading(true);
131
+ setError(null);
132
+ try {
133
+ const data = await getLlmSettings();
134
+ let nextVendors = mergeVendorOptions(FALLBACK_VENDORS);
135
+ try {
136
+ const { providers } = await getCatalogProviders();
137
+ if (providers.length > 0) nextVendors = mergeVendorOptions(providers);
138
+ } catch (err) {
139
+ console.warn('读取服务商目录失败:', err);
140
+ }
141
+ setVendors(nextVendors);
142
+ const vendorId = inferVendorId(data, nextVendors);
143
+ setSettings((prev) => ({
144
+ ...prev,
145
+ ...data,
146
+ vendorId,
147
+ baseUrl: data.baseUrl || '',
148
+ apiKey: data.apiKey || '',
149
+ }));
150
+ // compat 旗标词汇表:sidecar 未就绪时留空(设置区显示提示,不阻塞
151
+ // 其余设置项)。表单初值以已持久化的覆盖为准。
152
+ try {
153
+ const { flags } = await getCompatFlags();
154
+ setCompatFlags(flags);
155
+ setCompatForm(formStateFromOverrides(data.compat, flags));
156
+ } catch (err) {
157
+ console.warn('读取 compat 旗标词汇表失败:', err);
158
+ setCompatFlags([]);
159
+ setCompatForm({});
160
+ }
161
+ // 预制注册表:同样服务化;初值以已持久化的选择为准(钉死的 override
162
+ // 若在注册表里找到同参数行则选中该行,否则按「自定义」展示)。
163
+ try {
164
+ const { presets } = await getProviderPresets();
165
+ setPresetList(presets);
166
+ const mode = presetModeFromChoice(data.presets);
167
+ setPresetMode(mode);
168
+ if (mode === 'pinned' && data.presets?.override) {
169
+ const idx = presets.findIndex((d) =>
170
+ overridesEqual(overrideFromDescriptor(d), data.presets!.override!),
171
+ );
172
+ setPresetPinnedIdx(idx);
173
+ } else {
174
+ setPresetPinnedIdx(-1);
175
+ }
176
+ } catch (err) {
177
+ console.warn('读取厂商预制注册表失败:', err);
178
+ setPresetList([]);
179
+ setPresetMode(presetModeFromChoice(data.presets));
180
+ }
181
+ setExecTimeoutInput(
182
+ data.execTimeoutSeconds && data.execTimeoutSeconds > 0
183
+ ? String(data.execTimeoutSeconds)
184
+ : '',
185
+ );
186
+ } catch (err) {
187
+ setError(err instanceof Error ? err.message : String(err));
188
+ } finally {
189
+ setLoading(false);
190
+ }
191
+ }, []);
192
+
193
+ useEffect(() => {
194
+ void reload();
195
+ }, [reload]);
196
+
197
+ useEffect(
198
+ () => () => {
199
+ if (savedOkTimerRef.current) clearTimeout(savedOkTimerRef.current);
200
+ },
201
+ [],
202
+ );
203
+
204
+ // 「自动」档的命中预览:baseUrl/model 变化后向框架求一次 resolve
205
+ // (防抖 300ms;sidecar 未就绪时静默留空,预览区显示提示)。
206
+ useEffect(() => {
207
+ if (!usesOpenAiCompatExtras(settings.provider) || presetMode !== 'auto') return;
208
+ let cancelled = false;
209
+ const timer = window.setTimeout(() => {
210
+ resolveProviderPreset(settings.baseUrl || undefined, settings.model || undefined)
211
+ .then(({ preset }) => {
212
+ if (!cancelled) setResolvedPreset(preset);
213
+ })
214
+ .catch(() => {
215
+ if (!cancelled) setResolvedPreset(null);
216
+ });
217
+ }, 300);
218
+ return () => {
219
+ cancelled = true;
220
+ window.clearTimeout(timer);
221
+ };
222
+ }, [settings.provider, settings.baseUrl, settings.model, presetMode]);
223
+
224
+ const selectedVendor =
225
+ vendors.find((v) => v.id === (settings.vendorId || inferVendorId(settings, vendors))) ??
226
+ vendors.find((v) => v.id === 'custom') ??
227
+ vendors[0];
228
+
229
+ const catalogModels = selectedVendor?.models ?? [];
230
+ const modelOptions = modelPickerRows(
231
+ liveEntries,
232
+ catalogModels,
233
+ settings.model,
234
+ liveCatalogStatus,
235
+ );
236
+ const selectedEntry = liveEntries.find((entry) => entry.id === settings.model) ?? null;
237
+ const selectedKnownEmpty =
238
+ selectedEntry?.capabilities === 'known' &&
239
+ modelCapabilityChips(selectedEntry, { detail: true }).length === 0;
240
+
241
+ const featuredVendors = vendors.filter((v) => v.featured);
242
+ const otherVendors = vendors.filter((v) => !v.featured);
243
+
244
+ useEffect(() => {
245
+ const baseUrl = settings.baseUrl?.trim();
246
+ if (!baseUrl) {
247
+ setLiveEntries([]);
248
+ setLiveCatalogStatus('idle');
249
+ return;
250
+ }
251
+ let cancelled = false;
252
+ const timer = window.setTimeout(() => {
253
+ getLlmModels({
254
+ baseUrl,
255
+ apiKey: settings.apiKey || undefined,
256
+ provider: settings.provider,
257
+ })
258
+ .then((catalog) => {
259
+ if (cancelled) return;
260
+ setLiveEntries(catalog.models);
261
+ setLiveCatalogStatus(catalog.catalogStatus);
262
+ })
263
+ .catch(() => {
264
+ if (cancelled) return;
265
+ setLiveEntries([]);
266
+ setLiveCatalogStatus('offline');
267
+ });
268
+ }, 400);
269
+ return () => {
270
+ cancelled = true;
271
+ window.clearTimeout(timer);
272
+ };
273
+ }, [settings.baseUrl, settings.apiKey, settings.provider]);
274
+
275
+ useEffect(() => {
276
+ setKeyTest({ status: 'idle' });
277
+ }, [settings.baseUrl, settings.apiKey, settings.provider]);
278
+
279
+ const handleSwitchVendor = (vendorId: string) => {
280
+ const vendor = vendors.find((v) => v.id === vendorId);
281
+ if (!vendor) return;
282
+ setLiveEntries([]);
283
+ setLiveCatalogStatus('idle');
284
+ setSettings((prev) => ({
285
+ ...prev,
286
+ vendorId: vendor.id,
287
+ provider: llmProviderFromWireKind(vendor.wireKind),
288
+ baseUrl: vendor.apiBaseUrl ?? '',
289
+ model: defaultModelForVendor(vendor, prev.model),
290
+ }));
291
+ };
292
+
293
+ const handleRefreshModels = async () => {
294
+ const baseUrl = settings.baseUrl?.trim();
295
+ if (!baseUrl || modelsRefreshing) return;
296
+ setModelsRefreshing(true);
297
+ try {
298
+ const catalog = await getLlmModels({
299
+ baseUrl,
300
+ apiKey: settings.apiKey || undefined,
301
+ provider: settings.provider,
302
+ refresh: true,
303
+ });
304
+ setLiveEntries(catalog.models);
305
+ setLiveCatalogStatus(catalog.catalogStatus);
306
+ } catch {
307
+ setLiveEntries([]);
308
+ setLiveCatalogStatus('offline');
309
+ } finally {
310
+ setModelsRefreshing(false);
311
+ }
312
+ };
313
+
314
+ const handleTestKey = async () => {
315
+ const baseUrl = settings.baseUrl?.trim();
316
+ if (!baseUrl || keyTest.status === 'testing') return;
317
+ setKeyTest({ status: 'testing' });
318
+ try {
319
+ const catalog = await getLlmModels({
320
+ baseUrl,
321
+ apiKey: settings.apiKey || undefined,
322
+ provider: settings.provider,
323
+ refresh: true,
324
+ });
325
+ setLiveEntries(catalog.models);
326
+ setLiveCatalogStatus(catalog.catalogStatus);
327
+ if (catalog.catalogStatus === 'offline') {
328
+ setKeyTest({
329
+ status: 'fail',
330
+ detail: catalog.error || '网关目录不可用',
331
+ });
332
+ return;
333
+ }
334
+ setKeyTest({ status: 'ok', count: catalog.models.length });
335
+ } catch (err) {
336
+ setLiveEntries([]);
337
+ setLiveCatalogStatus('offline');
338
+ setKeyTest({
339
+ status: 'fail',
340
+ detail: err instanceof Error ? err.message : String(err),
341
+ });
342
+ }
343
+ };
344
+
345
+ const handleSave = async () => {
346
+ if (!isElectron()) {
347
+ setError('需要在桌面客户端中打开');
348
+ return;
349
+ }
350
+ setSaving(true);
351
+ setError(null);
352
+ setSavedOk(false);
353
+ try {
354
+ const parseSeconds = (raw: string): number | undefined => {
355
+ const n = parseInt(raw.trim(), 10);
356
+ return Number.isFinite(n) && n > 0 ? n : undefined;
357
+ };
358
+ const execTimeoutSeconds = parseSeconds(execTimeoutInput);
359
+ const saved = await setLlmSettings({
360
+ provider: settings.provider,
361
+ vendorId: settings.vendorId,
362
+ model: settings.model,
363
+ baseUrl: settings.baseUrl?.trim() || undefined,
364
+ apiKey: settings.apiKey?.trim() || undefined,
365
+ temperature: settings.temperature,
366
+ systemPrompt: settings.systemPrompt?.trim() || undefined,
367
+ maxTotalTokens: settings.maxTotalTokens,
368
+ execTimeoutSeconds,
369
+ compat:
370
+ settings.provider === 'openai-compat'
371
+ ? overridesFromFormState(compatForm, compatFlags)
372
+ : undefined,
373
+ presets: usesOpenAiCompatExtras(settings.provider)
374
+ ? choiceFromPresetMode(presetMode, presetPinnedIdx, presetList, settings.presets)
375
+ : undefined,
376
+ });
377
+ setSettings((prev) => ({ ...prev, ...saved }));
378
+ setSavedOk(true);
379
+ onSaved?.(saved);
380
+ if (savedOkTimerRef.current) clearTimeout(savedOkTimerRef.current);
381
+ savedOkTimerRef.current = setTimeout(() => setSavedOk(false), 1500);
382
+ } catch (err) {
383
+ setError(err instanceof Error ? err.message : String(err));
384
+ } finally {
385
+ setSaving(false);
386
+ }
387
+ };
388
+
389
+ const handleSaveRef = useRef(handleSave);
390
+ handleSaveRef.current = handleSave;
391
+ useImperativeHandle(ref, () => ({
392
+ save: () => handleSaveRef.current(),
393
+ }), []);
394
+
395
+ useEffect(() => {
396
+ onSaveUiChange?.({ saving, savedOk, loading });
397
+ }, [saving, savedOk, loading, onSaveUiChange]);
398
+
399
+ return (
400
+ <div className="space-y-4">
401
+ {loading ? (
402
+ <div className="flex items-center gap-2 py-4 text-sm text-agent-muted-foreground">
403
+ <LuLoaderCircle className="h-4 w-4 animate-spin" />
404
+ 加载当前配置...
405
+ </div>
406
+ ) : (
407
+ <>
408
+ <div>
409
+ <label className="mb-1.5 block text-xs font-medium text-agent-muted-foreground">
410
+ 服务商
411
+ </label>
412
+ <select
413
+ data-testid="llm-vendor-select"
414
+ value={selectedVendor?.id ?? 'custom'}
415
+ onChange={(e) => handleSwitchVendor(e.target.value)}
416
+ className="h-9 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-2 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
417
+ >
418
+ {featuredVendors.length > 0 && (
419
+ <optgroup label="常用">
420
+ {featuredVendors.map((v) => (
421
+ <option key={v.id} value={v.id}>
422
+ {v.label}
423
+ </option>
424
+ ))}
425
+ </optgroup>
426
+ )}
427
+ {otherVendors.length > 0 && (
428
+ <optgroup label="全部">
429
+ {otherVendors.map((v) => (
430
+ <option key={v.id} value={v.id}>
431
+ {v.label}
432
+ </option>
433
+ ))}
434
+ </optgroup>
435
+ )}
436
+ </select>
437
+ <p className="mt-1.5 text-[11px] text-agent-muted-foreground">
438
+ {selectedVendor?.id === 'ollama'
439
+ ? '请确认本机已运行 `ollama serve` 并且执行过 `ollama pull <model>`。'
440
+ : selectedVendor?.id === 'custom'
441
+ ? '任意兼容 OpenAI Chat Completions 的网关。Anthropic / Gemini 请从上方列表选择对应服务商。'
442
+ : selectedVendor?.wireKind === 'anthropic'
443
+ ? '走 Anthropic 原生 Messages 协议。'
444
+ : selectedVendor?.wireKind === 'google'
445
+ ? '走 Google Gemini 原生协议。'
446
+ : selectedVendor?.wireKind === 'openai-responses'
447
+ ? '走 OpenAI Responses API(如 xAI Grok)。'
448
+ : '兼容 OpenAI Chat Completions 的云端或本地网关。'}
449
+ </p>
450
+ </div>
451
+
452
+ <div>
453
+ <label className="mb-1.5 block text-xs font-medium text-agent-muted-foreground">
454
+ Base URL
455
+ </label>
456
+ <input
457
+ type="text"
458
+ value={settings.baseUrl || ''}
459
+ onChange={(e) =>
460
+ setSettings((prev) => ({ ...prev, baseUrl: e.target.value }))
461
+ }
462
+ placeholder={
463
+ selectedVendor?.apiBaseUrl ||
464
+ (selectedVendor?.id === 'custom'
465
+ ? 'https://your-gateway.example/v1'
466
+ : '该服务商没有默认地址,请手动填写')
467
+ }
468
+ className="h-9 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
469
+ />
470
+ <p className="mt-1.5 text-[11px] text-agent-muted-foreground">
471
+ {selectedVendor?.apiBaseUrl
472
+ ? '已按服务商填入默认地址,私有部署可改。'
473
+ : '目录里没有默认 URL,需要手动填写。'}
474
+ </p>
475
+ </div>
476
+
477
+ {settings.provider !== 'ollama' && (
478
+ <div>
479
+ <label className="mb-1.5 block text-xs font-medium text-agent-muted-foreground">
480
+ API Key
481
+ </label>
482
+ <div className="flex items-center gap-2">
483
+ <input
484
+ type="password"
485
+ value={settings.apiKey || ''}
486
+ onChange={(e) =>
487
+ setSettings((prev) => ({ ...prev, apiKey: e.target.value }))
488
+ }
489
+ placeholder={
490
+ selectedVendor?.id === 'custom'
491
+ ? 'sk-... (可选,私有部署可留空)'
492
+ : 'sk-... (必填,到服务商控制台创建)'
493
+ }
494
+ className="h-9 min-w-0 flex-1 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
495
+ />
496
+ <button
497
+ type="button"
498
+ data-testid="llm-key-test"
499
+ title="用当前 URL 和 Key 向网关拉一次模型目录"
500
+ disabled={!settings.baseUrl?.trim() || keyTest.status === 'testing'}
501
+ onClick={() => void handleTestKey()}
502
+ className="inline-flex h-9 shrink-0 items-center justify-center rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs font-medium text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-50"
503
+ >
504
+ {keyTest.status === 'testing' ? (
505
+ <LuLoaderCircle className="h-4 w-4 animate-spin" />
506
+ ) : (
507
+ '测试'
508
+ )}
509
+ </button>
510
+ </div>
511
+ <p
512
+ className={[
513
+ 'mt-1.5 text-[11px]',
514
+ keyTest.status === 'fail'
515
+ ? 'text-agent-destructive'
516
+ : keyTest.status === 'idle' && !settings.apiKey?.trim()
517
+ ? 'text-amber-600'
518
+ : 'text-agent-muted-foreground',
519
+ ].join(' ')}
520
+ >
521
+ {keyTest.status === 'testing'
522
+ ? '正在验证凭证…'
523
+ : keyTest.status === 'ok'
524
+ ? `凭证可用,网关返回 ${keyTest.count} 个模型。`
525
+ : keyTest.status === 'fail'
526
+ ? `验证失败:${keyTest.detail}`
527
+ : !settings.apiKey?.trim()
528
+ ? '尚未配置 API Key:到服务商控制台创建密钥(DeepSeek:platform.deepseek.com → API Keys),粘贴到上方输入框,点「测试」验证通过后保存。'
529
+ : '网络搜索在设置页配置:可选用免费搜索或 Tavily 钥;OpenAI 可用这把聊天钥走托管搜索。'}
530
+ </p>
531
+ </div>
532
+ )}
533
+
534
+ <div>
535
+ <label className="mb-1.5 block text-xs font-medium text-agent-muted-foreground">
536
+ 模型
537
+ </label>
538
+ <div className="flex items-center gap-2">
539
+ <div className="min-w-0 flex-1">
540
+ <ModelIdCombobox
541
+ value={settings.model}
542
+ options={modelOptions}
543
+ placeholder={liveEntries[0]?.id || catalogModels[0] || '选择或填写模型 id'}
544
+ onChange={(model) => setSettings((prev) => ({ ...prev, model }))}
545
+ />
546
+ </div>
547
+ <button
548
+ type="button"
549
+ data-testid="llm-model-refresh"
550
+ title="从网关重新拉取模型列表"
551
+ aria-label="刷新模型目录"
552
+ disabled={!settings.baseUrl?.trim() || modelsRefreshing}
553
+ onClick={() => void handleRefreshModels()}
554
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-agent-md border border-agent-border bg-agent-canvas text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
555
+ >
556
+ <LuRefreshCw className={`h-4 w-4 ${modelsRefreshing ? 'animate-spin' : ''}`} />
557
+ </button>
558
+ </div>
559
+ {(liveCatalogStatus === 'live' || liveCatalogStatus === 'stale') && (
560
+ <div
561
+ data-testid="llm-model-capabilities"
562
+ className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px] text-agent-muted-foreground"
563
+ >
564
+ <span>当前</span>
565
+ {selectedEntry ? (
566
+ selectedKnownEmpty ? (
567
+ <span>文本对话</span>
568
+ ) : (
569
+ <ModelCapabilityChips entry={selectedEntry} detail />
570
+ )
571
+ ) : (
572
+ <span>能力未识别(不在网关目录)</span>
573
+ )}
574
+ </div>
575
+ )}
576
+ <p className="mt-1.5 text-[11px] text-agent-muted-foreground">
577
+ {liveCatalogStatus === 'live'
578
+ ? `已从网关拉取 ${liveEntries.length} 个模型,也可直接填写。`
579
+ : liveCatalogStatus === 'stale'
580
+ ? '网关目录刷新失败,正在用上次拉取的缓存;也可直接填写。'
581
+ : liveCatalogStatus === 'idle' && (settings.baseUrl || '').trim()
582
+ ? '正在从网关拉取模型目录…'
583
+ : liveCatalogStatus === 'idle'
584
+ ? '填写 Base URL 后可拉取网关模型目录,也可直接填写。'
585
+ : catalogModels.length > 0
586
+ ? '网关目录不可用,暂用服务商内置列表;也可直接填写。'
587
+ : '网关目录不可用,请直接填写模型 id。'}
588
+ </p>
589
+ </div>
590
+
591
+ {usesOpenAiCompatExtras(settings.provider) && (
592
+ <div className="rounded-agent-md border border-agent-border/60 p-3 space-y-3">
593
+ <div>
594
+ <h4 className="text-xs font-semibold text-agent-muted-foreground uppercase tracking-wide">
595
+ 厂商参数预制
596
+ </h4>
597
+ <p className="mt-1 text-[11px] text-agent-muted-foreground">
598
+ 按厂商文档的最优采样参数(temperature / top_p / top_k 等)自动填充请求。
599
+ “自动”按 Base URL + 模型名匹配内置注册表;手动 Temperature 等显式设置永远优先于预制。
600
+ </p>
601
+ </div>
602
+ <div className="flex gap-1.5">
603
+ {(
604
+ [
605
+ { value: 'auto', label: '自动(推荐)' },
606
+ { value: 'off', label: '关闭' },
607
+ { value: 'pinned', label: '指定预制' },
608
+ ] as { value: PresetMode; label: string }[]
609
+ ).map((opt) => (
610
+ <button
611
+ key={opt.value}
612
+ type="button"
613
+ data-preset-mode={opt.value}
614
+ onClick={() => setPresetMode(opt.value)}
615
+ className={`h-7 flex-1 rounded-agent-md text-xs transition-all ${
616
+ presetMode === opt.value
617
+ ? 'bg-agent-foreground text-agent-canvas'
618
+ : 'bg-agent-muted text-agent-muted-foreground hover:text-agent-foreground'
619
+ }`}
620
+ >
621
+ {opt.label}
622
+ </button>
623
+ ))}
624
+ </div>
625
+ {presetMode === 'pinned' &&
626
+ (presetList.length > 0 ? (
627
+ <select
628
+ value={presetPinnedIdx}
629
+ onChange={(e) => setPresetPinnedIdx(Number(e.target.value))}
630
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
631
+ >
632
+ {presetPinnedIdx === -1 && (
633
+ <option value={-1}>
634
+ {settings.presets?.override ? '自定义(已保存的覆盖)' : '请选择…'}
635
+ </option>
636
+ )}
637
+ {presetList.map((d, i) => (
638
+ <option key={i} value={i}>
639
+ {descriptorLabel(d)}
640
+ </option>
641
+ ))}
642
+ </select>
643
+ ) : (
644
+ <p className="text-[11px] text-agent-muted-foreground">
645
+ 预制注册表由 sidecar 提供;sidecar 未就绪时无可选条目。
646
+ </p>
647
+ ))}
648
+ <p className="text-[11px] text-agent-muted-foreground/80" data-preset-preview>
649
+ {presetMode === 'off'
650
+ ? '已关闭:不下发厂商预制参数。'
651
+ : presetMode === 'pinned'
652
+ ? `生效参数:${summarizePreset(
653
+ presetPinnedIdx >= 0 && presetList[presetPinnedIdx]
654
+ ? overrideFromDescriptor(presetList[presetPinnedIdx])
655
+ : (settings.presets?.override ?? null),
656
+ )}`
657
+ : resolvedPreset
658
+ ? `命中预制:${summarizePreset(resolvedPreset)}`
659
+ : '当前 Base URL + 模型未命中预制,不下发额外采样参数。'}
660
+ </p>
661
+ </div>
662
+ )}
663
+
664
+ {settings.provider === 'openai-compat' && (
665
+ <div className="rounded-agent-md border border-agent-border/60 p-3 space-y-3">
666
+ <div>
667
+ <h4 className="text-xs font-semibold text-agent-muted-foreground uppercase tracking-wide">
668
+ 高级兼容旗标(可选)
669
+ </h4>
670
+ <p className="mt-1 text-[11px] text-agent-muted-foreground">
671
+ 仅当厂商网关与 OpenAI 协议有出入、且自动探测未覆盖时才需要改。“自动”
672
+ = 框架按 Base URL 主机名匹配已知厂商(DeepSeek / Moonshot / OpenRouter /
673
+ DashScope),未命中走 OpenAI 参考行为。
674
+ </p>
675
+ </div>
676
+ {compatFlags.length === 0 ? (
677
+ <p className="text-[11px] text-agent-muted-foreground">
678
+ 旗标词汇表由 sidecar 提供;sidecar 未就绪时此处为空,保存不受影响。
679
+ </p>
680
+ ) : (
681
+ compatFlags.map((flag) => (
682
+ <div key={flag.key} data-compat-flag={flag.key}>
683
+ <label className="mb-1 block text-xs font-medium text-agent-muted-foreground">
684
+ {flag.key}
685
+ </label>
686
+ {flag.kind === 'bool' ? (
687
+ <div className="flex gap-1.5">
688
+ {[
689
+ { value: COMPAT_AUTO, label: '自动' },
690
+ { value: 'true', label: '开' },
691
+ { value: 'false', label: '关' },
692
+ ].map((opt) => (
693
+ <button
694
+ key={opt.value}
695
+ type="button"
696
+ onClick={() =>
697
+ setCompatForm((prev) => ({ ...prev, [flag.key]: opt.value }))
698
+ }
699
+ className={`h-7 flex-1 rounded-agent-md text-xs transition-all ${
700
+ (compatForm[flag.key] ?? COMPAT_AUTO) === opt.value
701
+ ? 'bg-agent-foreground text-agent-canvas'
702
+ : 'bg-agent-muted text-agent-muted-foreground hover:text-agent-foreground'
703
+ }`}
704
+ >
705
+ {opt.label}
706
+ </button>
707
+ ))}
708
+ </div>
709
+ ) : flag.kind.startsWith('enum:') ? (
710
+ <select
711
+ value={compatForm[flag.key] ?? COMPAT_AUTO}
712
+ onChange={(e) =>
713
+ setCompatForm((prev) => ({ ...prev, [flag.key]: e.target.value }))
714
+ }
715
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
716
+ >
717
+ <option value={COMPAT_AUTO}>自动</option>
718
+ {flag.kind
719
+ .slice('enum:'.length)
720
+ .split(',')
721
+ .map((opt) => (
722
+ <option key={opt} value={opt}>
723
+ {opt}
724
+ </option>
725
+ ))}
726
+ </select>
727
+ ) : (
728
+ <input
729
+ type="text"
730
+ value={compatForm[flag.key] === COMPAT_AUTO ? '' : (compatForm[flag.key] ?? '')}
731
+ onChange={(e) =>
732
+ setCompatForm((prev) => ({
733
+ ...prev,
734
+ [flag.key]: e.target.value || COMPAT_AUTO,
735
+ }))
736
+ }
737
+ placeholder="逗号分隔,留空 = 自动"
738
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
739
+ />
740
+ )}
741
+ <p className="mt-1 text-[11px] text-agent-muted-foreground/80">
742
+ {flag.description}
743
+ </p>
744
+ </div>
745
+ ))
746
+ )}
747
+ </div>
748
+ )}
749
+
750
+ <div>
751
+ <div className="mb-1.5 flex items-center justify-between">
752
+ <label className="text-xs font-medium text-agent-muted-foreground">
753
+ Temperature
754
+ {settings.temperature !== undefined && ` (${settings.temperature.toFixed(2)})`}
755
+ </label>
756
+ <div className="flex gap-1">
757
+ {(
758
+ [
759
+ { value: 'auto', label: '自动' },
760
+ { value: 'manual', label: '手动' },
761
+ ] as const
762
+ ).map((opt) => (
763
+ <button
764
+ key={opt.value}
765
+ type="button"
766
+ data-temperature-mode={opt.value}
767
+ onClick={() =>
768
+ setSettings((prev) => ({
769
+ ...prev,
770
+ temperature:
771
+ opt.value === 'auto'
772
+ ? undefined
773
+ : (prev.temperature ?? resolvedPreset?.temperature ?? 0.3),
774
+ }))
775
+ }
776
+ className={`h-6 rounded-agent-md px-2.5 text-[11px] transition-all ${
777
+ (settings.temperature === undefined ? 'auto' : 'manual') === opt.value
778
+ ? 'bg-agent-foreground text-agent-canvas'
779
+ : 'bg-agent-muted text-agent-muted-foreground hover:text-agent-foreground'
780
+ }`}
781
+ >
782
+ {opt.label}
783
+ </button>
784
+ ))}
785
+ </div>
786
+ </div>
787
+ {settings.temperature === undefined ? (
788
+ <p className="text-[11px] text-agent-muted-foreground">
789
+ 自动:命中厂商预制时用预制温度
790
+ {presetMode === 'auto' && resolvedPreset?.temperature != null
791
+ ? `(当前命中:${resolvedPreset.temperature})`
792
+ : ''}
793
+ ,未命中则不下发(走厂商服务端默认)。手动显式值优先于预制。
794
+ </p>
795
+ ) : (
796
+ <input
797
+ type="range"
798
+ min={0}
799
+ max={1}
800
+ step={0.05}
801
+ value={settings.temperature}
802
+ onChange={(e) =>
803
+ setSettings((prev) => ({ ...prev, temperature: Number(e.target.value) }))
804
+ }
805
+ className="w-full"
806
+ />
807
+ )}
808
+ </div>
809
+
810
+ <div>
811
+ <label className="mb-1.5 block text-xs font-medium text-agent-muted-foreground">
812
+ Token 预算限制 (Token Budget Limit)
813
+ </label>
814
+ <input
815
+ type="number"
816
+ value={settings.maxTotalTokens ?? 60000}
817
+ onChange={(e) => {
818
+ const val = parseInt(e.target.value, 10);
819
+ setSettings((prev) => ({ ...prev, maxTotalTokens: isNaN(val) ? undefined : val }));
820
+ }}
821
+ placeholder="60000"
822
+ className="h-9 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
823
+ />
824
+ <p className="mt-1.5 text-[11px] text-agent-muted-foreground">
825
+ 单次对话中累积消耗的最大 Token 数量(默认 60,000)。超限后将自动停止,防止模型死循环或意外消耗过多 Token。
826
+ </p>
827
+ </div>
828
+
829
+ {/* ───── 超时设置 ───── */}
830
+ <div className="border-t border-agent-border/60 pt-4 space-y-4">
831
+ <h4 className="text-xs font-semibold text-agent-muted-foreground uppercase tracking-wide">
832
+ 超时设置
833
+ </h4>
834
+
835
+ <div>
836
+ <label className="mb-1.5 block text-xs font-medium text-agent-muted-foreground">
837
+ 本地命令默认超时(秒)
838
+ </label>
839
+ <input
840
+ type="number"
841
+ min={1}
842
+ value={execTimeoutInput}
843
+ onChange={(e) => setExecTimeoutInput(e.target.value)}
844
+ placeholder="留空 = 默认(后台 30s / 终端 60s)"
845
+ className="h-9 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
846
+ />
847
+ <p className="mt-1.5 text-[11px] text-agent-muted-foreground">
848
+ 执行本地命令时未显式指定超时的默认等待时长。命令行带 “gui” 的启动命令不受此限制:超时后按“程序已启动、仍在运行”处理,不会被判为失败或重复启动。
849
+ </p>
850
+ </div>
851
+
852
+ </div>
853
+ </>
854
+ )}
855
+
856
+ {error && (
857
+ <div className="rounded-agent-md border border-agent-destructive/20 bg-agent-destructive/10 p-2.5 text-xs text-agent-destructive">
858
+ {error}
859
+ </div>
860
+ )}
861
+
862
+ {showFooterSave && (
863
+ <div className="flex justify-end">
864
+ <SettingsSaveButton
865
+ testId="llm-settings-save"
866
+ saving={saving}
867
+ savedOk={savedOk}
868
+ disabled={saving || loading}
869
+ onClick={() => void handleSave()}
870
+ />
871
+ </div>
872
+ )}
873
+ </div>
874
+ );
875
+ });