返回 DeepSeek-Reasonix
SettingsPanel.tsx
根目录 / desktop / frontend / src / components / SettingsPanel.tsx
1 import { saveModelSettings, isModelSettingsResult } from "../lib/modelSettings";
2 import { ModelSettingHelp } from "./ModelSettingHelp";
3 import { desktopHost } from "../lib/desktopHost";
4 import { SettingsOptions } from "./SettingsOptions";
5 import { SettingsSelect } from "./SettingsSelect";
6 import { providerProtocolLabel, providerProtocolChoices } from "../lib/providerProtocol";
7 import { providerSupportsServerWebSearch } from "../lib/providerSearch";
8 export { providerSupportsServerWebSearch } from "../lib/providerSearch";
9 import { ManagementPageShell } from "./ManagementPageShell";
10 import { useProviderT as useT } from "../lib/providerSettingsLocale";
11 import type { ModelDetailsDraft } from "./ProviderModelDialog";
12 import { ConnectionTitle } from "./ConnectionTitle";
13 import { ProviderConnections } from "./ProviderConnections";
14 import { catalogForPreset } from "../lib/providerCatalog";
15 import { ProviderCatalogPicker, type CatalogChoice } from "./ProviderCatalogPicker";
16 import { Eye, EyeOff, Files } from "lucide-react";
17 import { lazy, memo, Suspense, startTransition, useCallback, useDeferredValue, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
18 import { ArrowRight, Check, CheckCircle2, ChevronDown, ChevronUp, CircleDollarSign, Clipboard, ExternalLink, KeyRound, Languages, ListChecks, Loader2, Monitor, MoreHorizontal, PanelBottom, Play, Power, QrCode, RefreshCw, Send, SlidersHorizontal, Trash2, Volume2 } from "lucide-react";
19 import { asArray } from "../lib/array";
20 import { ShellEnvironmentDetails, ShellInterpreterFields } from "./SettingsShellSupport";
21 import { CHANNEL_ICONS } from "./channelIcons";
22 import { botAccessEntryCount, botAccessReady, botConnectionCredentialSummary, botConnectionLabel, botConnectionScopeLabel, botConnectionSecretEnv, botConnectionSecretPatch, botInstallTargetForConnection, botInstallTargetMatchesConnection, botTargetHint, botTargetLabel, diagnosticMessage, diagnosticReportDetail, firstConnectionRemote, formatInstallTimeLeft, formatInstallUserCode, qqBotAdded, type BotInstallTarget, type BotOfficialInstallTarget } from "./botConnectionSettings";
23 import { app, COMPACT_RATIO_MAX_PERCENT, COMPACT_RATIO_MIN_PERCENT, onRuntimeRebuilt, openExternal } from "../lib/bridge";
24 import { normalizeLangPref, useI18n, type DictKey, type LangPref } from "../lib/i18n";
25 import { createLatestRequestGate, mergedFetchedProviderModels, mergeProviderModelContextWindows, providerApiKeyEnvForSave, providerDefaultModel, providerIsConfigured, providerModelCandidates, providerModelContextWindowDrafts, providerRequiresKey } from "../lib/providerModels";
26 import { cachedFetchProviderModelCatalog, cachedFetchProviderModels, invalidateProviderCacheByAPIKeyEnv, shouldSkipAutoRefresh } from "../lib/providerModelCache";
27 import { providerBaseURLForSave, providerEndpointMismatchDetail, providerRequestURLForCatalogFormatChange, providerRequestURLFromConfig, trimmedBaseURL } from "../lib/providerEndpoint";
28 import { providerModelVisionCapability, providerVisionModelsForView } from "../lib/providerVisionCapability";
29 import { useUpdater } from "../lib/useUpdater";
30 import {
31 applyTheme,
32 getTheme,
33 getThemeStyle,
34 normalizeThemePreference,
35 normalizeThemeStyleForTheme,
36 type Theme,
37 type ThemeStyle,
38 } from "../lib/theme";
39 import {
40 applyTerminalThemePreference,
41 createTerminalThemeSaveQueue,
42 getTerminalThemePreference,
43 normalizeTerminalThemePreference,
44 type TerminalThemePreference,
45 } from "../lib/terminalTheme";
46 import {
47 applyConversationWidth,
48 getCachedConversationWidth,
49 normalizeConversationWidth,
50 type ConversationWidth,
51 } from "../lib/conversationWidth";
52 import { applyTextSize, getTextSize, type TextSize } from "../lib/textSize";
53 import { snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
54 import {
55 applyFontFamily,
56 applyMonoFontFamily,
57 getFontFamily,
58 getMonoFontFamily,
59 getCustomFontName,
60 getCustomMonoFontName,
61 setCustomFontName,
62 setCustomMonoFontName,
63 type FontFamily,
64 type MonoFontFamily,
65 } from "../lib/fontFamily";
66 import { SessionExperienceSettings } from "./SessionExperienceSettings";
67 import { SettingsField, SettingsSection } from "./SettingsForm";
68 import { normalizeStatusBarItems, type StatusBarItemId } from "../lib/statusBarItems";
69 import { normalizeToolApprovalMode } from "../lib/types";
70 import {
71 comboFromKeyboardEvent,
72 detectShortcutPlatform,
73 formatShortcutCombo,
74 onShortcutsChanged,
75 resetCustomShortcuts,
76 resolvedShortcutCombo,
77 saveCustomShortcut,
78 shortcutAcceptsCombo,
79 shortcutConflict,
80 shortcutDefinitions,
81 type ShortcutAction,
82 } from "../lib/keyboardShortcuts";
83 import type { BotAccessView, BotAllowlistView, BotConnectionDiagnostic, BotConnectionView, BotInstallStartResult, BotRouteView, BotSettingsView, HookConfigView, HooksSettingsView, NetworkView, ProviderModelCapabilityView, ProviderModelCatalogUpdate, ProviderPresetView, ProviderView, SettingsTab, SettingsView } from "../lib/types";
84 import { AppearanceOverview } from "./AppearanceOverview";
85 import { applyConfiguredBaseAppearance, setBaseAppearance } from "../lib/themePack";
86 import { InlineConfirmButton } from "./InlineConfirmButton";
87 import { Tooltip } from "./Tooltip";
88 import { AnchoredPopover } from "./AnchoredPopover";
89 import { getGenerativePreset, setGenerativePreset, generativeMusic, type GenerativePreset } from "../lib/generative-music";
90 import { SoundSelect } from "./SoundSelect";
91 import { getSuccessPreference, setSuccessPreference, getAttentionPreference, setAttentionPreference, getNotificationVolume, setNotificationVolume as persistNotificationVolume, playSuccessChime, playAttentionChime, type SoundWavPref } from "../lib/sound";
92 import { NotificationVolumeSlider } from "./NotificationVolumeSlider";
93 import { ShortcutComboDisplay } from "./ShortcutComboDisplay";
94 import { SettingsNavigation, SETTINGS_NAV_TABS } from "./SettingsNavigation";
95 import { StatusBarItemsEditor } from "./StatusBarItemsEditor";
96 import { DesktopCloseBehaviorHint } from "./DesktopCloseBehaviorHint";
97 export type SettingsInitialFocus =
98 | { target: "bot-allowlist"; connectionId?: string; requestId?: number }
99 | { target: "model-access"; requestId?: number; onboarding?: boolean; providerName?: string; sourceTabId?: string }
100 | { target: "model-stats"; requestId: number };
101 type DesktopPlatform = "darwin" | "windows" | "linux";
102
103 const MCPServersSettingsPage = lazy(() => import("./CapabilitiesPanel").then((module) => ({ default: module.MCPServersSettingsPage })));
104 const RemoteHostsPage = lazy(() => import("./RemoteHostsPage").then((module) => ({ default: module.RemoteHostsPage })));
105 const SkillsSettingsPage = lazy(() => import("./CapabilitiesPanel").then((module) => ({ default: module.SkillsSettingsPage })));
106 const PluginsSettingsPage = lazy(() => import("./CapabilitiesPanel").then((module) => ({ default: module.PluginsSettingsPage })));
107 const MemorySettingsPage = lazy(() => import("./MemoryPanel").then((module) => ({ default: module.MemorySettingsPage })));
108 const SubagentsSettingsPage = lazy(() => import("./SubagentsPanel").then((module) => ({ default: module.SubagentsSettingsPage })));
109 const DiagnosticsSettingsPage = lazy(() => import("./DiagnosticsSettingsPage").then((module) => ({ default: module.DiagnosticsSettingsPage })));
110 const StorageSettingsPage = lazy(() => import("./StorageSettingsPage").then((module) => ({ default: module.StorageSettingsPage })));
111 const BrowserControlSettingsPage = lazy(() => import("./BrowserControlSettingsPage").then((module) => ({ default: module.BrowserControlSettingsPage })));
112 const UsageStatsPanel = lazy(() => import("./UsageStatsPanel").then((module) => ({ default: module.UsageStatsPanel })));
113 const QRCodeSVG = lazy(() => import("qrcode.react").then((module) => ({ default: module.QRCodeSVG })));
114
115 // SettingsPanel owns a full-window settings page while the workspace stays mounted.
116 export function SettingsPanel({
117 onClose,
118 onChanged,
119 initialTab,
120 onNavigate,
121 initialFocus,
122 agentRunning = false,
123 desktopPlatform,
124 onUseSubagent,
125 activeWorkspaceKey = "",
126 }: {
127 onClose: () => void;
128 onChanged: (settings?: SettingsView | null) => void;
129 initialTab?: SettingsTab;
130 onNavigate?: (tab: SettingsTab) => void;
131 workspaceRef?: { readonly current: HTMLDivElement | null };
132 initialFocus?: SettingsInitialFocus;
133 agentRunning?: boolean;
134 desktopPlatform: DesktopPlatform;
135 onUseSubagent: (command: string) => void;
136 activeWorkspaceKey?: string;
137 }) {
138 const t = useT();
139 const [s, setS] = useState<SettingsView | null>(null);
140 const [loadingSettings, setLoadingSettings] = useState(true);
141 const [settingsLoadFailed, setSettingsLoadFailed] = useState(false);
142 const [busy, setBusy] = useState(false);
143 const [err, setErr] = useState<string | null>(null);
144 const [warning, setWarning] = useState<string | null>(null);
145 const [modelApplication, setModelApplication] = useState<import("../lib/types").ModelSettingsResult | null>(null);
146 const modelApplicationSeq = useRef(0);
147 const settingsLoadSeq = useRef(0);
148 const settingsApplySeq = useRef(0);
149 const [theme, setThemeState] = useState<Theme>(getTheme());
150 const [themeStyle, setThemeStyleState] = useState<ThemeStyle>(() => getThemeStyle(getTheme()));
151 const [terminalTheme, setTerminalThemeState] = useState<TerminalThemePreference>(getTerminalThemePreference());
152 const [conversationWidth, setConversationWidth] = useState<ConversationWidth>(() => getCachedConversationWidth());
153 const [textSize, setTextSizeState] = useState<TextSize>(getTextSize());
154 const [zoomPct, setZoomPct] = useState<number>(zoomToPercent(getRestartZoom()));
155 const [fontFamily, setFontFamilyState] = useState<FontFamily>(getFontFamily());
156 const [monoFontFamily, setMonoFontFamilyState] = useState<MonoFontFamily>(getMonoFontFamily());
157 const [customFontName, setCustomFontNameState] = useState<string>(getCustomFontName());
158 const [customMonoFontName, setCustomMonoFontNameState] = useState<string>(getCustomMonoFontName());
159 const [tab, setTab] = useState<SettingsTab>(initialTab ?? "general");
160 const settingsContentRef = useRef<HTMLElement>(null);
161 const pendingSubagentCommandRef = useRef<string | null>(null);
162 const requestClose = useCallback(() => {
163 const command = pendingSubagentCommandRef.current;
164 pendingSubagentCommandRef.current = null;
165 onClose();
166 if (command) onUseSubagent(command);
167 }, [onClose, onUseSubagent]);
168 const zoomSaveSeq = useRef(0);
169 const terminalThemeSaveSeq = useRef(0);
170 const terminalThemeSavePending = useRef(false);
171 const terminalThemeSaveQueue = useRef<ReturnType<typeof createTerminalThemeSaveQueue> | null>(null);
172 if (!terminalThemeSaveQueue.current) {
173 terminalThemeSaveQueue.current = createTerminalThemeSaveQueue((next) => app.SetDesktopTerminalTheme(next));
174 }
175
176 const reload = useCallback(async () => {
177 const seq = ++settingsLoadSeq.current;
178 const applicationSeq = ++modelApplicationSeq.current;
179 setLoadingSettings(true);
180 setSettingsLoadFailed(false);
181 try {
182 const [view, application] = await Promise.all([
183 app.Settings(),
184 Promise.resolve().then(() => app.GetModelSettingsApplication()).catch(() => null),
185 ]);
186 const next = normalizeSettingsView(view);
187 if (seq !== settingsLoadSeq.current) return next;
188 setS(next);
189 if (application && applicationSeq === modelApplicationSeq.current) setModelApplication(application);
190 return next;
191 } catch {
192 if (seq !== settingsLoadSeq.current) return null;
193 setSettingsLoadFailed(true);
194 return null;
195 } finally {
196 if (seq === settingsLoadSeq.current) setLoadingSettings(false);
197 }
198 }, []);
199 useEffect(() => {
200 void reload();
201 }, [reload]);
202 const refreshModelApplication = useCallback(async () => {
203 const seq = ++modelApplicationSeq.current;
204 try {
205 const result = await app.GetModelSettingsApplication();
206 if (seq === modelApplicationSeq.current) setModelApplication(result);
207 } catch { /* Keep the last confirmed status until the next read. */ }
208 }, []);
209 useEffect(() => {
210 const refresh = () => { void refreshModelApplication(); };
211 const unsubscribe = onRuntimeRebuilt(refresh);
212 window.addEventListener("focus", refresh);
213 return () => {
214 unsubscribe();
215 window.removeEventListener("focus", refresh);
216 modelApplicationSeq.current += 1;
217 };
218 }, [refreshModelApplication]);
219 useEffect(() => {
220 if (modelApplication?.application !== "pending" && modelApplication?.application !== "failed") return;
221 const timer = window.setInterval(() => { void refreshModelApplication(); }, 2000);
222 return () => window.clearInterval(timer);
223 }, [modelApplication?.application, refreshModelApplication]);
224 useEffect(() => {
225 if (initialTab) setTab(initialTab);
226 }, [initialTab]);
227 useEffect(() => {
228 if (initialFocus?.target === "model-access") setTab("providers");
229 if (initialFocus?.target === "model-stats") setTab("model-stats");
230 }, [initialFocus?.target, initialFocus?.requestId]);
231 useEffect(() => {
232 const content = settingsContentRef.current;
233 if (!content) return;
234 content.scrollTop = 0;
235 content.scrollLeft = 0;
236 }, [tab]);
237 useEffect(() => {
238 if (!s) return;
239 const nextTheme = normalizeThemePreference(s.desktopTheme);
240 const nextStyle = normalizeThemeStyleForTheme(s.desktopThemeStyle, nextTheme);
241 setThemeState(nextTheme);
242 setThemeStyleState(nextStyle);
243 if (!terminalThemeSavePending.current) {
244 setTerminalThemeState(applyTerminalThemePreference(s.desktopTerminalTheme));
245 }
246 setConversationWidth(applyConversationWidth(s.conversationWidth));
247 }, [s?.conversationWidth, s?.desktopTheme, s?.desktopThemeStyle, s?.desktopTerminalTheme]);
248 useEffect(() => {
249 const host = desktopHost();
250 if (host.kind === "none" && desktopPlatform !== "windows") return;
251 let cancelled = false;
252 void (async () => {
253 try {
254 const persisted = host.kind === "electron"
255 ? await host.native.getAppZoom()
256 : await app.GetDesktopZoomFactor();
257 if (cancelled || typeof persisted !== "number" || !Number.isFinite(persisted)) return;
258 const snapped = snapZoom(persisted);
259 saveRestartZoom(snapped);
260 setZoomPct(zoomToPercent(snapped));
261 } catch {
262 // Older mocks or startup races can lack the binding; keep the local fallback.
263 }
264 })();
265 return () => {
266 cancelled = true;
267 };
268 }, [desktopPlatform]);
269
270 // apply runs a mutation, re-reads settings, and refreshes the topbar/model.
271 const pendingSettingsApplies = useRef(0);
272 const apply = useCallback(async (fn: () => Promise<unknown>) => {
273 const seq = ++settingsApplySeq.current;
274 pendingSettingsApplies.current += 1;
275 setBusy(true);
276 setErr(null);
277 setWarning(null);
278 try {
279 const result = await fn();
280 if (seq !== settingsApplySeq.current) return isModelSettingsResult(result) ? result.persisted : true;
281 if (isModelSettingsResult(result)) {
282 modelApplicationSeq.current += 1;
283 setModelApplication(result);
284 }
285 const next = await reload();
286 if (seq !== settingsApplySeq.current) return isModelSettingsResult(result) ? result.persisted : true;
287 onChanged(next);
288 window.dispatchEvent(new Event("reasonix:model-catalog-changed"));
289 if (isModelSettingsResult(result)) {
290 if (!result.persisted) throw new Error(result.issues.map(issue => issue.message).join("\n"));
291 }
292 if (typeof result === "string" && result.trim()) {
293 setWarning(result.trim());
294 }
295 return true;
296 } catch (e) {
297 if (seq !== settingsApplySeq.current) return false;
298 // Settings writes can be two-phase: persistence may succeed before a
299 // runtime refresh reports a real boot error. Re-read the authoritative
300 // state even on failure so the UI never offers an action that already
301 // committed (for example, a DeepSeek protocol upgrade).
302 try {
303 const next = await reload();
304 if (seq !== settingsApplySeq.current) return false;
305 onChanged(next);
306 window.dispatchEvent(new Event("reasonix:model-catalog-changed"));
307 } catch {
308 // Keep the original mutation error; it is the actionable failure.
309 }
310 if (seq !== settingsApplySeq.current) return false;
311 setErr(formatSettingsError(e, t));
312 return false;
313 } finally {
314 pendingSettingsApplies.current -= 1;
315 setBusy(pendingSettingsApplies.current > 0);
316 }
317 }, [reload, onChanged, t]);
318 const backgroundApply = useCallback(async (fn: () => Promise<void>) => {
319 const seq = ++settingsApplySeq.current;
320 setErr(null);
321 setWarning(null);
322 try {
323 await fn();
324 if (seq !== settingsApplySeq.current) return;
325 const next = await reload();
326 if (seq !== settingsApplySeq.current) return;
327 onChanged(next);
328 window.dispatchEvent(new Event("reasonix:model-catalog-changed"));
329 } catch (e) {
330 if (seq !== settingsApplySeq.current) return;
331 setErr(formatSettingsError(e, t));
332 try {
333 const current = await reload();
334 if (seq === settingsApplySeq.current) onChanged(current);
335 } catch { /* Keep the original save issue if readback is unavailable. */ }
336 }
337 }, [reload, onChanged, t]);
338 const setTerminalThemePreference = useCallback((next: TerminalThemePreference) => {
339 const seq = ++terminalThemeSaveSeq.current;
340 const previous = getTerminalThemePreference();
341 terminalThemeSavePending.current = true;
342 setErr(null);
343 setWarning(null);
344 applyTerminalThemePreference(next);
345 setTerminalThemeState(next);
346
347 void terminalThemeSaveQueue.current!(next)
348 .then(async () => {
349 if (seq !== terminalThemeSaveSeq.current) return;
350 const refreshed = await reload();
351 if (seq !== terminalThemeSaveSeq.current) return;
352 terminalThemeSavePending.current = false;
353 onChanged(refreshed);
354 })
355 .catch(async (error) => {
356 if (seq !== terminalThemeSaveSeq.current) return;
357 const refreshed = await reload();
358 if (seq !== terminalThemeSaveSeq.current) return;
359 const restored = normalizeTerminalThemePreference(refreshed?.desktopTerminalTheme ?? previous);
360 applyTerminalThemePreference(restored);
361 setTerminalThemeState(restored);
362 terminalThemeSavePending.current = false;
363 setErr(formatSettingsError(error, t));
364 onChanged(refreshed);
365 });
366 }, [onChanged, reload, t]);
367 const setRestartZoom = useCallback(async (zoom: ZoomLevel) => {
368 const snapped = snapZoom(zoom);
369 const seq = ++zoomSaveSeq.current;
370 setErr(null);
371 setWarning(null);
372 setZoomPct(zoomToPercent(snapped));
373 try {
374 const host = desktopHost();
375 if (host.kind === "electron") await host.native.setAppZoom(snapped);
376 else await app.SetDesktopZoomFactor(snapped);
377 if (seq === zoomSaveSeq.current) saveRestartZoom(snapped);
378 } catch (e) {
379 if (seq !== zoomSaveSeq.current) return;
380 setErr(formatSettingsError(e, t));
381 setZoomPct(zoomToPercent(getRestartZoom()));
382 }
383 }, [t]);
384
385 const selectTab = (next: SettingsTab) => { setTab(next); onNavigate?.(next); };
386
387 // These pages need SettingsView; capability pages load their own data.
388 const needsSettings = tab === "general" || tab === "models" || tab === "providers" || tab === "model-stats" || tab === "bots" || tab === "subagents" || tab === "network" || tab === "permissions" || tab === "sandbox" || tab === "appearance" || tab === "updates";
389 const lazySettingsPageFallback = <div className="empty">{t("settings.loading")}</div>;
390 const settingsNavigationItems = useMemo(() => SETTINGS_NAV_TABS.map((id) => ({
391 id,
392 label: settingsTabLabel(id, t),
393 meta: s ? settingsTabMeta(id, s, t) : "",
394 searchTerms: id === "general" ? [
395 "settings.language", "settings.currency",
396 "settings.closeBehavior",
397 "settings.defaultToolApprovalMode", "settings.sound", "settings.statusBarStyle", "settings.statusBarItems",
398 "settings.hardwareAcceleration", "GPU", "白屏", "闪烁", "渲染",
399 ].map((key) => t(key as DictKey)).join(" ") : "",
400 })), [s, t]);
401
402 return (
403 <ManagementPageShell title={t("settings.title")} className="settings-screen" onBack={requestClose} contentRef={settingsContentRef}
404 navigation={<SettingsNavigation items={settingsNavigationItems} activeTab={tab} onSelect={selectTab} />}>
405 <div className="settings-page-content">
406 {needsSettings && settingsLoadFailed && (
407 <div className="banner banner--error settings-load-error" role="alert">
408 <span>{t("settings.loadFailed")}</span>
409 <button className="btn btn--small" type="button" onClick={() => void reload()}>{t("common.retry")}</button>
410 </div>
411 )}
412 {needsSettings && err && <div className="banner banner--error">{err}</div>}
413 {needsSettings && warning && <div className="banner banner--warning">{warning}</div>}
414 {needsSettings && (tab === "models" || tab === "providers") && modelApplication && (modelApplication.application === "pending" || modelApplication.application === "failed") && (
415 <div className="banner banner--warning" role="status">
416 <span>{t(modelApplication.application === "pending" ? "settings.models.savedPending" : "settings.models.savedApplyFailed")}</span>
417 {modelApplication.issues.map((issue, index) => <span key={`${issue.code}:${index}`}>{issue.message}</span>)}
418 {modelApplication.targets.filter(target => target.application === "failed").map(target => (
419 <button className="btn btn--small" key={target.tabId} type="button" disabled={busy} onClick={() => void apply(() => app.RetryModelSettingsApplication(target.tabId))}>{t("settings.models.applyRetry")}{target.title ? ` · ${target.title}` : ""}</button>
420 ))}
421 </div>
422 )}
423 {needsSettings && !s ? (
424 loadingSettings ? <div className="empty">{t("settings.loading")}</div> : null
425 ) : (
426 <>
427 {tab === "general" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><GeneralSection s={s} busy={busy} apply={apply} agentRunning={agentRunning} /></SettingsPageShell>}
428 {(tab === "models" || tab === "providers" || tab === "model-stats") && s && <SettingsPageShell key="model-pages" s={s} tab={tab} busy={busy} apply={apply}><ModelsSection onOpenProviders={() => selectTab("providers")} s={s} busy={busy} apply={apply} backgroundApply={backgroundApply} onboarding={initialFocus?.target === "model-access" && initialFocus.onboarding} focusProvider={initialFocus?.target === "model-access" ? initialFocus.providerName : undefined} focusRequestId={initialFocus?.target === "model-access" ? initialFocus.requestId : undefined} onOnboardingComplete={onClose} subtab={tab === "providers" ? "access" : tab === "model-stats" ? "stats" : "usage"} /></SettingsPageShell>}
429 {tab === "bots" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><BotsSection s={s} busy={busy} apply={apply} initialFocus={initialFocus} /></SettingsPageShell>}
430 {tab === "mcp" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><MCPServersSettingsPage /></Suspense></SettingsPageShell>}
431 {tab === "remote" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><RemoteHostsPage /></Suspense></SettingsPageShell>}
432 {tab === "skills" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><SkillsSettingsPage activeWorkspaceKey={activeWorkspaceKey} /></Suspense></SettingsPageShell>}
433 {tab === "subagents" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><Suspense fallback={lazySettingsPageFallback}><SubagentsSettingsPage s={s} onUseInChat={(command) => {
434 pendingSubagentCommandRef.current = command;
435 requestClose();
436 }} /></Suspense></SettingsPageShell>}
437 {tab === "plugins" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><PluginsSettingsPage /></Suspense></SettingsPageShell>}
438 {tab === "memory" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><MemorySettingsPage /></Suspense></SettingsPageShell>}
439 {tab === "hooks" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><HooksSection onChanged={onChanged} /></SettingsPageShell>}
440 {tab === "diagnostics" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><DiagnosticsSettingsPage onNavigate={selectTab} /></Suspense></SettingsPageShell>}
441 {tab === "shortcuts" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><ShortcutsSection /></SettingsPageShell>}
442 {tab === "permissions" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><PermissionsSection s={s} busy={busy} apply={apply} /></SettingsPageShell>}
443 {tab === "sandbox" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><SandboxSection s={s} busy={busy} apply={apply} windows={desktopPlatform === "windows"} /></SettingsPageShell>}
444 {tab === "network" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><NetworkSection s={s} busy={busy} apply={apply} /></SettingsPageShell>}
445 {tab === "appearance" && s && (
446 <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}>
447 <AppearanceOverview
448 theme={theme}
449 themeStyle={themeStyle}
450 terminalTheme={terminalTheme}
451 conversationWidth={conversationWidth}
452 textSize={textSize}
453 showDisplayZoom={desktopHost().kind !== "none" || desktopPlatform === "windows"}
454 zoomPct={zoomPct}
455 fontFamily={fontFamily}
456 monoFontFamily={monoFontFamily}
457 customFontName={customFontName}
458 customMonoFontName={customMonoFontName}
459 onTheme={(nextTheme) => {
460 applyConfiguredBaseAppearance(nextTheme, themeStyle);
461 setThemeState(nextTheme);
462 void apply(() => app.SetDesktopAppearance(nextTheme, themeStyle));
463 }}
464 onConversationWidth={(width) => {
465 applyConversationWidth(width);
466 setConversationWidth(width);
467 void apply(() => app.SetDesktopConversationWidth(width));
468 }}
469 onThemeStyle={(style) => {
470 // AppearanceOverview already persists via ActivateBaseStyle /
471 // experience APIs. Parent only mirrors React + DOM state.
472 applyTheme(getTheme(), style, { persist: false });
473 setThemeStyleState(style);
474 setBaseAppearance(getTheme(), style);
475 }}
476 onTerminalTheme={setTerminalThemePreference}
477 onTextSize={(size) => {
478 applyTextSize(size);
479 setTextSizeState(size);
480 }}
481 onRestartZoom={setRestartZoom}
482 onFontFamily={(font) => {
483 applyFontFamily(font);
484 setFontFamilyState(font);
485 }}
486 onMonoFontFamily={(font) => {
487 applyMonoFontFamily(font);
488 setMonoFontFamilyState(font);
489 }}
490 onCustomFontNameChange={(name) => {
491 setCustomFontNameState(name);
492 setCustomFontName(name);
493 applyFontFamily("custom");
494 }}
495 onCustomMonoFontNameChange={(name) => {
496 setCustomMonoFontNameState(name);
497 setCustomMonoFontName(name);
498 applyMonoFontFamily("custom");
499 }}
500 />
501 </SettingsPageShell>
502 )}
503 {tab === "storage" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><StorageSettingsPage /></Suspense></SettingsPageShell>}
504 {tab === "browser" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><BrowserControlSettingsPage /></Suspense></SettingsPageShell>}
505 {tab === "updates" && s && (
506 <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}>
507 <AboutSection
508 configPath={s.configPath}
509 shadowedByPath={s.shadowedByPath}
510 checkUpdates={s.checkUpdates}
511 updaterEnabled={s.updaterEnabled === true}
512 telemetry={s.telemetry !== false}
513 metrics={s.metrics !== false}
514 settingsBusy={busy}
515 applySettings={apply}
516 />
517 </SettingsPageShell>
518 )}
519 </>
520 )}
521 </div>
522 </ManagementPageShell>
523 );
524 }
525
526 function SettingsPageShell({ s: _s, tab, children }: { s: SettingsView | null; tab: SettingsTab; busy: boolean; apply: (fn: () => Promise<unknown>) => Promise<boolean>; children: ReactNode }) {
527 const t = useT();
528 return (
529 <div aria-label={settingsTabPageTitle(tab, t)} className={`settings-page settings-page--${settingsPageKind(tab)} settings-page--${tab}`} data-layout={settingsPageLayout(tab)}>
530 {children}
531 </div>
532 );
533 }
534
535 export function settingsPageLayout(tab: SettingsTab): "form" | "list" | "detail" | "tool" {
536 if (tab === "providers" || tab === "bots") return "detail";
537 if (["model-stats", "diagnostics", "hooks", "updates"].includes(tab)) return "tool";
538 if (["mcp", "remote", "skills", "subagents", "plugins", "memory", "shortcuts"].includes(tab)) return "list";
539 return "form";
540 }
541
542 function settingsPageKind(tab: SettingsTab): "form" | "manager" {
543 switch (tab) {
544 case "providers":
545 case "model-stats":
546 case "bots":
547 case "hooks":
548 case "diagnostics":
549 case "mcp":
550 case "remote":
551 case "skills":
552 case "subagents":
553 case "plugins":
554 case "memory":
555 case "appearance":
556 return "manager";
557 default:
558 return "form";
559 }
560 }
561
562
563 function settingsTabPageTitle(id: SettingsTab, t: ReturnType<typeof useT>): string {
564 switch (id) {
565 case "mcp": return t("settings.tab.mcp");
566 case "skills": return t("settings.tab.skills");
567 case "plugins": return t("settings.tab.plugins");
568 case "memory": return t("settings.tab.memory");
569 case "diagnostics": return t("settings.tab.diagnostics");
570 case "shortcuts": return t("settings.tab.shortcuts");
571 default: return settingsTabLabel(id, t);
572 }
573 }
574
575 type SectionProps = {
576 s: SettingsView;
577 busy: boolean;
578 apply: (fn: () => Promise<unknown>) => Promise<boolean>;
579 };
580
581 type ModelsSectionProps = SectionProps & {
582 onOpenProviders?: () => void;
583 onboarding?: boolean;
584 focusProvider?: string;
585 focusRequestId?: number;
586 onOnboardingComplete?: () => void;
587 backgroundApply: (fn: () => Promise<void>) => Promise<void>;
588 subtab: "usage" | "access" | "stats";
589 };
590
591 function settingsTabLabel(id: SettingsTab, t: ReturnType<typeof useT>): string {
592 switch (id) {
593 case "model-stats": return t("settings.modelTab.stats");
594 case "general":
595 return t("settings.tab.general");
596 case "models":
597 return t("settings.models.preferences");
598 case "providers":
599 return t("settings.models.services");
600 case "bots":
601 return t("settings.tab.bots");
602 case "mcp":
603 return t("settings.tab.mcp");
604 case "remote":
605 return t("settings.tab.remote");
606 case "skills":
607 return t("settings.tab.skills");
608 case "subagents":
609 return t("settings.tab.subagents");
610 case "plugins":
611 return t("settings.tab.plugins");
612 case "memory":
613 return t("settings.tab.memory");
614 case "hooks":
615 return t("settings.tab.hooks");
616 case "diagnostics":
617 return t("settings.tab.diagnostics");
618 case "shortcuts":
619 return t("settings.tab.shortcuts");
620 case "network":
621 return t("settings.tab.network");
622 case "permissions":
623 return t("settings.tab.permissions");
624 case "sandbox":
625 return t("settings.tab.sandbox");
626 case "appearance": return t("settings.tab.appearance");
627 case "storage": return t("settings.tab.storage");
628 case "browser": return t("settings.tab.browser");
629 case "updates":
630 return t("settings.tab.updates");
631 }
632 }
633
634 function settingsTabMeta(id: SettingsTab, s: SettingsView, t: ReturnType<typeof useT>): string {
635 switch (id) {
636 case "model-stats": return "";
637 case "models":
638 return settingsModelMeta(s, t);
639 case "general":
640 return s.sessionExperience === "deep" ? t("settings.sessionExperience.deep") : t("settings.sessionExperience.standard");
641 case "providers":
642 return t("settings.providerCount", { n: s.providers.length });
643 case "bots":
644 return botSettingsMeta(s.bot, t);
645 case "mcp":
646 return t("caps.connectorsTab");
647 case "remote":
648 return t("remote.tabHint");
649 case "skills":
650 return t("settings.tabSub.skills");
651 case "subagents":
652 return t("subagents.tabHint");
653 case "plugins":
654 return t("settings.tabSub.plugins");
655 case "memory":
656 return t("settings.tabSub.memory");
657 case "hooks":
658 return t("settings.tabSub.hooks");
659 case "diagnostics":
660 return t("settings.tabSub.diagnostics");
661 case "shortcuts":
662 return t("settings.tabSub.shortcuts");
663 case "network":
664 return proxyModeLabel(normalizeProxyMode(s.network.proxyMode), t);
665 case "permissions":
666 return "";
667 case "sandbox":
668 return "";
669 case "appearance": return t("settings.appearanceMeta");
670 case "storage": return t("settings.storageMeta");
671 case "browser": return t("settings.browserMeta");
672 case "updates":
673 return t("settings.updatesMeta");
674 }
675 }
676
677 function settingsModelMeta(s: SettingsView, t: ReturnType<typeof useT>): string {
678 const ref = toRef(s.defaultModel, s);
679 if (!ref) return t("common.none");
680 if (!ref.includes("/")) return ref;
681 const [provider, ...modelParts] = ref.split("/");
682 const model = modelParts.join("/") || ref;
683 const providerView = s.providers.find((p) => p.name === provider);
684 return `${modelProviderLabel(provider, providerView, t)} · ${model}`;
685 }
686
687 function botSettingsMeta(bot: BotSettingsView, t: ReturnType<typeof useT>): string {
688 const normalized = normalizeBotSettings(bot);
689 const connections = normalized.connections.length + (qqBotAdded(normalized.qq) ? 1 : 0);
690 if (connections === 0) return t("settings.botNoConnections");
691 if (!normalized.enabled) return t("settings.botDisabledWithConnections", { n: connections });
692 return t("settings.botConnectionCount", { n: connections });
693 }
694
695 export function ShortcutsSection() {
696 const t = useT();
697 const [platform] = useState(() => detectShortcutPlatform());
698 const [revision, setRevision] = useState(0);
699 const [recording, setRecording] = useState<ShortcutAction | null>(null);
700 const [conflict, setConflict] = useState<{ action: ShortcutAction; conflictAction: ShortcutAction } | null>(null);
701 const [unsupportedAction, setUnsupportedAction] = useState<ShortcutAction | null>(null);
702
703 useEffect(() => onShortcutsChanged(() => setRevision((value) => value + 1)), []);
704
705 const definitions = shortcutDefinitions();
706 const commitShortcut = (action: ShortcutAction, event: ReactKeyboardEvent<HTMLButtonElement>) => {
707 if (event.key === "Escape") {
708 event.preventDefault();
709 event.stopPropagation();
710 setConflict(null);
711 setUnsupportedAction(null);
712 setRecording(null);
713 return;
714 }
715 const combo = comboFromKeyboardEvent(event.nativeEvent);
716 if (!combo) return;
717 if (!shortcutAcceptsCombo(action, combo)) {
718 // Let the browser move focus before onBlur cancels recording. Updating
719 // recording state synchronously here can keep focus on the re-rendered
720 // button in WebKit.
721 if (event.key === "Tab") {
722 const recorder = event.currentTarget;
723 queueMicrotask(() => {
724 // Native Tab normally moves focus first. If this WebView does not,
725 // release focus so the recorder cannot become a keyboard trap.
726 if (document.activeElement === recorder) recorder.blur();
727 });
728 return;
729 }
730 event.preventDefault();
731 event.stopPropagation();
732 setConflict(null);
733 setUnsupportedAction(action);
734 return;
735 }
736 event.preventDefault();
737 event.stopPropagation();
738 const conflictDefinition = shortcutConflict(action, combo, platform);
739 if (conflictDefinition) {
740 setUnsupportedAction(null);
741 setConflict({ action, conflictAction: conflictDefinition.action });
742 return;
743 }
744 saveCustomShortcut(action, combo);
745 setConflict(null);
746 setUnsupportedAction(null);
747 setRecording(null);
748 setRevision((value) => value + 1);
749 };
750
751 return (
752 <SettingsSection
753 title={t("settings.shortcutsTitle")}
754 description={t("settings.shortcutsHint")}
755 actions={
756 <button
757 className="chip chip--icon"
758 type="button"
759 title={t("settings.shortcutsResetAll")}
760 aria-label={t("settings.shortcutsResetAll")}
761 onClick={() => {
762 resetCustomShortcuts();
763 setConflict(null);
764 setUnsupportedAction(null);
765 setRecording(null);
766 setRevision((value) => value + 1);
767 }}
768 >
769 <RefreshCw size={14} />
770 </button>
771 }
772 >
773 <div className="shortcuts-settings" data-revision={revision}>
774 {conflict && (
775 <div className="shortcuts-settings__conflict" role="alert">
776 {t("settings.shortcutsConflict", {
777 action: t(definitions.find((definition) => definition.action === conflict.action)?.labelKey ?? "settings.tab.shortcuts"),
778 conflict: t(definitions.find((definition) => definition.action === conflict.conflictAction)?.labelKey ?? "settings.tab.shortcuts"),
779 })}
780 </div>
781 )}
782 {unsupportedAction && (
783 <div className="shortcuts-settings__conflict" role="alert">
784 {t("settings.shortcutsEnterOnly", {
785 action: t(definitions.find((definition) => definition.action === unsupportedAction)?.labelKey ?? "settings.tab.shortcuts"),
786 })}
787 </div>
788 )}
789 {definitions.map((definition) => {
790 const resolved = resolvedShortcutCombo(definition.action, platform);
791 const defaultCombo = definition.defaults[platform];
792 const display = formatShortcutCombo(resolved, platform);
793 const isCustom = formatShortcutCombo(resolved, platform) !== formatShortcutCombo(defaultCombo, platform);
794 const isRecording = recording === definition.action;
795 return (
796 <div className="shortcuts-settings__row" key={definition.action}>
797 <div className="shortcuts-settings__copy">
798 <div className="shortcuts-settings__label">{t(definition.labelKey)}</div>
799 <div className="shortcuts-settings__desc">{t(definition.descriptionKey)}</div>
800 </div>
801 <div className="shortcuts-settings__control">
802 <button
803 className={`shortcuts-settings__key${isRecording ? " shortcuts-settings__key--recording" : ""}${definition.configurable === false ? " shortcuts-settings__key--locked" : ""}`}
804 type="button"
805 data-shortcut-action={definition.action}
806 disabled={definition.configurable === false}
807 aria-label={isRecording ? t("settings.shortcutsRecording") : display}
808 aria-pressed={isRecording}
809 onClick={(event) => {
810 setRecording(definition.action);
811 setConflict(null);
812 setUnsupportedAction(null);
813 // WebKit (the desktop WKWebView) does not focus buttons on
814 // click, and the recorder listens for keys on the button —
815 // without this the recorder never receives any keydown.
816 event.currentTarget.focus();
817 }}
818 onBlur={() => {
819 if (!isRecording) return;
820 setConflict(null);
821 setUnsupportedAction(null);
822 setRecording(null);
823 }}
824 onKeyDown={(event) => isRecording && commitShortcut(definition.action, event)}
825 >
826 {isRecording ? t("settings.shortcutsRecording") : <ShortcutComboDisplay combo={resolved} platform={platform} />}
827 </button>
828 <button
829 className="chip"
830 type="button"
831 disabled={!isCustom}
832 onClick={() => {
833 saveCustomShortcut(definition.action, null);
834 setConflict(null);
835 setUnsupportedAction(null);
836 setRecording(null);
837 setRevision((value) => value + 1);
838 }}
839 >
840 {t("settings.shortcutsReset")}
841 </button>
842 </div>
843 </div>
844 );
845 })}
846 </div>
847 </SettingsSection>
848 );
849 }
850
851 // allRefs flattens providers into "provider/model" refs for the model selectors.
852 export function allRefs(s: SettingsView): string[] {
853 const out: string[] = [];
854 for (const p of s.providers) {
855 if (!p.added || !providerIsConfigured(p)) continue;
856 for (const m of p.models) out.push(`${p.name}/${m}`);
857 }
858 return out;
859 }
860
861 // toRef normalises a stored model id (a provider name, a bare model, or a ref) to
862 // a "provider/model" ref so a <select> of refs can show it selected.
863 export function toRef(model: string, s: SettingsView): string {
864 if (!model) return "";
865 if (model.includes("/")) return model;
866 const byName = s.providers.find((p) => p.name === model);
867 if (byName) return `${byName.name}/${byName.default || byName.models[0] || ""}`;
868 const byModel = s.providers.find((p) => p.models.includes(model));
869 if (byModel) return `${byModel.name}/${model}`;
870 return model;
871 }
872
873 const PROXY_MODES = ["auto", "custom", "off"] as const;
874
875 // EFFORT_PRESETS is the canonical union of /effort levels the kernel recognises.
876 // The settings UI uses it for subagent defaults; provider-specific levels are
877 // inferred by the backend or edited in TOML for rare gateways.
878 export const EFFORT_PRESETS: readonly string[] = ["low", "medium", "high", "xhigh", "max"];
879 const COMPACT_RATIO_PRESETS = [
880 [0.7, "settings.compactRatioPreset.70", "settings.compactRatioPresetEffect.70"],
881 [0.8, "settings.compactRatioPreset.80", "settings.compactRatioPresetEffect.80"],
882 [0.85, "settings.compactRatioPreset.85", "settings.compactRatioPresetEffect.85"],
883 ] as const;
884 const REASONING_PROTOCOLS: readonly string[] = ["", "deepseek", "glm", "kimi-k3", "openai", "none"];
885 const THINKING_MODES: readonly string[] = ["", "enabled", "disabled", "adaptive"];
886 const PROXY_TYPES = ["http", "https", "socks5", "socks5h"] as const;
887 const LANGUAGE_PREFS: LangPref[] = ["", "zh", "en"];
888 const TOOL_APPROVAL_MODES = ["read-only", "workspace-write", "danger-full-access"] as const;
889 const BOT_TOOL_APPROVAL_MODES = ["", "read-only", "workspace-write", "danger-full-access"] as const;
890 const BOT_QUEUE_MODES = ["steer", "followup", "collect", "interrupt"] as const;
891 const BOT_QUEUE_DROPS = ["summarize", "old", "new"] as const;
892 const BOT_ROUTE_CHAT_TYPES = ["", "dm", "group", "guild", "direct", "thread"] as const;
893
894 type ProxyMode = (typeof PROXY_MODES)[number];
895
896 function normalizeProxyMode(mode: string): ProxyMode {
897 switch (mode) {
898 case "custom":
899 return "custom";
900 case "off":
901 return "off";
902 default:
903 return "auto";
904 }
905 }
906
907 function normalizeNetworkView(network: NetworkView): NetworkView {
908 return { ...network, proxyMode: normalizeProxyMode(network.proxyMode) };
909 }
910
911 function normalizeReasoningProtocol(protocol: string | undefined): string {
912 return REASONING_PROTOCOLS.includes(protocol ?? "") ? protocol ?? "" : "";
913 }
914
915 function normalizeThinkingMode(thinking: string | undefined): string {
916 const v = String(thinking ?? "").trim().toLowerCase();
917 return THINKING_MODES.includes(v) ? v : "";
918 }
919
920 export function providerEditorEffectiveKind(isNewCustomProvider: boolean, kind: string, kinds: string[]): string {
921 void isNewCustomProvider;
922 const selected = kind.trim();
923 return selected || kinds[0] || "openai";
924 }
925
926 function formatProviderHeaders(headers: Record<string, string> | null | undefined): string {
927 const entries = Object.entries(headers ?? {})
928 .map(([key, value]) => [key.trim(), String(value ?? "").trim()] as const)
929 .filter(([key, value]) => key && value)
930 .sort(([a], [b]) => a.localeCompare(b));
931 return entries.map(([key, value]) => `${key}: ${value}`).join("\n");
932 }
933
934 function parseProviderHeaders(raw: string): Record<string, string> {
935 const out: Record<string, string> = {};
936 for (const line of raw.split(/\r?\n/)) {
937 const trimmed = line.trim();
938 if (!trimmed || trimmed.startsWith("#")) continue;
939 const colon = trimmed.indexOf(":");
940 const eq = trimmed.indexOf("=");
941 const cut = colon >= 0 && (eq < 0 || colon < eq) ? colon : eq;
942 if (cut <= 0) continue;
943 const key = trimmed.slice(0, cut).trim();
944 const value = trimmed.slice(cut + 1).trim();
945 if (key && value) out[key] = value;
946 }
947 return out;
948 }
949
950 function sortedJSONValue(value: unknown): unknown {
951 if (Array.isArray(value)) return value.map(sortedJSONValue);
952 if (value && typeof value === "object") {
953 const out: Record<string, unknown> = {};
954 for (const key of Object.keys(value as Record<string, unknown>).sort((a, b) => a.localeCompare(b))) {
955 out[key] = sortedJSONValue((value as Record<string, unknown>)[key]);
956 }
957 return out;
958 }
959 return value;
960 }
961
962 function formatSettingsError(error: unknown, t: ReturnType<typeof useT>): string {
963 const msg = String((error as Error)?.message ?? error ?? "").trim();
964 const unknownModel = /^unknown model (.+)$/i.exec(msg);
965 if (unknownModel) return t("settings.errorUnknownModel", { model: unknownModel[1] });
966 const providerNotAdded = /^model (.+) is not available because provider (.+) is not added$/i.exec(msg);
967 if (providerNotAdded) return t("settings.errorModelProviderMissing", { model: providerNotAdded[1], provider: providerNotAdded[2] });
968 const providerNoKey = /^model (.+) is not available because provider (.+) has no key$/i.exec(msg);
969 if (providerNoKey) return t("settings.errorModelProviderNoKey", { model: providerNoKey[1], provider: providerNoKey[2] });
970 if (/^background session is still open; reopen or close it before upgrading the DeepSeek provider protocol$/i.test(msg)) {
971 return t("settings.errorProviderDetached");
972 }
973 const removeAccessBusy = /^finish or cancel active work using (.+) before removing the provider access$/i.exec(msg);
974 if (removeAccessBusy) return t("settings.errorRemoveAccessBusy", { provider: removeAccessBusy[1] });
975 const removeAccessDetached = /^background session is still using (.+); reopen or close it before removing the provider access$/i.exec(msg);
976 if (removeAccessDetached) return t("settings.errorProviderDetached");
977 const removeAccessNoFallback = /^remove provider access: (.+) is in use and no other configured provider exists$/i.exec(msg);
978 if (removeAccessNoFallback) return t("settings.errorRemoveProviderNoFallback", { provider: removeAccessNoFallback[1] });
979 const deleteProviderNoFallback = /^remove provider: (.+) is in use and no other configured provider exists$/i.exec(msg);
980 if (deleteProviderNoFallback) return t("settings.errorRemoveProviderNoFallback", { provider: deleteProviderNoFallback[1] });
981 const deleteProviderBusy = /^finish or cancel active work using (.+) before deleting the provider$/i.exec(msg);
982 if (deleteProviderBusy) return t("settings.errorDeleteProviderBusy", { provider: deleteProviderBusy[1] });
983 const deleteProviderDetached = /^background session is still using (.+); reopen or close it before deleting the provider$/i.exec(msg);
984 if (deleteProviderDetached) return t("settings.errorProviderDetached");
985 const saveBeforeRemoveAccess = /^save current session before removing provider access: (.+)$/is.exec(msg);
986 if (saveBeforeRemoveAccess) return t("settings.errorSaveBeforeRemoveAccess", { err: saveBeforeRemoveAccess[1] });
987 const saveBeforeDeleteProvider = /^save current session before deleting provider: (.+)$/is.exec(msg);
988 if (saveBeforeDeleteProvider) return t("settings.errorSaveBeforeDeleteProvider", { err: saveBeforeDeleteProvider[1] });
989 const removeProviderUsed = /^remove provider: (.+) is used by open tabs and no other configured provider exists$/i.exec(msg);
990 if (removeProviderUsed) return t("settings.errorRemoveProviderNoFallback", { provider: removeProviderUsed[1] });
991 return msg || t("settings.errorUnknown");
992 }
993
994 function validateProviderExtraBodyValue(value: unknown, path = "extra_body", t?: ReturnType<typeof useT>): void {
995 if (value === null) {
996 throw new Error(t ? t("settings.providerExtraBodyNull", { path }) : `${path} cannot contain null`);
997 }
998 if (Array.isArray(value)) {
999 value.forEach((item, index) => validateProviderExtraBodyValue(item, `${path}[${index}]`, t));
1000 return;
1001 }
1002 if (typeof value === "object") {
1003 for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
1004 validateProviderExtraBodyValue(child, `${path}.${key}`, t);
1005 }
1006 }
1007 }
1008
1009 export function formatProviderExtraBody(extraBody: Record<string, unknown> | null | undefined): string {
1010 const cleaned: Record<string, unknown> = {};
1011 for (const [rawKey, value] of Object.entries(extraBody ?? {})) {
1012 const key = rawKey.trim();
1013 if (!key || value === undefined) continue;
1014 cleaned[key] = value;
1015 }
1016 if (Object.keys(cleaned).length === 0) return "";
1017 return JSON.stringify(sortedJSONValue(cleaned), null, 2);
1018 }
1019
1020 export function parseProviderExtraBody(raw: string, t?: ReturnType<typeof useT>): Record<string, unknown> {
1021 const trimmed = raw.trim();
1022 if (!trimmed) return {};
1023 const parsed = JSON.parse(trimmed) as unknown;
1024 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1025 throw new Error(t ? t("settings.providerExtraBodyObjectRequired") : "extra body must be a JSON object");
1026 }
1027 validateProviderExtraBodyValue(parsed, "extra_body", t);
1028 const out: Record<string, unknown> = {};
1029 for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
1030 const key = rawKey.trim();
1031 if (key) out[key] = value;
1032 }
1033 return out;
1034 }
1035
1036 export function providerExtraBodyParseError(error: unknown, t: ReturnType<typeof useT>): string {
1037 if (error instanceof SyntaxError) return t("settings.providerExtraBodyError");
1038 const message = String((error as Error)?.message ?? error ?? "").trim();
1039 return message || t("settings.providerExtraBodyError");
1040 }
1041
1042 function providerModelFetchFallbackMessage(error: unknown, t: ReturnType<typeof useT>): string {
1043 const message = String((error as Error)?.message ?? error);
1044 if (/\bstatus\s+(401|403)\b/i.test(message)) {
1045 return t("settings.fetchModelsManualFallbackAuth");
1046 }
1047 if (/\bstatus\s+(404|405)\b/i.test(message)) {
1048 return t("settings.fetchModelsManualFallbackUnsupported");
1049 }
1050 if (/\b(status\s+5\d\d|request failed|network|timeout|timed out|connection|deadline|fetch failed)\b/i.test(message)) {
1051 return t("settings.fetchModelsManualFallbackNetwork");
1052 }
1053 if (/\b(decode response|invalid character|unexpected end|unexpected format)\b/i.test(message)) {
1054 return t("settings.fetchModelsManualFallbackDecode");
1055 }
1056 return t("settings.fetchModelsManualFallbackGeneric", { err: message });
1057 }
1058
1059 function normalizeReasoningLanguage(lang: string | undefined): string {
1060 const v = String(lang ?? "").trim().toLowerCase();
1061 return v === "zh" || v === "en" ? v : "auto";
1062 }
1063
1064 function normalizeBotQueueMode(mode: unknown): string {
1065 const raw = String(mode ?? "").trim().toLowerCase();
1066 return BOT_QUEUE_MODES.includes(raw as any) ? raw : "steer";
1067 }
1068
1069 function normalizeBotQueueDrop(mode: unknown): string {
1070 const raw = String(mode ?? "").trim().toLowerCase();
1071 return BOT_QUEUE_DROPS.includes(raw as any) ? raw : "summarize";
1072 }
1073
1074 function normalizeBotRouteChatType(value: unknown): string {
1075 const raw = String(value ?? "").trim().toLowerCase();
1076 return BOT_ROUTE_CHAT_TYPES.includes(raw as any) ? raw : "";
1077 }
1078
1079 function normalizeBotRoute(raw: any): BotRouteView {
1080 return {
1081 connectionId: String(raw?.connectionId ?? "").trim(),
1082 platform: String(raw?.platform ?? "").trim().toLowerCase(),
1083 chatType: normalizeBotRouteChatType(raw?.chatType),
1084 chatId: String(raw?.chatId ?? "").trim(),
1085 userId: String(raw?.userId ?? "").trim(),
1086 threadId: String(raw?.threadId ?? "").trim(),
1087 model: String(raw?.model ?? "").trim(),
1088 toolApprovalMode: normalizeBotToolApprovalMode(raw?.toolApprovalMode),
1089 workspaceRoot: String(raw?.workspaceRoot ?? "").trim(),
1090 };
1091 }
1092
1093 function emptyBotRoute(): BotRouteView {
1094 return {
1095 connectionId: "",
1096 platform: "",
1097 chatType: "",
1098 chatId: "",
1099 userId: "",
1100 threadId: "",
1101 model: "",
1102 toolApprovalMode: "",
1103 workspaceRoot: "",
1104 };
1105 }
1106
1107 function botRouteHasValue(route: BotRouteView): boolean {
1108 return Boolean(
1109 route.connectionId ||
1110 route.platform ||
1111 route.chatType ||
1112 route.chatId ||
1113 route.userId ||
1114 route.threadId ||
1115 route.model ||
1116 route.toolApprovalMode ||
1117 route.workspaceRoot
1118 );
1119 }
1120
1121 function defaultBotSettings(): BotSettingsView {
1122 return {
1123 enabled: false,
1124 model: "",
1125 toolApprovalMode: "workspace-write",
1126 maxSteps: 0,
1127 debounceMs: 1500,
1128 queueMode: "steer",
1129 queueCap: 20,
1130 queueDrop: "summarize",
1131 ignoreSelfMessages: true,
1132 selfUserIds: {
1133 qq: [],
1134 feishu: [],
1135 weixin: [],
1136 dingtalk: [],
1137 },
1138 control: {
1139 enabled: false,
1140 addr: "127.0.0.1:37913",
1141 tokenEnv: "REASONIX_BOT_CONTROL_TOKEN",
1142 },
1143 pairing: {
1144 enabled: true,
1145 requestTtlMinutes: 60,
1146 maxPendingPerPlatform: 3,
1147 },
1148 routes: [],
1149 allowlist: {
1150 enabled: true,
1151 allowAll: false,
1152 qqUsers: [],
1153 feishuUsers: [],
1154 weixinUsers: [],
1155 qqApprovers: [],
1156 feishuApprovers: [],
1157 weixinApprovers: [],
1158 qqAdmins: [],
1159 feishuAdmins: [],
1160 weixinAdmins: [],
1161 qqGroups: [],
1162 feishuGroups: [],
1163 weixinGroups: [],
1164 dingtalkUsers: [],
1165 dingtalkApprovers: [],
1166 dingtalkAdmins: [],
1167 dingtalkGroups: [],
1168 },
1169 qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false, model: "", toolApprovalMode: "workspace-write", workspaceRoot: "", access: defaultBotAccess() },
1170 feishu: {
1171 enabled: false,
1172 domain: "feishu",
1173 appId: "",
1174 appSecretEnv: "FEISHU_BOT_APP_SECRET",
1175 secretSet: false,
1176 verificationToken: "",
1177 mode: "webhook",
1178 webhookPort: 8080,
1179 requireMention: true,
1180 },
1181 weixin: {
1182 enabled: false,
1183 accountId: "default",
1184 tokenEnv: "WEIXIN_BOT_TOKEN",
1185 tokenSet: false,
1186 apiBase: "https://ilinkai.weixin.qq.com",
1187 },
1188 dingtalk: {
1189 enabled: false,
1190 clientId: "",
1191 clientSecretEnv: "DINGTALK_CLIENT_SECRET",
1192 secretSet: false,
1193 botName: "",
1194 requireMention: true,
1195 model: "",
1196 toolApprovalMode: "",
1197 workspaceRoot: "",
1198 access: defaultBotAccess(),
1199 },
1200 connections: [],
1201 };
1202 }
1203
1204 function defaultBotAccess(): BotAccessView {
1205 return {
1206 enabled: true,
1207 allowAll: false,
1208 pairingEnabled: true,
1209 users: [],
1210 groups: [],
1211 approvers: [],
1212 admins: [],
1213 };
1214 }
1215
1216 function normalizeBotAccess(raw: any, fallback: BotAccessView = defaultBotAccess()): BotAccessView {
1217 const access = raw ?? fallback;
1218 return {
1219 enabled: access.enabled !== false,
1220 allowAll: Boolean(access.allowAll),
1221 pairingEnabled: access.pairingEnabled !== false,
1222 users: asArray(access.users),
1223 groups: asArray(access.groups),
1224 approvers: asArray(access.approvers),
1225 admins: asArray(access.admins),
1226 };
1227 }
1228
1229 function normalizeBotSettings(bot: BotSettingsView | null | undefined): BotSettingsView {
1230 const fallback = defaultBotSettings();
1231 const allowlist = bot?.allowlist ?? fallback.allowlist;
1232 const selfUserIds = bot?.selfUserIds ?? fallback.selfUserIds;
1233 const control = bot?.control ?? fallback.control;
1234 const pairing = bot?.pairing ?? fallback.pairing;
1235 const mode = bot?.feishu?.mode === "websocket" ? "websocket" : "webhook";
1236 return {
1237 ...fallback,
1238 ...bot,
1239 toolApprovalMode: normalizeBotToolApprovalMode(bot?.toolApprovalMode),
1240 maxSteps: Math.max(0, Number(bot?.maxSteps ?? fallback.maxSteps) || 0),
1241 debounceMs: Number(bot?.debounceMs) || fallback.debounceMs,
1242 queueMode: normalizeBotQueueMode(bot?.queueMode),
1243 queueCap: Math.max(0, Math.floor(Number(bot?.queueCap ?? fallback.queueCap) || 0)),
1244 queueDrop: normalizeBotQueueDrop(bot?.queueDrop),
1245 ignoreSelfMessages: bot?.ignoreSelfMessages !== false,
1246 selfUserIds: {
1247 qq: asArray(selfUserIds.qq),
1248 feishu: asArray(selfUserIds.feishu),
1249 weixin: asArray(selfUserIds.weixin),
1250 dingtalk: asArray(selfUserIds.dingtalk),
1251 },
1252 control: {
1253 enabled: Boolean(control.enabled),
1254 addr: String(control.addr ?? fallback.control.addr),
1255 tokenEnv: String(control.tokenEnv ?? fallback.control.tokenEnv),
1256 },
1257 pairing: {
1258 enabled: pairing.enabled !== false,
1259 requestTtlMinutes: Math.max(0, Math.floor(Number(pairing.requestTtlMinutes ?? fallback.pairing.requestTtlMinutes) || 0)),
1260 maxPendingPerPlatform: Math.max(0, Math.floor(Number(pairing.maxPendingPerPlatform ?? fallback.pairing.maxPendingPerPlatform) || 0)),
1261 },
1262 routes: asArray(bot?.routes).map(normalizeBotRoute).filter(botRouteHasValue),
1263 allowlist: {
1264 ...fallback.allowlist,
1265 ...allowlist,
1266 qqUsers: asArray(allowlist.qqUsers),
1267 feishuUsers: asArray(allowlist.feishuUsers),
1268 weixinUsers: asArray(allowlist.weixinUsers),
1269 qqApprovers: asArray(allowlist.qqApprovers),
1270 feishuApprovers: asArray(allowlist.feishuApprovers),
1271 weixinApprovers: asArray(allowlist.weixinApprovers),
1272 qqAdmins: asArray(allowlist.qqAdmins),
1273 feishuAdmins: asArray(allowlist.feishuAdmins),
1274 weixinAdmins: asArray(allowlist.weixinAdmins),
1275 qqGroups: asArray(allowlist.qqGroups),
1276 feishuGroups: asArray(allowlist.feishuGroups),
1277 weixinGroups: asArray(allowlist.weixinGroups),
1278 dingtalkUsers: asArray(allowlist.dingtalkUsers),
1279 dingtalkApprovers: asArray(allowlist.dingtalkApprovers),
1280 dingtalkAdmins: asArray(allowlist.dingtalkAdmins),
1281 dingtalkGroups: asArray(allowlist.dingtalkGroups),
1282 },
1283 qq: {
1284 ...fallback.qq,
1285 ...bot?.qq,
1286 model: String(bot?.qq?.model ?? fallback.qq.model).trim(),
1287 toolApprovalMode: normalizeBotToolApprovalMode(bot?.qq?.toolApprovalMode),
1288 workspaceRoot: String(bot?.qq?.workspaceRoot ?? fallback.qq.workspaceRoot).trim(),
1289 access: normalizeBotAccess(bot?.qq?.access, fallback.qq.access),
1290 },
1291 feishu: { ...fallback.feishu, ...bot?.feishu, domain: bot?.feishu?.domain === "lark" ? "lark" : "feishu", mode },
1292 weixin: { ...fallback.weixin, ...bot?.weixin },
1293 dingtalk: {
1294 ...fallback.dingtalk,
1295 ...bot?.dingtalk,
1296 toolApprovalMode: normalizeBotToolApprovalMode(bot?.dingtalk?.toolApprovalMode),
1297 access: normalizeBotAccess(bot?.dingtalk?.access, fallback.dingtalk.access),
1298 },
1299 connections: asArray(bot?.connections).map(normalizeBotConnection),
1300 };
1301 }
1302
1303 function normalizeBotConnection(raw: any) {
1304 const credential = raw?.credential ?? {};
1305 const workspaceRoot = String(raw?.workspaceRoot ?? "").trim();
1306 return {
1307 id: String(raw?.id ?? "").trim(),
1308 provider: String(raw?.provider ?? "").trim(),
1309 domain: String(raw?.domain ?? "").trim(),
1310 label: String(raw?.label ?? "").trim(),
1311 enabled: raw?.enabled !== false,
1312 status: String(raw?.status ?? "disconnected").trim(),
1313 model: String(raw?.model ?? "").trim(),
1314 toolApprovalMode: normalizeBotToolApprovalMode(raw?.toolApprovalMode, true),
1315 workspaceRoot,
1316 access: normalizeBotAccess(raw?.access),
1317 credential: {
1318 appId: String(credential.appId ?? "").trim(),
1319 appSecretEnv: String(credential.appSecretEnv ?? "").trim(),
1320 accountId: String(credential.accountId ?? "").trim(),
1321 tokenEnv: String(credential.tokenEnv ?? "").trim(),
1322 secretSet: Boolean(credential.secretSet),
1323 },
1324 sessionMappings: asArray(raw?.sessionMappings).map((item: any) => ({
1325 remoteId: String(item?.remoteId ?? "").trim(),
1326 sessionId: String(item?.sessionId ?? "").trim(),
1327 sessionSource: String(item?.sessionSource ?? "").trim(),
1328 chatType: String(item?.chatType ?? "").trim(),
1329 userId: String(item?.userId ?? "").trim(),
1330 threadId: String(item?.threadId ?? "").trim(),
1331 scope: normalizeBotMappingScope(item?.scope, item?.workspaceRoot ?? workspaceRoot),
1332 workspaceRoot: normalizeBotMappingScope(item?.scope, item?.workspaceRoot ?? workspaceRoot) === "project"
1333 ? String(item?.workspaceRoot ?? workspaceRoot).trim()
1334 : "",
1335 updatedAt: String(item?.updatedAt ?? "").trim(),
1336 })),
1337 lastError: String(raw?.lastError ?? "").trim(),
1338 createdAt: String(raw?.createdAt ?? "").trim(),
1339 updatedAt: String(raw?.updatedAt ?? "").trim(),
1340 };
1341 }
1342
1343 function normalizeBotToolApprovalMode(mode: unknown, allowEmpty = false): "read-only" | "workspace-write" | "danger-full-access" | "" {
1344 const raw = String(mode ?? "").trim().toLowerCase();
1345 if (raw === "") return allowEmpty ? "" : "workspace-write";
1346 if (raw === "read-only" || raw === "ask") return "read-only";
1347 if (raw === "workspace-write" || raw === "auto" || raw === "yolo") return "workspace-write";
1348 if (raw === "danger-full-access" || raw === "full" || raw === "full-access" || raw === "bypass") return "danger-full-access";
1349 // A non-empty unknown value is an incompatible permission contract. Keep it
1350 // fail-closed even for per-connection settings that otherwise allow inherit.
1351 return "read-only";
1352 }
1353
1354 function normalizeBotMappingScope(scope: unknown, workspaceRoot: unknown): "global" | "project" {
1355 if (String(scope ?? "").trim() === "project") return "project";
1356 return String(workspaceRoot ?? "").trim() ? "project" : "global";
1357 }
1358
1359 function normalizeStringMap(value: unknown): Record<string, string> {
1360 if (!value || typeof value !== "object" || Array.isArray(value)) return {};
1361 const out: Record<string, string> = {};
1362 for (const [rawKey, rawValue] of Object.entries(value as Record<string, unknown>)) {
1363 const key = rawKey.trim();
1364 const val = String(rawValue ?? "").trim();
1365 if (key && val) out[key] = val;
1366 }
1367 return out;
1368 }
1369
1370 function normalizeExtraBodyMap(value: unknown): Record<string, unknown> {
1371 if (!value || typeof value !== "object" || Array.isArray(value)) return {};
1372 const out: Record<string, unknown> = {};
1373 for (const [rawKey, rawValue] of Object.entries(value as Record<string, unknown>)) {
1374 const key = rawKey.trim();
1375 if (key && rawValue !== undefined) out[key] = rawValue;
1376 }
1377 return out;
1378 }
1379
1380 export function normalizeProviderView(p: ProviderView): ProviderView {
1381 const visionModels = asArray(p.visionModels);
1382 const modelCapabilities = asArray(p.modelCapabilities).flatMap((item) => {
1383 if (!item || typeof item !== "object") return [];
1384 const raw = item as Partial<ProviderModelCapabilityView>;
1385 const model = String(raw.model ?? "").trim();
1386 if (!model) return [];
1387 return [{
1388 automaticState: raw.automaticState,
1389 automaticSource: raw.automaticSource,
1390 imageInputEnableAllowed: raw.imageInputEnableAllowed,
1391 imageInputBlockReason: raw.imageInputBlockReason,
1392 model,
1393 inputModalities: asArray(raw.inputModalities).map(String),
1394 state: String(raw.state ?? "unknown"),
1395 source: String(raw.source ?? "unknown"),
1396 }];
1397 });
1398 const requiresKey = providerRequiresKey(p);
1399 return {
1400 ...p,
1401 name: String(p.name ?? ""),
1402 baseUrl: String(p.baseUrl ?? ""),
1403 builtIn: Boolean(p.builtIn),
1404 added: Boolean(p.added),
1405 chatUrl: p.chatUrl ?? "",
1406 requestUrl: p.requestUrl ?? "",
1407 models: asArray(p.models),
1408 visionModels,
1409 modelCapabilities,
1410 visionModelsConfigured: Boolean(p.visionModelsConfigured ?? visionModels.length > 0),
1411 visionCapability: p.visionCapability === "unsupported" || p.visionCapability === "configurable"
1412 ? p.visionCapability
1413 : undefined,
1414 modelsUrl: p.modelsUrl ?? "",
1415 headers: normalizeStringMap(p.headers),
1416 extraBody: normalizeExtraBodyMap(p.extraBody),
1417 authHeader: Boolean(p.authHeader),
1418 noProxy: Boolean(p.noProxy),
1419 reasoningProtocol: normalizeReasoningProtocol(p.reasoningProtocol),
1420 thinking: normalizeThinkingMode(p.thinking),
1421 webSearch: Boolean(p.webSearch),
1422 serverWebSearchCapability: typeof p.serverWebSearchCapability === "boolean"
1423 ? p.serverWebSearchCapability
1424 : undefined,
1425 supportedEfforts: asArray(p.supportedEfforts),
1426 modelOverrides: asArray(p.modelOverrides),
1427 recommendedUpgradeAvailable: Boolean(p.recommendedUpgradeAvailable),
1428 requiresKey,
1429 configured: providerIsConfigured({ ...p, requiresKey }),
1430 keySource: p.keySource ?? "",
1431 keySourcePath: p.keySourcePath ?? "",
1432 modelCatalogFingerprint: p.modelCatalogFingerprint ?? "",
1433 };
1434 }
1435
1436 type ProviderPresetStatus = NonNullable<ProviderPresetView["status"]> | "partial";
1437
1438 function normalizeProviderPresetStatus(status: ProviderPresetView["status"] | undefined, added: boolean): ProviderPresetStatus {
1439 if (status === "installed" || status === "installed_modified" || status === "partial" || status === "name_conflict" || status === "similar_existing") return status;
1440 return added ? "installed" : "available";
1441 }
1442
1443 function normalizeProviderPresetView(p: ProviderPresetView): ProviderPresetView {
1444 const requiresKey = Boolean(p.requiresKey ?? p.keyEnv);
1445 const configured = Boolean(p.configured ?? (!requiresKey || p.keySet));
1446 const status = normalizeProviderPresetStatus(p.status, Boolean(p.added));
1447 return {
1448 ...p,
1449 id: String(p.id ?? "").trim(),
1450 label: String(p.label ?? "").trim(),
1451 description: String(p.description ?? "").trim(),
1452 keyEnv: String(p.keyEnv ?? "").trim(),
1453 providerNames: asArray(p.providerNames),
1454 models: asArray(p.models),
1455 added: Boolean(p.added || status === "installed" || status === "installed_modified" || status === "partial" || status === "name_conflict"),
1456 status,
1457 statusProviderNames: asArray(p.statusProviderNames),
1458 missingProviderNames: asArray(p.missingProviderNames),
1459 keySet: Boolean(p.keySet),
1460 requiresKey,
1461 configured,
1462 keySource: p.keySource ?? "",
1463 keySourcePath: p.keySourcePath ?? "",
1464 };
1465 }
1466
1467 function normalizeSettingsView(view: SettingsView | null | undefined): SettingsView | null {
1468 if (!view) return null;
1469 const permissions = view.permissions ?? { mode: "ask", allow: [], ask: [], deny: [] };
1470 const sandbox = view.sandbox ?? { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "", effectiveWriteRoots: [], shell: "auto", effectiveShell: "" };
1471 const network = view.network ?? {
1472 proxyMode: "auto",
1473 proxyUrl: "",
1474 noProxy: "",
1475 proxy: { type: "socks5", server: "", port: 0, username: "", password: "" },
1476 };
1477 const agent = view.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", reasoningLanguage: "auto", compactRatio: 0.80 };
1478 agent.plannerMaxSteps = Number.isFinite(agent.plannerMaxSteps) ? Math.max(0, Math.trunc(agent.plannerMaxSteps)) : 0;
1479 agent.maxSteps = Number.isFinite(agent.maxSteps) ? Math.max(0, Math.trunc(agent.maxSteps)) : 0;
1480 agent.maxSubagentDepth = Number.isFinite(agent.maxSubagentDepth) && agent.maxSubagentDepth <= 1 ? 1 : 2;
1481 agent.reasoningLanguage = normalizeReasoningLanguage(agent.reasoningLanguage);
1482 agent.compactRatio = Number.isFinite(agent.compactRatio) && Number(agent.compactRatio) > 0 ? Number(agent.compactRatio) : 0.80;
1483 agent.effectiveCompactRatio = Number.isFinite(agent.effectiveCompactRatio) && Number(agent.effectiveCompactRatio) > 0
1484 ? Number(agent.effectiveCompactRatio)
1485 : agent.compactRatio;
1486 agent.compactRatioOverridden = Boolean(agent.compactRatioOverridden);
1487 return {
1488 ...view,
1489 webSearchModel: view.webSearchModel || "auto",
1490 webSearchModels: asArray(view.webSearchModels).filter((ref): ref is string => typeof ref === "string"),
1491 providers: asArray(view.providers).map(normalizeProviderView),
1492 officialProviders: asArray(view.officialProviders).map(normalizeProviderView),
1493 providerPresets: asArray(view.providerPresets).map(normalizeProviderPresetView).filter((p) => p.id),
1494 providerKinds: asArray(view.providerKinds),
1495 permissions: {
1496 ...permissions,
1497 allow: asArray(permissions.allow),
1498 ask: asArray(permissions.ask),
1499 deny: asArray(permissions.deny),
1500 },
1501 sandbox: {
1502 ...sandbox,
1503 allowWrite: asArray(sandbox.allowWrite),
1504 effectiveWorkspaceRoot: String(sandbox.effectiveWorkspaceRoot ?? ""),
1505 effectiveWriteRoots: asArray(sandbox.effectiveWriteRoots),
1506 effectiveShell: String(sandbox.effectiveShell ?? sandbox.shell ?? ""),
1507 },
1508 network: {
1509 ...network,
1510 proxy: network.proxy ?? { type: "socks5", server: "", port: 0, username: "", password: "" },
1511 },
1512 agent,
1513 bot: normalizeBotSettings(view.bot),
1514 autoPlan: "off",
1515 defaultToolApprovalMode: normalizeToolApprovalMode(view.defaultToolApprovalMode),
1516 autoApproveTools: Boolean(view.autoApproveTools ?? view.bypass),
1517 bypass: Boolean(view.autoApproveTools ?? view.bypass),
1518 desktopLanguage: normalizeLangPref(view.desktopLanguage),
1519 desktopCurrency: normalizeDesktopCurrency(view.desktopCurrency),
1520 desktopTheme: normalizeThemePreference(view.desktopTheme),
1521 desktopThemeStyle: normalizeThemeStyleForTheme(view.desktopThemeStyle, normalizeThemePreference(view.desktopTheme)),
1522 desktopTerminalTheme: normalizeTerminalThemePreference(view.desktopTerminalTheme),
1523 closeBehavior: normalizeCloseBehavior(view.closeBehavior),
1524 sessionExperience: view.sessionExperience === "deep" ? "deep" : "standard",
1525 displayMode: "standard",
1526 statusBarStyle: normalizeStatusBarStyle(view.statusBarStyle),
1527 statusBarItems: normalizeStatusBarItems(view.statusBarItems),
1528 conversationWidth: normalizeConversationWidth(view.conversationWidth),
1529 checkUpdates: view.checkUpdates !== false,
1530 updaterEnabled: view.updaterEnabled === true,
1531 updateChannel: "stable",
1532 };
1533 }
1534
1535 type DesktopCurrency = "" | "CNY" | "USD";
1536
1537 function normalizeDesktopCurrency(currency: string | undefined): DesktopCurrency {
1538 return currency === "CNY" || currency === "USD" ? currency : "";
1539 }
1540
1541 type CloseBehavior = "background" | "quit";
1542
1543 function normalizeCloseBehavior(mode: string | undefined): CloseBehavior {
1544 return mode === "quit" ? "quit" : "background";
1545 }
1546
1547 type StatusBarStyle = "icon" | "text";
1548 function normalizeStatusBarStyle(style: string | undefined): StatusBarStyle {
1549 return style === "text" ? "text" : "icon";
1550 }
1551
1552 function statusBarItemLabel(id: StatusBarItemId, t: ReturnType<typeof useT>): string {
1553 switch (id) {
1554 case "workspace":
1555 return t("settings.statusBarItem.workspace");
1556 case "cache":
1557 return t("status.cacheLabel");
1558 case "cache_avg":
1559 return t("status.cacheAvgLabel");
1560 case "session_tokens":
1561 return t("status.sessionTokensLabel");
1562 case "turn_tokens":
1563 return t("status.turnTokensLabel");
1564 case "turn_tps":
1565 return t("status.tpsLabel");
1566 case "turn_output_tokens":
1567 return t("status.outputTokensLabel");
1568 case "turn_cache_tokens":
1569 return t("status.cacheTokensLabel");
1570 case "turn_cost":
1571 return t("status.turnCostLabel");
1572 case "session_turns":
1573 return t("status.sessionTurnsLabel");
1574 case "context":
1575 return t("status.ctxLabel");
1576 case "compact":
1577 return t("status.compactLabel");
1578 case "cost":
1579 return t("status.costLabel");
1580 case "balance":
1581 return t("status.balanceLabel");
1582 }
1583 }
1584
1585 function closeBehaviorLabel(mode: CloseBehavior, t: ReturnType<typeof useT>): string {
1586 return mode === "quit" ? t("settings.closeBehavior.quit") : t("settings.closeBehavior.background");
1587 }
1588
1589 function providerKindLabel(kind: string, _t: ReturnType<typeof useT>): string {
1590 return providerProtocolLabel(kind);
1591 }
1592
1593 function providerKindHint(kind: string, t: ReturnType<typeof useT>): string {
1594 if (kind === "responses" || kind === "dashscope-responses") return providerProtocolLabel(kind);
1595 return kind === "responses" ? t("settings.catalog.responsesHint") : kind === "anthropic" ? t("settings.providerProtocolAnthropicHint") : t("settings.providerProtocolOpenAIHint");
1596 }
1597
1598 function reasoningProtocolLabel(protocol: string, t: ReturnType<typeof useT>): string {
1599 switch (protocol) {
1600 case "deepseek":
1601 return t("settings.reasoningProtocol.deepseek");
1602 case "glm": return t("settings.reasoningProtocol.glm");
1603 case "kimi-k3": return t("settings.reasoningProtocol.kimiK3");
1604 case "openai":
1605 return t("settings.reasoningProtocol.openai");
1606 case "none":
1607 return t("settings.reasoningProtocol.none");
1608 default:
1609 return t("settings.reasoningProtocol.auto");
1610 }
1611 }
1612
1613 function thinkingModeLabel(mode: string, t: ReturnType<typeof useT>): string {
1614 switch (mode) {
1615 case "enabled":
1616 return t("settings.thinkingMode.enabled");
1617 case "disabled":
1618 return t("settings.thinkingMode.disabled");
1619 case "adaptive":
1620 return t("settings.thinkingMode.adaptive");
1621 default:
1622 return t("settings.thinkingMode.auto");
1623 }
1624 }
1625 function GeneralSection({ s, busy, apply, agentRunning }: SectionProps & { agentRunning: boolean }) {
1626 const { setPref } = useI18n();
1627 const t = useT();
1628 const closeBehavior = normalizeCloseBehavior(s.closeBehavior);
1629 const soundPanelId = useId();
1630 const languagePref = normalizeLangPref(s.desktopLanguage);
1631 const desktopCurrency = normalizeDesktopCurrency(s.desktopCurrency);
1632 const [genMusicPreset, setGenMusicPreset] = useState<GenerativePreset>(getGenerativePreset());
1633 const [soundPref, setSoundPref] = useState<SoundWavPref>(getSuccessPreference());
1634 const [attentionPref, setAttentionPref] = useState<SoundWavPref>(getAttentionPreference());
1635 const [notificationVolume, setNotificationVolume] = useState(getNotificationVolume);
1636 const [soundExpanded, setSoundExpanded] = useState(false);
1637 const [graphics, setGraphics] = useState<import("../lib/desktopHost").GraphicsSettingsState | null>(null);
1638 const [graphicsBusy, setGraphicsBusy] = useState(false);
1639 const [graphicsError, setGraphicsError] = useState<string | null>(null);
1640 useEffect(() => {
1641 let active = true;
1642 const host = desktopHost();
1643 if (host.kind === "electron") void host.native.graphics.get().then((value) => { if (active) setGraphics(value); }).catch(() => undefined);
1644 return () => { active = false; };
1645 }, []);
1646 const updateGraphics = (enabled: boolean) => {
1647 const host = desktopHost();
1648 if (host.kind !== "electron" || graphicsBusy) return;
1649 setGraphicsBusy(true); setGraphicsError(null);
1650 void host.native.graphics.setHardwareAcceleration(enabled).then((value) => { setGraphics(value); }).catch((error: unknown) => { setGraphicsError(error instanceof Error ? error.message : String(error)); }).finally(() => setGraphicsBusy(false));
1651 };
1652 const statusBarStyle = normalizeStatusBarStyle(s.statusBarStyle);
1653 const statusBarItems = normalizeStatusBarItems(s.statusBarItems);
1654 const soundStatus = summarizeSoundStatus(genMusicPreset, soundPref, attentionPref, notificationVolume);
1655 const applyStatusBarItems = (items: StatusBarItemId[]) => {
1656 const contentScrollTop = document.querySelector<HTMLElement>(".settings-center__content")?.scrollTop ?? 0;
1657 const navScrollTop = document.querySelector<HTMLElement>(".settings-center__nav")?.scrollTop ?? 0;
1658 const active = document.activeElement;
1659 if (active instanceof HTMLElement && active.closest(".status-bar-items-editor")) active.blur();
1660 void apply(() => app.SetStatusBarItems(items)).finally(() => {
1661 window.scrollTo(0, 0);
1662 requestAnimationFrame(() => {
1663 window.scrollTo(0, 0);
1664 const content = document.querySelector<HTMLElement>(".settings-center__content");
1665 const nav = document.querySelector<HTMLElement>(".settings-center__nav");
1666 if (content) content.scrollTop = Math.min(contentScrollTop, Math.max(0, content.scrollHeight - content.clientHeight));
1667 if (nav) nav.scrollTop = navScrollTop;
1668 });
1669 });
1670 };
1671 const setLanguage = (next: LangPref) => {
1672 setPref(next);
1673 void apply(() => app.SetDesktopLanguage(next));
1674 };
1675 return (
1676 <>
1677 <SettingsSection title={t("settings.general.sectionAppearance")} description={t("settings.general.sectionAppearanceHint")}>
1678 <SettingsField label={t("settings.language")} hint={t("settings.languageHint")} icon={<Languages size={18} />}>
1679 <SettingsOptions layout="field" className="set-seg">
1680 {LANGUAGE_PREFS.map((pref) => (
1681 <button
1682 key={pref || "auto"}
1683 className={`set-seg__btn${languagePref === pref ? " set-seg__btn--on" : ""}`}
1684 disabled={busy}
1685 onClick={() => setLanguage(pref)}
1686 >
1687 {pref === "" ? t("settings.langAuto") : pref === "zh" ? "中文" : "English"}
1688 </button>
1689 ))}
1690 </SettingsOptions>
1691 </SettingsField>
1692 <SettingsField label={t("settings.currency")} hint={t("settings.currencyHint")} icon={<CircleDollarSign size={18} />}>
1693 <SettingsOptions layout="field" className="set-seg">
1694 {(["", "CNY", "USD"] as DesktopCurrency[]).map((currency) => (
1695 <button
1696 key={currency || "auto"}
1697 className={`set-seg__btn${desktopCurrency === currency ? " set-seg__btn--on" : ""}`}
1698 disabled={busy || agentRunning}
1699 onClick={() => void apply(() => app.SetDesktopCurrency(currency))}
1700 >
1701 {currency === "" ? t("settings.currencyAuto") : currency}
1702 </button>
1703 ))}
1704 </SettingsOptions>
1705 </SettingsField>
1706 </SettingsSection>
1707
1708 <SessionExperienceSettings snapshot={s} busy={busy} apply={apply} />
1709
1710 <SettingsSection title={t("settings.general.sectionSystem")} description={t("settings.general.sectionSystemHint")}>
1711 {graphics && <SettingsField label={<span className="settings-graphics-label"><span>{t("settings.hardwareAcceleration")}</span>{graphics.restartRequired && graphics.override === "none" && <span className="settings-graphics-status">{t("settings.hardwareAccelerationRestartShort")}</span>}{graphics.override !== "none" && <span className="settings-graphics-status settings-graphics-status--warning">{t("settings.hardwareAccelerationOverride")}</span>}{graphicsError && <span className="settings-graphics-status settings-graphics-status--error" role="alert">{graphicsError}</span>}</span>} hint={t("settings.hardwareAccelerationHint")} icon={<Monitor size={18} />}>
1712 <div className="settings-graphics-control">
1713 <ToggleSegment value={graphics.hardwareAcceleration} disabled={graphicsBusy || !graphics.writable || graphics.override !== "none"} onChange={updateGraphics} />
1714 </div>
1715 </SettingsField>}
1716 <SettingsField label={t("settings.closeBehavior")} hint={<DesktopCloseBehaviorHint backgroundSelected={closeBehavior === "background"} hint={t("settings.closeBehaviorHint")} unavailableHint={t("settings.closeBehaviorUnavailable")} />} icon={<Power size={18} />}>
1717 <SettingsOptions layout="field" className="set-seg">
1718 {(["background", "quit"] as const).map((mode) => (
1719 <button
1720 key={mode}
1721 className={`set-seg__btn${closeBehavior === mode ? " set-seg__btn--on" : ""}`}
1722 disabled={busy}
1723 onClick={() => void apply(() => app.SetCloseBehavior(mode))}
1724 >
1725 {closeBehaviorLabel(mode, t)}
1726 </button>
1727 ))}
1728 </SettingsOptions>
1729 </SettingsField>
1730 <SettingsField label={t("settings.sound")} hint={t("settings.soundHint")} icon={<Volume2 size={18} />} stacked>
1731 <div className={`settings-sound-editor${soundExpanded ? " settings-sound-editor--expanded" : ""}`}>
1732 <div className="settings-sound-editor__summary">
1733 <span className={`settings-sound-editor__status settings-sound-editor__status--${soundStatus}`}>
1734 {t(`settings.soundStatus.${soundStatus}`)}
1735 </span>
1736 <Tooltip label={t(soundExpanded ? "settings.soundCollapse" : "settings.soundExpand")}>
1737 <button
1738 type="button"
1739 className="settings-sound-editor__toggle"
1740 aria-expanded={soundExpanded}
1741 aria-controls={soundPanelId}
1742 aria-label={t(soundExpanded ? "settings.soundCollapse" : "settings.soundExpand")}
1743 onClick={() => setSoundExpanded((open) => !open)}
1744 >
1745 {soundExpanded ? <ChevronUp size={15} aria-hidden="true" /> : <ChevronDown size={15} aria-hidden="true" />}
1746 </button>
1747 </Tooltip>
1748 </div>
1749 {soundExpanded && (
1750 <div className="settings-sound-editor__list" id={soundPanelId}>
1751 <div className="settings-sound-row">
1752 <span className="settings-sound-row__label">{t("settings.generativeMusic")}</span>
1753 <GenMusicSelect
1754 value={genMusicPreset}
1755 onChange={(next) => {
1756 setGenMusicPreset(next);
1757 setGenerativePreset(next);
1758 if (next === "off") {
1759 generativeMusic.stop();
1760 } else {
1761 if (generativeMusic.isRunning) {
1762 generativeMusic.setPreset(next);
1763 } else if (agentRunning) {
1764 generativeMusic.start(next);
1765 }
1766 generativeMusic.playPreview(next);
1767 }
1768 }}
1769 onPreview={() => { if (genMusicPreset !== "off") generativeMusic.playPreview(genMusicPreset); }}
1770 previewDisabled={genMusicPreset === "off"}
1771 />
1772 </div>
1773 <div className="settings-sound-row">
1774 <span className="settings-sound-row__label">{t("settings.notificationVolume")}</span>
1775 <NotificationVolumeSlider
1776 value={notificationVolume}
1777 onChange={(next) => setNotificationVolume(persistNotificationVolume(next))}
1778 />
1779 </div>
1780 <div className="settings-sound-row">
1781 <span className="settings-sound-row__label">{t("settings.notificationSoundSuccess")}</span>
1782 <SoundSelect
1783 value={soundPref}
1784 onChange={(next) => {
1785 setSoundPref(next);
1786 setSuccessPreference(next);
1787 playSuccessChime();
1788 }}
1789 onPreview={playSuccessChime}
1790 previewDisabled={soundPref === "off"}
1791 />
1792 </div>
1793 <div className="settings-sound-row">
1794 <span className="settings-sound-row__label">{t("settings.notificationSoundAttention")}</span>
1795 <SoundSelect
1796 value={attentionPref}
1797 onChange={(next) => {
1798 setAttentionPref(next);
1799 setAttentionPreference(next);
1800 playAttentionChime();
1801 }}
1802 onPreview={playAttentionChime}
1803 previewDisabled={attentionPref === "off"}
1804 />
1805 </div>
1806 </div>
1807 )}
1808 </div>
1809 </SettingsField>
1810 <SettingsField label={t("settings.statusBarStyle")} hint={t("settings.statusBarStyleHint")} icon={<PanelBottom size={18} />}>
1811 <SettingsOptions layout="field" className="set-seg">
1812 {(["icon", "text"] as const).map((style) => (
1813 <button
1814 key={style}
1815 className={`set-seg__btn${statusBarStyle === style ? " set-seg__btn--on" : ""}`}
1816 disabled={busy}
1817 onClick={() => void apply(() => app.SetStatusBarStyle(style))}
1818 >
1819 {t(`settings.statusBarStyle.${style}`)}
1820 </button>
1821 ))}
1822 </SettingsOptions>
1823 </SettingsField>
1824 <SettingsField label={t("settings.statusBarItems")} hint={t("settings.statusBarItemsHint")} icon={<ListChecks size={18} />} className="status-bar-items-setting" stacked>
1825 <StatusBarItemsEditor
1826 items={statusBarItems}
1827 busy={busy}
1828 onChange={applyStatusBarItems}
1829 itemLabel={(id) => statusBarItemLabel(id, t)}
1830 />
1831 </SettingsField>
1832 </SettingsSection>
1833 </>
1834 );
1835 }
1836
1837 const GENRE_OPTIONS: { value: GenerativePreset; labelKey: DictKey }[] = [
1838 { value: "off", labelKey: "settings.generativeMusic.off" },
1839 { value: "ethereal", labelKey: "settings.generativeMusic.presets.ethereal" },
1840 { value: "classic", labelKey: "settings.generativeMusic.presets.classic" },
1841 { value: "digital", labelKey: "settings.generativeMusic.presets.digital" },
1842 { value: "retro", labelKey: "settings.generativeMusic.presets.retro" },
1843 ];
1844
1845 function summarizeSoundStatus(
1846 music: GenerativePreset,
1847 success: SoundWavPref,
1848 attention: SoundWavPref,
1849 notificationVolume: number,
1850 ): "allOff" | "enabled" | "custom" {
1851 const notificationsAudible = notificationVolume > 0;
1852 const enabledCount = [
1853 music !== "off",
1854 notificationsAudible && success !== "off",
1855 notificationsAudible && attention !== "off",
1856 ].filter(Boolean).length;
1857 if (enabledCount === 0) return "allOff";
1858 if (enabledCount === 1) return "enabled";
1859 return "custom";
1860 }
1861
1862 function GenMusicSelect({
1863 value,
1864 onChange,
1865 onPreview,
1866 previewDisabled,
1867 }: {
1868 value: GenerativePreset;
1869 onChange: (v: GenerativePreset) => void;
1870 onPreview: () => void;
1871 previewDisabled?: boolean;
1872 }) {
1873 const t = useT();
1874 return (
1875 <div className="sound-select">
1876 <SettingsSelect value={value} onValueChange={next => onChange(next as GenerativePreset)}
1877 aria-label={t("settings.generativeMusic")}
1878 options={GENRE_OPTIONS.map(option => ({ value: option.value, label: t(option.labelKey) }))} />
1879 {!previewDisabled && (
1880 <button className="chip chip--icon" type="button" title={t("settings.generativeMusicPreview")} aria-label={t("settings.generativeMusicPreview")} onClick={onPreview}>
1881 <Play size={13} aria-hidden="true" />
1882 </button>
1883 )}
1884
1885 </div>
1886 );
1887 }
1888
1889 function NetworkSection({ s, busy, apply }: SectionProps) {
1890 const t = useT();
1891 const savedNetwork = normalizeNetworkView(s.network);
1892 const [draft, setDraft] = useState<NetworkView>(savedNetwork);
1893 useEffect(() => setDraft(normalizeNetworkView(s.network)), [s.network]);
1894 const dirty = JSON.stringify(draft) !== JSON.stringify(savedNetwork);
1895 const setProxy = (next: Partial<NetworkView["proxy"]>) => {
1896 setDraft({ ...draft, proxy: { ...draft.proxy, ...next } });
1897 };
1898
1899 return (
1900 <SettingsSection
1901 title={t("settings.tab.network")}
1902
1903 >
1904 <SettingsField label={t("settings.proxyMode")}>
1905 <SettingsOptions layout="field" className="set-seg">
1906 {PROXY_MODES.map((mode) => (
1907 <button
1908 key={mode}
1909 className={`set-seg__btn${draft.proxyMode === mode ? " set-seg__btn--on" : ""}`}
1910 disabled={busy}
1911 onClick={() => setDraft({ ...draft, proxyMode: mode })}
1912 >
1913 {proxyModeLabel(mode, t)}
1914 </button>
1915 ))}
1916 </SettingsOptions>
1917 </SettingsField>
1918
1919 {draft.proxyMode === "custom" && (
1920 <>
1921 <SettingsField label={t("settings.proxyType")}>
1922 <SettingsOptions layout="field" className="set-seg">
1923 {PROXY_TYPES.map((typ) => (
1924 <button
1925 key={typ}
1926 className={`set-seg__btn${draft.proxy.type === typ ? " set-seg__btn--on" : ""}`}
1927 disabled={busy}
1928 onClick={() => setProxy({ type: typ })}
1929 >
1930 {typ.toUpperCase()}
1931 </button>
1932 ))}
1933 </SettingsOptions>
1934 </SettingsField>
1935 <SettingsField label={t("settings.proxyServer")}>
1936 <div className="settings-inline-controls">
1937 <input
1938 className="mem-input set-grow"
1939 placeholder="127.0.0.1"
1940 value={draft.proxy.server}
1941 disabled={busy || !!draft.proxyUrl.trim()}
1942 onChange={(e) => setProxy({ server: e.target.value })}
1943 />
1944 <label className="set-label">{t("settings.proxyPort")}</label>
1945 <input
1946 className="mem-input set-narrow"
1947 placeholder="7890"
1948 value={draft.proxy.port ? String(draft.proxy.port) : ""}
1949 disabled={busy || !!draft.proxyUrl.trim()}
1950 inputMode="numeric"
1951 onChange={(e) => setProxy({ port: Number(e.target.value) || 0 })}
1952 />
1953 </div>
1954 </SettingsField>
1955 <SettingsField label={t("settings.proxyUsername")}>
1956 <div className="settings-inline-controls">
1957 <input
1958 className="mem-input set-grow"
1959 value={draft.proxy.username}
1960 disabled={busy || !!draft.proxyUrl.trim()}
1961 onChange={(e) => setProxy({ username: e.target.value })}
1962 />
1963 <label className="set-label">{t("settings.proxyPassword")}</label>
1964 <input
1965 className="mem-input set-grow"
1966 type="password"
1967 value={draft.proxy.password}
1968 disabled={busy || !!draft.proxyUrl.trim()}
1969 onChange={(e) => setProxy({ password: e.target.value })}
1970 />
1971 </div>
1972 </SettingsField>
1973 <SettingsField label={t("settings.proxyUrl")} hint={t("settings.proxyUrlHint")}>
1974 <input
1975 className="mem-input set-grow"
1976 placeholder="socks5://127.0.0.1:7890"
1977 value={draft.proxyUrl}
1978 disabled={busy}
1979 onChange={(e) => setDraft({ ...draft, proxyUrl: e.target.value })}
1980 />
1981 </SettingsField>
1982 <SettingsField label={t("settings.noProxy")}>
1983 <input
1984 className="mem-input set-grow"
1985 placeholder="localhost,127.0.0.1,.local"
1986 value={draft.noProxy}
1987 disabled={busy}
1988 onChange={(e) => setDraft({ ...draft, noProxy: e.target.value })}
1989 />
1990 </SettingsField>
1991 </>
1992 )}
1993 <div className="settings-save-bar">
1994 <span role="status">{t(dirty ? "settings.models.unsaved" : "settings.models.saved")}</span>
1995 <button className="btn btn--small" disabled={busy || !dirty} onClick={() => setDraft(savedNetwork)}>{t("common.cancel")}</button>
1996 <button className="btn btn--primary btn--small" disabled={busy || !dirty} onClick={() => void apply(() => app.SetNetwork(draft))}>{t("settings.saveNetwork")}</button>
1997 </div>
1998 </SettingsSection>
1999 );
2000 }
2001
2002 const BOT_ALLOWLIST_TEXT_KEYS = [
2003 "qqUsers",
2004 "feishuUsers",
2005 "weixinUsers",
2006 "qqApprovers",
2007 "feishuApprovers",
2008 "weixinApprovers",
2009 "qqAdmins",
2010 "feishuAdmins",
2011 "weixinAdmins",
2012 "qqGroups",
2013 "feishuGroups",
2014 "weixinGroups",
2015 "dingtalkUsers",
2016 "dingtalkApprovers",
2017 "dingtalkAdmins",
2018 "dingtalkGroups",
2019 ] as const;
2020 type BotAllowlistTextKey = typeof BOT_ALLOWLIST_TEXT_KEYS[number];
2021 type BotSelfUserTextKey = keyof BotSettingsView["selfUserIds"];
2022 type BotInstallState = {
2023 target: BotInstallTarget | "";
2024 result: BotInstallStartResult | null;
2025 status: "idle" | "starting" | "showing" | "connected" | "error";
2026 timeLeft: number;
2027 message: string;
2028 };
2029 const BOT_INSTALL_TARGETS: BotInstallTarget[] = ["qq", "feishu", "lark", "weixin", "dingtalk"];
2030 const BOT_INSTALL_DEFAULT_TIMEOUT_SECONDS = 300;
2031 const BOT_INSTALL_MIN_POLL_SECONDS = 3;
2032 const DEFAULT_QQ_SECRET_ENV = "QQ_BOT_APP_SECRET";
2033 const QQ_CONNECTION_ID = "__qq_bot__";
2034 const DINGTALK_CONNECTION_ID = "__dingtalk_bot__";
2035 const BOT_PLATFORM_KEYS = ["qq", "feishu", "weixin", "dingtalk"] as const;
2036 type BotPlatformKey = typeof BOT_PLATFORM_KEYS[number];
2037 const BOT_ALLOWLIST_ROLES = ["Users", "Groups", "Approvers", "Admins"] as const;
2038 type BotAllowlistRole = typeof BOT_ALLOWLIST_ROLES[number];
2039 type BotAccessListField = "users" | "groups" | "approvers" | "admins";
2040
2041 function botAllowlistKey(platform: BotPlatformKey, role: BotAllowlistRole): BotAllowlistTextKey {
2042 return `${platform}${role}`;
2043 }
2044
2045 function botConnectionPlatform(connection: BotConnectionView): BotPlatformKey {
2046 if (connection.provider === "weixin") return "weixin";
2047 if (connection.provider === "qq") return "qq";
2048 if (connection.provider === "dingtalk") return "dingtalk";
2049 return "feishu";
2050 }
2051
2052 function botPlatformLabel(platform: BotPlatformKey, t: ReturnType<typeof useT>): string {
2053 if (platform === "qq") return "QQ";
2054 if (platform === "weixin") return t("settings.botWeixin");
2055 if (platform === "dingtalk") return t("settings.botDingtalk");
2056 return t("settings.botPlatformFeishuLark");
2057 }
2058
2059 type BotConnectionListItem =
2060 | { kind: "qq" }
2061 | { kind: "connection"; connection: BotConnectionView };
2062
2063 type BotsSectionProps = SectionProps & { initialFocus?: SettingsInitialFocus };
2064
2065 function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
2066 const t = useT();
2067 const savedBot = normalizeBotSettings(s.bot);
2068 const [draft, setDraft] = useState<BotSettingsView>(savedBot);
2069 const [allowlistText, setAllowlistText] = useState<Record<BotAllowlistTextKey, string>>(() => botAllowlistTextValues(savedBot.allowlist));
2070 const [selfUserText, setSelfUserText] = useState<Record<BotSelfUserTextKey, string>>(() => botSelfUserTextValues(savedBot.selfUserIds));
2071 const [showAllPlatforms, setShowAllPlatforms] = useState(false);
2072 const [installTarget, setInstallTarget] = useState<BotInstallTarget>("qq");
2073 const [install, setInstall] = useState<BotInstallState>({ target: "qq", result: null, status: "idle", timeLeft: 0, message: "" });
2074 const [diagnostics, setDiagnostics] = useState<Record<string, BotConnectionDiagnostic | string>>({});
2075 const [testTargets, setTestTargets] = useState<Record<string, string>>({});
2076 const [connectionSecrets, setConnectionSecrets] = useState<Record<string, string>>({});
2077 const [accessText, setAccessText] = useState<Record<string, string>>({});
2078 const [qqSecretValue, setQQSecretValue] = useState("");
2079 const [dingtalkSecretValue, setDingtalkSecretValue] = useState("");
2080 const [dingtalkTesting, setDingtalkTesting] = useState(false);
2081 const [runtimePlatforms, setRuntimePlatforms] = useState<Record<string, string>>({});
2082 const [expandedConnectionId, setExpandedConnectionId] = useState("");
2083 const [advancedMode, setAdvancedMode] = useState(false);
2084 const installRef = useRef(install);
2085 const installPollTimerRef = useRef<number | null>(null);
2086 const installCountdownTimerRef = useRef<number | null>(null);
2087 const installRequestInFlightRef = useRef(false);
2088 const installAttemptRef = useRef(0);
2089 const stepConnectRef = useRef<HTMLElement | null>(null);
2090 const initialFocusHandledRef = useRef("");
2091 const refs = allRefs(s);
2092
2093 useEffect(() => {
2094 const nextBot = normalizeBotSettings(s.bot);
2095 setDraft(nextBot);
2096 setAllowlistText(botAllowlistTextValues(nextBot.allowlist));
2097 setSelfUserText(botSelfUserTextValues(nextBot.selfUserIds));
2098 setConnectionSecrets({});
2099 setAccessText({});
2100 setQQSecretValue("");
2101 setTestTargets({});
2102 }, [s.bot]);
2103 // 轮询 bot runtime 的真实平台连接状态,用于 channel 在线徽章(如钉钉黄/绿点)。
2104 useEffect(() => {
2105 let cancelled = false;
2106 const refresh = () => {
2107 if (typeof app.BotRuntimeStatus !== "function") return;
2108 void app.BotRuntimeStatus()
2109 .then((status) => {
2110 if (!cancelled) setRuntimePlatforms(status.platforms ?? {});
2111 })
2112 .catch(() => {});
2113 };
2114 refresh();
2115 const id = window.setInterval(refresh, 5000);
2116 return () => {
2117 cancelled = true;
2118 window.clearInterval(id);
2119 };
2120 }, []);
2121 const focusAccessStep = () => {
2122 if (!expandedConnectionId && connectionItems.length > 0) {
2123 const first = connectionItems[0];
2124 if (first.kind === "qq") {
2125 setInstallTarget("qq");
2126 setExpandedConnectionId(QQ_CONNECTION_ID);
2127 } else {
2128 const nextTarget = botInstallTargetForConnection(first.connection);
2129 setInstallTarget(nextTarget);
2130 setExpandedConnectionId(first.connection.id);
2131 }
2132 }
2133 window.setTimeout(() => stepConnectRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }), 60);
2134 };
2135 useEffect(() => {
2136 if (initialFocus?.target !== "bot-allowlist") return;
2137 const focusKey = `${initialFocus.target}:${initialFocus.connectionId ?? ""}`;
2138 if (initialFocusHandledRef.current === focusKey) return;
2139 initialFocusHandledRef.current = focusKey;
2140 focusAccessStep();
2141 }, [initialFocus]);
2142 useEffect(() => {
2143 installRef.current = install;
2144 }, [install]);
2145 useEffect(() => {
2146 installAttemptRef.current += 1;
2147 installRequestInFlightRef.current = false;
2148 clearInstallTimers();
2149 setInstall({ target: installTarget, result: null, status: "idle", timeLeft: 0, message: "" });
2150 }, [installTarget]);
2151 useEffect(() => () => {
2152 installAttemptRef.current += 1;
2153 clearInstallTimers();
2154 }, []);
2155
2156 const setConnections = (mapper: (connections: BotConnectionView[]) => BotConnectionView[]) =>
2157 setDraft((prev) => ({ ...prev, connections: mapper(prev.connections) }));
2158 const persistBotDraft = async (nextDraft: BotSettingsView) => {
2159 const nextBot = botDraftWithDerivedGatewayState(nextDraft);
2160 setDraft(nextBot);
2161 await apply(async () => {
2162 await app.SetBotSettings(nextBot);
2163 });
2164 };
2165 const persistConnections = (mapper: (connections: BotConnectionView[]) => BotConnectionView[]) =>
2166 persistBotDraft({ ...draft, connections: mapper(draft.connections) });
2167 const updateConnection = (id: string, patch: Partial<BotConnectionView>) =>
2168 setConnections((items) => items.map((item) => item.id === id ? { ...item, ...patch } : item));
2169 const persistConnection = (id: string, patch: Partial<BotConnectionView>) =>
2170 persistConnections((items) => items.map((item) => item.id === id ? { ...item, ...patch } : item));
2171 const persistConnectionToolApprovalMode = (id: string, mode: string) => {
2172 const normalizedMode = normalizeBotToolApprovalMode(mode, true);
2173 setConnections((items) => items.map((item) => item.id === id ? { ...item, toolApprovalMode: normalizedMode } : item));
2174 void apply(() => app.SetBotConnectionToolApprovalMode(id, normalizedMode));
2175 };
2176 const updateConnectionCredential = (id: string, patch: Partial<BotConnectionView["credential"]>) =>
2177 setConnections((items) => items.map((item) => item.id === id ? { ...item, credential: { ...item.credential, ...patch } } : item));
2178 const persistConnectionCredential = (id: string, patch: Partial<BotConnectionView["credential"]>) =>
2179 persistConnections((items) => items.map((item) => item.id === id ? { ...item, credential: { ...item.credential, ...patch } } : item));
2180 const updateAllowlist = (patch: Partial<BotAllowlistView>) =>
2181 setDraft((prev) => ({ ...prev, allowlist: { ...prev.allowlist, ...patch } }));
2182 const persistAllowlist = (patch: Partial<BotAllowlistView>) =>
2183 persistBotDraft({ ...draft, allowlist: { ...draft.allowlist, ...patch } });
2184 const persistAllowlistText = (key: BotAllowlistTextKey, value: string) => {
2185 const entries = parseBotListInput(value);
2186 setAllowlistText((prev) => ({ ...prev, [key]: entries.join("\n") }));
2187 void persistAllowlist({ [key]: entries } as Partial<BotAllowlistView>);
2188 };
2189 const updateBotSettings = (patch: Partial<BotSettingsView>) =>
2190 setDraft((prev) => ({ ...prev, ...patch }));
2191 const persistBotSettings = (patch: Partial<BotSettingsView>) =>
2192 persistBotDraft({ ...draft, ...patch });
2193 const updateSelfUserText = (key: BotSelfUserTextKey, value: string) =>
2194 setSelfUserText((prev) => ({ ...prev, [key]: value }));
2195 const persistSelfUserText = (key: BotSelfUserTextKey, value: string) => {
2196 const entries = parseBotListInput(value);
2197 const nextSelfUserIds = { ...draft.selfUserIds, [key]: entries };
2198 setSelfUserText((prev) => ({ ...prev, [key]: entries.join("\n") }));
2199 void persistBotSettings({ selfUserIds: nextSelfUserIds });
2200 };
2201 const updateRoute = (index: number, patch: Partial<BotRouteView>) =>
2202 setDraft((prev) => ({
2203 ...prev,
2204 routes: prev.routes.map((route, routeIndex) => routeIndex === index ? normalizeBotRoute({ ...route, ...patch }) : route),
2205 }));
2206 const persistRoute = (index: number, patch: Partial<BotRouteView>) =>
2207 persistBotDraft({
2208 ...draft,
2209 routes: draft.routes.map((route, routeIndex) => routeIndex === index ? normalizeBotRoute({ ...route, ...patch }) : route),
2210 });
2211 const addRoute = () =>
2212 setDraft((prev) => ({ ...prev, routes: [...prev.routes, emptyBotRoute()] }));
2213 const removeRoute = (index: number) =>
2214 void persistBotDraft({ ...draft, routes: draft.routes.filter((_, routeIndex) => routeIndex !== index) });
2215 const updateQQ = (patch: Partial<BotSettingsView["qq"]>) =>
2216 setDraft((prev) => ({ ...prev, qq: { ...prev.qq, ...patch } }));
2217 const persistQQ = (patch: Partial<BotSettingsView["qq"]>) =>
2218 persistBotDraft({ ...draft, qq: { ...draft.qq, ...patch } });
2219 const updateQQAccess = (patch: Partial<BotAccessView>) =>
2220 updateQQ({ access: normalizeBotAccess({ ...draft.qq.access, ...patch }) });
2221 const persistQQAccess = (patch: Partial<BotAccessView>) =>
2222 persistQQ({ access: normalizeBotAccess({ ...draft.qq.access, ...patch }) });
2223 const updateDingtalk = (patch: Partial<BotSettingsView["dingtalk"]>) =>
2224 setDraft((prev) => ({ ...prev, dingtalk: { ...prev.dingtalk, ...patch } }));
2225 const persistDingtalk = (patch: Partial<BotSettingsView["dingtalk"]>) =>
2226 persistBotDraft({ ...draft, dingtalk: { ...draft.dingtalk, ...patch } });
2227 const updateDingtalkAccess = (patch: Partial<BotAccessView>) =>
2228 updateDingtalk({ access: normalizeBotAccess({ ...draft.dingtalk.access, ...patch }) });
2229 const persistDingtalkAccess = (patch: Partial<BotAccessView>) =>
2230 persistDingtalk({ access: normalizeBotAccess({ ...draft.dingtalk.access, ...patch }) });
2231 const updateConnectionAccess = (id: string, patch: Partial<BotAccessView>) =>
2232 setConnections((items) => items.map((item) => item.id === id ? { ...item, access: normalizeBotAccess({ ...item.access, ...patch }) } : item));
2233 const persistConnectionAccess = (connection: BotConnectionView, patch: Partial<BotAccessView>) =>
2234 persistConnection(connection.id, { access: normalizeBotAccess({ ...connection.access, ...patch }) });
2235 const accessTextKey = (id: string, field: BotAccessListField) => `${id}:${field}`;
2236 const accessListText = (id: string, access: BotAccessView, field: BotAccessListField) =>
2237 accessText[accessTextKey(id, field)] ?? access[field].join("\n");
2238 const setAccessListText = (id: string, field: BotAccessListField, value: string) =>
2239 setAccessText((prev) => ({ ...prev, [accessTextKey(id, field)]: value }));
2240 const persistAccessListText = (
2241 id: string,
2242 access: BotAccessView,
2243 field: BotAccessListField,
2244 value: string,
2245 persistAccess: (patch: Partial<BotAccessView>) => void,
2246 ) => {
2247 const entries = parseBotListInput(value);
2248 setAccessText((prev) => ({ ...prev, [accessTextKey(id, field)]: entries.join("\n") }));
2249 persistAccess({ ...access, [field]: entries } as Partial<BotAccessView>);
2250 };
2251 const removeConnection = async (connection: BotConnectionView) => {
2252 const nextDraft = botDraftWithDerivedGatewayState({
2253 ...draft,
2254 connections: draft.connections.filter((item) => item.id !== connection.id),
2255 });
2256 await apply(async () => {
2257 await app.SetBotSettings(nextDraft);
2258 });
2259 };
2260 const installQrURL = install.result?.url ?? "";
2261 const installQrIsImage = installQrURL.startsWith("data:image/");
2262 const isQQInstallTarget = installTarget === "qq";
2263 const isDingtalkInstallTarget = installTarget === "dingtalk";
2264 const selectedInstallLabel = botTargetLabel(installTarget, t);
2265 const installUserCode = install.result?.userCode && installTarget !== "weixin" ? formatInstallUserCode(install.result.userCode) : "";
2266 const qqSecretEnv = draft.qq.appSecretEnv.trim() || DEFAULT_QQ_SECRET_ENV;
2267 const qqConfigured = draft.qq.enabled && draft.qq.appId.trim() && qqSecretEnv && draft.qq.secretSet;
2268 const qqCanEnableAccess = botAccessReady(draft.qq.access);
2269 const qqCanSaveAndEnable = Boolean(draft.qq.appId.trim() && qqSecretEnv && (draft.qq.secretSet || qqSecretValue.trim()) && qqCanEnableAccess);
2270 const qqAdded = qqBotAdded(draft.qq);
2271 const nativeRuntimeAvailable = desktopHost().kind !== "none";
2272 const browserPreviewBotConfigured = !nativeRuntimeAvailable && (qqAdded || draft.connections.length > 0);
2273 const qqOnline = qqConfigured && nativeRuntimeAvailable;
2274 const dingtalkSecretEnv = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
2275 const dingtalkConfigured = Boolean(draft.dingtalk.enabled && draft.dingtalk.clientId.trim() && dingtalkSecretEnv && draft.dingtalk.secretSet);
2276 // 保存按钮仅在用户输入了新的密钥后才可点;已保存过密钥但没有新输入时置灰。
2277 const dingtalkCanSaveAndEnable = Boolean(draft.dingtalk.clientId.trim() && dingtalkSecretEnv && dingtalkSecretValue.trim());
2278 const dingtalkOnline = runtimePlatforms["dingtalk"] === "running" || runtimePlatforms["dingtalk"] === "degraded";
2279 const connectionItems: BotConnectionListItem[] = [
2280 ...(qqAdded ? [{ kind: "qq" as const }] : []),
2281 ...draft.connections.map((connection) => ({ kind: "connection" as const, connection })),
2282 ];
2283 const selectedInstallConnection = isQQInstallTarget || isDingtalkInstallTarget ? undefined : draft.connections.find((connection) => botInstallTargetMatchesConnection(installTarget, connection));
2284 const selectedChannelConfigured = isQQInstallTarget ? qqAdded : isDingtalkInstallTarget ? dingtalkConfigured : Boolean(selectedInstallConnection);
2285 const routeConnectionOptions = [
2286 ...(qqAdded ? [{ id: "qq", label: "QQ" }] : []),
2287 ...draft.connections.map((connection) => ({
2288 id: connection.id || [connection.provider, connection.domain].filter(Boolean).join("-"),
2289 label: connection.label || botConnectionLabel(connection, t),
2290 })).filter((item) => item.id),
2291 ];
2292
2293 const saveBot = () => app.SetBotSettings(botDraftWithDerivedGatewayState(draft));
2294 function clearInstallTimers() {
2295 if (installPollTimerRef.current !== null) {
2296 window.clearTimeout(installPollTimerRef.current);
2297 installPollTimerRef.current = null;
2298 }
2299 if (installCountdownTimerRef.current !== null) {
2300 window.clearInterval(installCountdownTimerRef.current);
2301 installCountdownTimerRef.current = null;
2302 }
2303 }
2304 function beginInstallCountdown(attempt: number) {
2305 if (installCountdownTimerRef.current !== null) {
2306 window.clearInterval(installCountdownTimerRef.current);
2307 }
2308 installCountdownTimerRef.current = window.setInterval(() => {
2309 setInstall((prev) => {
2310 if (installAttemptRef.current !== attempt || prev.status !== "showing") return prev;
2311 return { ...prev, timeLeft: Math.max(0, prev.timeLeft - 1) };
2312 });
2313 }, 1000);
2314 }
2315 function scheduleInstallPoll(attempt: number, interval: number) {
2316 if (installPollTimerRef.current !== null) {
2317 window.clearTimeout(installPollTimerRef.current);
2318 }
2319 installPollTimerRef.current = window.setTimeout(() => void pollInstall(attempt), Math.max(interval || BOT_INSTALL_MIN_POLL_SECONDS, BOT_INSTALL_MIN_POLL_SECONDS) * 1000);
2320 }
2321 const startInstall = async (target: BotOfficialInstallTarget) => {
2322 if (installRequestInFlightRef.current) return;
2323 const existing = draft.connections.find((connection) => botInstallTargetMatchesConnection(target, connection));
2324 if (existing) {
2325 installAttemptRef.current += 1;
2326 clearInstallTimers();
2327 setInstall({ target, result: null, status: "connected", timeLeft: 0, message: t("settings.botInstallAlreadyConnected", { provider: botTargetLabel(target, t) }) });
2328 return;
2329 }
2330 clearInstallTimers();
2331 const attempt = installAttemptRef.current + 1;
2332 installAttemptRef.current = attempt;
2333 installRequestInFlightRef.current = true;
2334 setInstall({ target, result: null, status: "starting", timeLeft: 0, message: t("settings.botInstallStarting") });
2335 const provider = target === "weixin" ? "weixin" : "feishu";
2336 const domain = target === "lark" ? "lark" : target === "weixin" ? "weixin" : "feishu";
2337 try {
2338 const result = await app.StartBotConnectionInstall(provider, domain);
2339 if (installAttemptRef.current !== attempt) return;
2340 if (!result.ok) {
2341 setInstall({ target, result, status: "error", timeLeft: 0, message: result.message || t("settings.botInstallFailed") });
2342 return;
2343 }
2344 const timeLeft = result.expireIn > 0 ? result.expireIn : BOT_INSTALL_DEFAULT_TIMEOUT_SECONDS;
2345 setInstall({ target, result, status: "showing", timeLeft, message: result.message || t("settings.botInstallScanHint") });
2346 beginInstallCountdown(attempt);
2347 scheduleInstallPoll(attempt, result.interval);
2348 } catch (err) {
2349 if (installAttemptRef.current === attempt) {
2350 setInstall({ target, result: null, status: "error", timeLeft: 0, message: err instanceof Error ? err.message : t("settings.botInstallFailed") });
2351 }
2352 } finally {
2353 if (installAttemptRef.current === attempt) {
2354 installRequestInFlightRef.current = false;
2355 }
2356 }
2357 };
2358 const pollInstall = async (attempt = installAttemptRef.current) => {
2359 const current = installRef.current;
2360 if (installAttemptRef.current !== attempt || current.status !== "showing" || !current.result?.installId || !current.target) return;
2361 const poll = await app.PollBotConnectionInstall(current.result.installId);
2362 if (installAttemptRef.current !== attempt) return;
2363 if (poll.done) {
2364 clearInstallTimers();
2365 setDraft((prev) => ({
2366 ...prev,
2367 enabled: true,
2368 connections: [...prev.connections.filter((c) => c.id !== poll.connection.id), poll.connection],
2369 }));
2370 setInstall((prev) => ({ ...prev, status: "connected", timeLeft: 0, message: poll.message || t("settings.botInstallConnected") }));
2371 return;
2372 }
2373 if (poll.error) {
2374 clearInstallTimers();
2375 setInstall((prev) => ({ ...prev, status: "error", timeLeft: 0, message: poll.error }));
2376 return;
2377 }
2378 setInstall((prev) => ({ ...prev, message: poll.message || t("settings.botInstallWaiting") }));
2379 scheduleInstallPoll(attempt, current.result.interval);
2380 };
2381 useEffect(() => {
2382 if (install.status !== "showing" || install.timeLeft > 0) return;
2383 installAttemptRef.current += 1;
2384 clearInstallTimers();
2385 setInstall((prev) => prev.status === "showing" ? { ...prev, status: "error", message: t("settings.botInstallExpired") } : prev);
2386 }, [install.status, install.timeLeft]);
2387 const diagnoseConnection = async (id: string) => {
2388 const diag = await app.DiagnoseBotConnection(id);
2389 setDiagnostics((prev) => ({ ...prev, [id]: diag }));
2390 return diag;
2391 };
2392 const testConnection = async (connection: BotConnectionView) => {
2393 const target = (testTargets[connection.id] ?? firstConnectionRemote(connection)).trim();
2394 const diag = await app.TestBotConnection(connection.id, target);
2395 setDiagnostics((prev) => ({ ...prev, [connection.id]: diag }));
2396 if (diag.messageId && target) {
2397 const updatedAt = new Date().toISOString();
2398 await persistConnections((items) => items.map((item) => {
2399 if (item.id !== connection.id) return item;
2400 const scope = connection.workspaceRoot ? "project" : "global";
2401 const matchesTestMapping = (mapping: BotConnectionView["sessionMappings"][number]) =>
2402 mapping.remoteId === target &&
2403 !mapping.chatType.trim() &&
2404 !mapping.userId.trim() &&
2405 !mapping.threadId.trim();
2406 const sessionMappings = [
2407 ...item.sessionMappings.filter((mapping) => !matchesTestMapping(mapping)),
2408 { remoteId: target, sessionId: "", sessionSource: "", chatType: "", userId: "", threadId: "", scope, workspaceRoot: scope === "project" ? connection.workspaceRoot : "", updatedAt },
2409 ];
2410 return { ...item, sessionMappings, updatedAt };
2411 }));
2412 }
2413 };
2414 const testDingtalkBot = async () => {
2415 setDingtalkTesting(true);
2416 try {
2417 const diag = await app.TestDingtalkBot();
2418 setDiagnostics((prev) => ({ ...prev, dingtalk: diag }));
2419 } finally {
2420 setDingtalkTesting(false);
2421 }
2422 };
2423 const ensureReportableDiagnostic = async (connection: BotConnectionView) => {
2424 return diagnoseConnection(connection.id);
2425 };
2426 const copyConnectionDiagnostic = async (connection: BotConnectionView) => {
2427 const diag = await ensureReportableDiagnostic(connection);
2428 if (!diag.reportDetail) return;
2429 try {
2430 await navigator.clipboard.writeText(diag.reportDetail);
2431 setDiagnostics((prev) => ({ ...prev, [connection.id]: { ...diag, message: t("settings.botDiagnosticCopied") } }));
2432 } catch (err) {
2433 setDiagnostics((prev) => ({
2434 ...prev,
2435 [connection.id]: { ...diag, status: "error", message: err instanceof Error ? err.message : t("settings.botDiagnosticCopyFailed") },
2436 }));
2437 }
2438 };
2439 const reportConnectionDiagnostic = async (connection: BotConnectionView) => {
2440 const diag = await ensureReportableDiagnostic(connection);
2441 if (!diag.reportDetail) return;
2442 try {
2443 await app.ReportCrash(diag.reportKind || "bot", diag.reportDetail);
2444 setDiagnostics((prev) => ({ ...prev, [connection.id]: { ...diag, status: "ok", message: t("settings.botDiagnosticReportSent") } }));
2445 } catch (err) {
2446 setDiagnostics((prev) => ({
2447 ...prev,
2448 [connection.id]: { ...diag, status: "error", message: err instanceof Error ? err.message : t("settings.botDiagnosticReportFailed") },
2449 }));
2450 }
2451 };
2452 const saveConnectionSecret = async (connection: BotConnectionView) => {
2453 const env = botConnectionSecretEnv(connection).trim();
2454 const value = (connectionSecrets[connection.id] ?? "").trim();
2455 if (!env || !value) return;
2456 await apply(async () => {
2457 await saveBot();
2458 await app.SetBotSecret(env, value);
2459 });
2460 setConnectionSecrets((prev) => ({ ...prev, [connection.id]: "" }));
2461 };
2462 const clearConnectionSecret = async (connection: BotConnectionView) => {
2463 const env = botConnectionSecretEnv(connection).trim();
2464 if (!env) return;
2465 await apply(async () => {
2466 await saveBot();
2467 await app.ClearBotSecret(env);
2468 });
2469 };
2470 const clearQQSecret = async () => {
2471 const env = draft.qq.appSecretEnv.trim() || DEFAULT_QQ_SECRET_ENV;
2472 if (!env) return;
2473 await apply(async () => {
2474 await saveBot();
2475 await app.ClearBotSecret(env);
2476 });
2477 setQQSecretValue("");
2478 };
2479 const clearDingtalkSecret = async () => {
2480 const env = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
2481 if (!env) return;
2482 await apply(async () => {
2483 await saveBot();
2484 await app.ClearBotSecret(env);
2485 });
2486 setDingtalkSecretValue("");
2487 };
2488 const focusQQAccessSettings = () => {
2489 setDiagnostics((prev) => ({ ...prev, [QQ_CONNECTION_ID]: t("settings.botQQAccessRequired") }));
2490 setExpandedConnectionId(QQ_CONNECTION_ID);
2491 window.setTimeout(() => stepConnectRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }), 60);
2492 };
2493 const saveQQAndEnable = async () => {
2494 if (!qqCanEnableAccess) {
2495 focusQQAccessSettings();
2496 return;
2497 }
2498 const env = draft.qq.appSecretEnv.trim() || DEFAULT_QQ_SECRET_ENV;
2499 const secret = qqSecretValue.trim();
2500 const nextDraft = botDraftWithDerivedGatewayState({
2501 ...draft,
2502 qq: {
2503 ...draft.qq,
2504 enabled: true,
2505 appId: draft.qq.appId.trim(),
2506 appSecretEnv: env,
2507 secretSet: draft.qq.secretSet || Boolean(secret),
2508 },
2509 });
2510 await apply(async () => {
2511 await app.SetBotSettings(nextDraft);
2512 if (secret) await app.SetBotSecret(env, secret);
2513 });
2514 setDraft(nextDraft);
2515 setQQSecretValue("");
2516 };
2517 const saveDingtalkAndEnable = async () => {
2518 const env = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
2519 const secret = dingtalkSecretValue.trim();
2520 const nextDraft = botDraftWithDerivedGatewayState({
2521 ...draft,
2522 dingtalk: {
2523 ...draft.dingtalk,
2524 enabled: true,
2525 clientId: draft.dingtalk.clientId.trim(),
2526 clientSecretEnv: env,
2527 secretSet: draft.dingtalk.secretSet || Boolean(secret),
2528 },
2529 });
2530 await apply(async () => {
2531 await app.SetBotSettings(nextDraft);
2532 if (secret) await app.SetBotSecret(env, secret);
2533 });
2534 setDraft(nextDraft);
2535 setDingtalkSecretValue("");
2536 };
2537 const removeDingtalkBot = async () => {
2538 const env = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
2539 const nextDraft = botDraftWithDerivedGatewayState({
2540 ...draft,
2541 dingtalk: { enabled: false, clientId: "", clientSecretEnv: "DINGTALK_CLIENT_SECRET", secretSet: false, botName: "", requireMention: true, model: "", toolApprovalMode: "", workspaceRoot: "", access: defaultBotAccess() },
2542 });
2543 await apply(async () => {
2544 await app.SetBotSettings(nextDraft);
2545 if (draft.dingtalk.secretSet) await app.ClearBotSecret(env);
2546 });
2547 setDraft(nextDraft);
2548 };
2549 const removeQQBot = async () => {
2550 const env = draft.qq.appSecretEnv.trim() || DEFAULT_QQ_SECRET_ENV;
2551 const nextDraft = botDraftWithDerivedGatewayState({
2552 ...draft,
2553 qq: { enabled: false, appId: "", appSecretEnv: DEFAULT_QQ_SECRET_ENV, secretSet: false, sandbox: false, model: "", toolApprovalMode: "workspace-write", workspaceRoot: "", access: defaultBotAccess() },
2554 });
2555 await apply(async () => {
2556 await app.SetBotSettings(nextDraft);
2557 if (draft.qq.secretSet) await app.ClearBotSecret(env);
2558 });
2559 setDraft(nextDraft);
2560 setQQSecretValue("");
2561 setExpandedConnectionId("");
2562 };
2563 const selectedQQ = isQQInstallTarget && qqAdded;
2564 const selectedConnection = isQQInstallTarget || isDingtalkInstallTarget ? null : selectedInstallConnection ?? null;
2565 const selectedDingtalk = isDingtalkInstallTarget && dingtalkConfigured;
2566 const selectedDiagnostic = selectedConnection ? diagnostics[selectedConnection.id] : undefined;
2567 const selectedDiagnosticDetail = diagnosticReportDetail(selectedDiagnostic);
2568 const selectedConnectionRemote = selectedConnection ? firstConnectionRemote(selectedConnection) : "";
2569 const selectedConnectionToolApprovalMode = selectedConnection ? normalizeBotToolApprovalMode(selectedConnection.toolApprovalMode) : "workspace-write";
2570 const simpleAccessMode = draft.allowlist.allowAll ? "everyone" : "trusted";
2571 const connectedPlatforms = new Set<BotPlatformKey>();
2572 if (qqAdded) connectedPlatforms.add("qq");
2573 for (const connection of draft.connections) connectedPlatforms.add(botConnectionPlatform(connection));
2574 const platformHasAllowlistText = (platform: BotPlatformKey) =>
2575 BOT_ALLOWLIST_ROLES.some((role) => allowlistText[botAllowlistKey(platform, role)].trim());
2576 const visibleAccessPlatforms = BOT_PLATFORM_KEYS.filter((platform) =>
2577 showAllPlatforms || connectedPlatforms.size === 0 || connectedPlatforms.has(platform) || platformHasAllowlistText(platform));
2578 const platformFilterAvailable = connectedPlatforms.size > 0 &&
2579 BOT_PLATFORM_KEYS.some((platform) => !connectedPlatforms.has(platform) && !platformHasAllowlistText(platform));
2580 const botChannelConnectionForTarget = (target: BotInstallTarget) =>
2581 target === "qq" || target === "dingtalk" ? null : draft.connections.find((connection) => botInstallTargetMatchesConnection(target, connection));
2582 const botChannelIsConfigured = (target: BotInstallTarget) =>
2583 target === "qq" ? qqAdded : target === "dingtalk" ? dingtalkConfigured : Boolean(botChannelConnectionForTarget(target));
2584 const openBotChannel = (target: BotInstallTarget) => {
2585 setInstallTarget(target);
2586 const connection = botChannelConnectionForTarget(target);
2587 setExpandedConnectionId(target === "qq" && qqAdded ? QQ_CONNECTION_ID : connection?.id || "");
2588 };
2589 const setSimpleAccessMode = (mode: "trusted" | "everyone") => {
2590 const patch = mode === "everyone"
2591 ? { enabled: false, allowAll: true }
2592 : { enabled: true, allowAll: false };
2593 updateAllowlist(patch);
2594 void persistAllowlist(patch);
2595 };
2596 const renderBotAccessSection = (
2597 id: string,
2598 access: BotAccessView,
2599 updateAccess: (patch: Partial<BotAccessView>) => void,
2600 persistAccess: (patch: Partial<BotAccessView>) => void,
2601 ) => {
2602 const mode = access.allowAll ? "everyone" : "trusted";
2603 const setMode = (nextMode: "trusted" | "everyone") => {
2604 const patch = nextMode === "everyone"
2605 ? { enabled: false, allowAll: true }
2606 : { enabled: true, allowAll: false };
2607 updateAccess(patch);
2608 persistAccess(patch);
2609 };
2610 return (
2611 <section className="bot-detail-section bot-detail-section--access">
2612 <div>
2613 <div className="bot-detail-section__head">{t("settings.botAccessControl")}</div>
2614 <p>{access.allowAll ? t("settings.botSimpleAccessEveryoneSummary") : t("settings.botSimpleAccessTrustedSummary", { count: botAccessEntryCount(access) })}</p>
2615 </div>
2616 <div className="bot-choice-grid bot-choice-grid--access">
2617 <button
2618 type="button"
2619 className={`bot-choice-card${mode === "trusted" ? " bot-choice-card--active" : ""}`}
2620 disabled={busy}
2621 onClick={() => setMode("trusted")}
2622 >
2623 <strong>{t("settings.botAccessTrusted")}</strong>
2624 <span>{t("settings.botAccessTrustedHint")}</span>
2625 </button>
2626 <button
2627 type="button"
2628 className={`bot-choice-card${mode === "everyone" ? " bot-choice-card--active" : ""}`}
2629 disabled={busy}
2630 onClick={() => setMode("everyone")}
2631 >
2632 <strong>{t("settings.botAccessEveryone")}</strong>
2633 <span>{t("settings.botAccessEveryoneHint")}</span>
2634 </button>
2635 </div>
2636 <div className="bot-pairing-row">
2637 <div>
2638 <strong>{t("settings.botAccessPairing")}</strong>
2639 <span>{t("settings.botAccessPairingHint")}</span>
2640 </div>
2641 <ToggleSegment
2642 value={access.pairingEnabled}
2643 disabled={busy}
2644 onChange={(pairingEnabled) => {
2645 updateAccess({ pairingEnabled });
2646 persistAccess({ pairingEnabled });
2647 }}
2648 />
2649 </div>
2650 {access.allowAll ? (
2651 <div className="bot-access-panel__warning">{t("settings.botAllowAllWarn")}</div>
2652 ) : (
2653 <div className="bot-access-platforms bot-access-platforms--single">
2654 <div className="bot-access-platform">
2655 <BotListInput
2656 label={t("settings.botListUsers")}
2657 value={accessListText(id, access, "users")}
2658 disabled={busy}
2659 placeholder={t("settings.botListPlaceholder")}
2660 onChange={(value) => setAccessListText(id, "users", value)}
2661 onBlur={(value) => persistAccessListText(id, access, "users", value, persistAccess)}
2662 />
2663 <BotListInput
2664 label={t("settings.botListGroups")}
2665 value={accessListText(id, access, "groups")}
2666 disabled={busy}
2667 placeholder={t("settings.botListPlaceholder")}
2668 onChange={(value) => setAccessListText(id, "groups", value)}
2669 onBlur={(value) => persistAccessListText(id, access, "groups", value, persistAccess)}
2670 />
2671 </div>
2672 </div>
2673 )}
2674 <details className="bot-access-panel bot-simple-roles">
2675 <summary className="bot-access-panel__summary">
2676 <span>
2677 <strong>{t("settings.botRoleAccess")}</strong>
2678 <small>{t("settings.botRoleAccessHint")}</small>
2679 </span>
2680 <ChevronDown className="bot-access-panel__chevron" size={16} aria-hidden="true" />
2681 </summary>
2682 <div className="bot-access-panel__body">
2683 <div className="bot-access-platforms bot-access-platforms--single">
2684 <div className="bot-access-platform">
2685 <BotListInput
2686 label={t("settings.botListApprovers")}
2687 value={accessListText(id, access, "approvers")}
2688 disabled={busy || access.allowAll}
2689 placeholder={t("settings.botListPlaceholder")}
2690 onChange={(value) => setAccessListText(id, "approvers", value)}
2691 onBlur={(value) => persistAccessListText(id, access, "approvers", value, persistAccess)}
2692 />
2693 <BotListInput
2694 label={t("settings.botListAdmins")}
2695 value={accessListText(id, access, "admins")}
2696 disabled={busy || access.allowAll}
2697 placeholder={t("settings.botListPlaceholder")}
2698 onChange={(value) => setAccessListText(id, "admins", value)}
2699 onBlur={(value) => persistAccessListText(id, access, "admins", value, persistAccess)}
2700 />
2701 </div>
2702 </div>
2703 </div>
2704 </details>
2705 </section>
2706 );
2707 };
2708 const qqDetailCard = (
2709 <article className="bot-detail-card" aria-labelledby="bot-detail-title">
2710 <div className="bot-detail-card__head">
2711 <div className="bot-detail-card__identity">
2712 <div className="bot-detail-card__title" id="bot-detail-title">
2713 QQ Bot
2714 <span className="badge badge--neutral">QQ</span>
2715 <span className={`badge ${qqOnline ? "badge--project" : qqConfigured ? "badge--feedback" : "badge--feedback"}`}>
2716 {qqOnline ? t("settings.botConnectionConnected") : qqConfigured ? t("settings.botConnectionConfigured") : t("settings.botConnectionDisconnected")}
2717 </span>
2718 </div>
2719 <div className="bot-detail-card__desc">{t("settings.botAutoSaveHint")}</div>
2720 </div>
2721 </div>
2722
2723 <section className="bot-detail-section">
2724 <div className="bot-detail-section__head">{t("settings.botConnectionSummary")}</div>
2725 <div className="bot-detail-summary">
2726 <div>
2727 <span>{t("settings.botConnectionColumnChannel")}</span>
2728 <strong>QQ</strong>
2729 </div>
2730 <div>
2731 <span>{t("settings.botConnectionColumnRemote")}</span>
2732 <code title={draft.qq.appId.trim() || undefined}>{draft.qq.appId.trim() || "—"}</code>
2733 </div>
2734 <div>
2735 <span>{t("settings.botConnectionColumnScope")}</span>
2736 <strong>{t("settings.botScopeGlobal")}</strong>
2737 </div>
2738 <div>
2739 <span>{t("settings.botConnectionColumnStatus")}</span>
2740 <strong>{qqOnline ? t("settings.botConnectionConnected") : qqConfigured ? t("settings.botConnectionConfigured") : t("settings.botConnectionDisconnected")}</strong>
2741 </div>
2742 </div>
2743 </section>
2744
2745 <section className="bot-detail-section bot-detail-section--runtime-primary">
2746 <SettingsField label={t("settings.botEnableBot")} hint={t("settings.botGatewayEnabled")}>
2747 <ToggleSegment
2748 value={draft.qq.enabled}
2749 disabled={busy}
2750 onChange={(enabled) => {
2751 if (enabled && !qqCanEnableAccess) {
2752 focusQQAccessSettings();
2753 return;
2754 }
2755 updateQQ({ enabled });
2756 void persistQQ({ enabled });
2757 }}
2758 />
2759 </SettingsField>
2760 <SettingsField label={t("settings.botToolApprovalMode")} hint={t("settings.botToolApprovalModeHint")}>
2761 <SettingsOptions className="provider-add-segmented" role="group" aria-label={t("settings.botToolApprovalMode")}>
2762 {TOOL_APPROVAL_MODES.map((mode) => (
2763 <button
2764 key={mode}
2765 type="button"
2766 className={normalizeBotToolApprovalMode(draft.qq.toolApprovalMode) === mode ? "provider-add-segmented__item provider-add-segmented__item--active" : "provider-add-segmented__item"}
2767 disabled={busy}
2768 onClick={() => void persistQQ({ toolApprovalMode: mode })}
2769 >
2770 {t(`settings.botToolApprovalMode.${mode}` as DictKey)}
2771 </button>
2772 ))}
2773 </SettingsOptions>
2774 </SettingsField>
2775 <SettingsField label={t("settings.botChannelModel")} hint={t("settings.botChannelModelHint")}>
2776 <ModelPicker
2777 s={s}
2778 refs={refs}
2779 value={toRef(draft.qq.model, s)}
2780 disabled={busy}
2781 emptyOptionLabel={t("settings.botChannelModelAuto")}
2782 emptyOptionHint={settingsModelMeta(s, t)}
2783 onPick={(model) => void persistQQ({ model })}
2784 />
2785 </SettingsField>
2786 </section>
2787
2788 {renderBotAccessSection(QQ_CONNECTION_ID, draft.qq.access, updateQQAccess, (patch) => void persistQQAccess(patch))}
2789
2790 <section className="bot-detail-section">
2791 <div className="bot-detail-section__head">{t("settings.botRuntimeSettings")}</div>
2792 <SettingsField label={t("settings.botSandbox")} hint={t("settings.botInstallQQHint")}>
2793 <ToggleSegment
2794 value={draft.qq.sandbox}
2795 disabled={busy}
2796 onLabel={t("settings.toggleOn")}
2797 offLabel={t("settings.toggleOff")}
2798 onChange={(sandbox) => {
2799 updateQQ({ sandbox });
2800 void persistQQ({ sandbox });
2801 }}
2802 />
2803 </SettingsField>
2804 <SettingsField label={t("settings.botWorkspaceRoot")} hint={t("settings.botWorkspaceRootHint")}>
2805 <input
2806 className="mem-input"
2807 value={draft.qq.workspaceRoot}
2808 disabled={busy}
2809 placeholder={t("settings.botWorkspaceRootPlaceholder")}
2810 spellCheck={false}
2811 onChange={(event) => updateQQ({ workspaceRoot: event.target.value })}
2812 onBlur={(event) => void persistQQ({ workspaceRoot: event.currentTarget.value })}
2813 />
2814 </SettingsField>
2815 </section>
2816
2817 <section className="bot-detail-section">
2818 <div className="bot-detail-section__head">{t("settings.botCredential")}</div>
2819 <div className="bot-credential-stack">
2820 <div className="bot-credential-line">
2821 <span>{draft.qq.appId.trim() ? t("settings.botCredentialApp", { value: draft.qq.appId.trim() }) : t("settings.botCredentialConfigured")}</span>
2822 <strong>{draft.qq.secretSet ? t("settings.botSecretSet") : t("settings.botSecretMissing")}</strong>
2823 </div>
2824 <div className="bot-secret-row bot-secret-row--qq">
2825 <input
2826 className="mem-input"
2827 value={draft.qq.appId}
2828 disabled={busy}
2829 placeholder={t("settings.botAppId")}
2830 spellCheck={false}
2831 aria-label={t("settings.botAppId")}
2832 onChange={(event) => updateQQ({ appId: event.target.value })}
2833 onBlur={(event) => void persistQQ({ appId: event.currentTarget.value })}
2834 />
2835 <input
2836 className="mem-input"
2837 value={draft.qq.appSecretEnv || DEFAULT_QQ_SECRET_ENV}
2838 disabled={busy}
2839 placeholder={DEFAULT_QQ_SECRET_ENV}
2840 spellCheck={false}
2841 aria-label={t("settings.botSecretEnv")}
2842 onChange={(event) => updateQQ({ appSecretEnv: event.target.value })}
2843 onBlur={(event) => void persistQQ({ appSecretEnv: event.currentTarget.value || DEFAULT_QQ_SECRET_ENV })}
2844 />
2845 <input
2846 className="mem-input"
2847 type="password"
2848 value={qqSecretValue}
2849 disabled={busy}
2850 placeholder={draft.qq.secretSet ? t("settings.botSecretReplace") : t("settings.botSecretPaste")}
2851 aria-label={t("settings.botSecretValue")}
2852 onChange={(event) => setQQSecretValue(event.target.value)}
2853 />
2854 <button type="button" className="btn btn--secondary btn--small" disabled={busy || !qqCanSaveAndEnable} onClick={() => void saveQQAndEnable()}>
2855 {draft.qq.secretSet ? t("settings.saveKey") : t("settings.botSaveAndEnable")}
2856 </button>
2857 <button type="button" className="btn btn--secondary btn--small" disabled={busy || !draft.qq.secretSet} onClick={() => void clearQQSecret()}>
2858 {t("settings.clearKey")}
2859 </button>
2860 </div>
2861 {!qqCanEnableAccess ? <div className="bot-connect-panel__hint bot-connect-panel__hint--warning">{t("settings.botQQAccessRequired")}</div> : null}
2862 </div>
2863 </section>
2864
2865 <section className="bot-detail-section bot-detail-section--danger">
2866 <div>
2867 <div className="bot-detail-section__head">{t("settings.botDangerZone")}</div>
2868 <p>{t("settings.deleteBotHint")}</p>
2869 </div>
2870 <InlineConfirmButton
2871 label={t("settings.deleteBot")}
2872 confirmLabel={t("settings.confirmDeleteBot")}
2873 cancelLabel={t("common.cancel")}
2874 disabled={busy}
2875 danger
2876 onConfirm={() => void removeQQBot()}
2877 />
2878 </section>
2879 </article>
2880 );
2881
2882 const dingtalkDetailCard = (
2883 <article className="bot-detail-card" aria-labelledby="bot-detail-title">
2884 <div className="bot-detail-card__head">
2885 <div className="bot-detail-card__identity">
2886 <div className="bot-detail-card__title" id="bot-detail-title">
2887 DingTalk Bot
2888 <span className="badge badge--neutral">DingTalk</span>
2889 <span className={`badge ${dingtalkOnline ? "badge--project" : dingtalkConfigured ? "badge--feedback" : "badge--feedback"}`}>
2890 {dingtalkOnline ? t("settings.botConnectionConnected") : dingtalkConfigured ? t("settings.botConnectionConfigured") : t("settings.botConnectionDisconnected")}
2891 </span>
2892 </div>
2893 <div className="bot-detail-card__desc">{t("settings.botAutoSaveHint")}</div>
2894 </div>
2895 <div className="bot-detail-card__actions">
2896 <button type="button" className="btn btn--small" disabled={busy || !dingtalkConfigured || dingtalkTesting} onClick={() => void testDingtalkBot()}>
2897 {dingtalkTesting ? t("settings.botTesting") : t("settings.botTest")}
2898 </button>
2899 </div>
2900 </div>
2901
2902 <section className="bot-detail-section">
2903 <div className="bot-detail-section__head">{t("settings.botConnectionSummary")}</div>
2904 <div className="bot-detail-summary">
2905 <div>
2906 <span>{t("settings.botConnectionColumnChannel")}</span>
2907 <strong>DingTalk</strong>
2908 </div>
2909 <div>
2910 <span>{t("settings.botDingtalkClientId")}</span>
2911 <code title={draft.dingtalk.clientId.trim() || undefined}>{draft.dingtalk.clientId.trim() || "—"}</code>
2912 </div>
2913 <div>
2914 <span>{t("settings.botConnectionColumnScope")}</span>
2915 <strong>{t("settings.botScopeGlobal")}</strong>
2916 </div>
2917 <div>
2918 <span>{t("settings.botConnectionColumnStatus")}</span>
2919 <strong>{dingtalkOnline ? t("settings.botConnectionConnected") : dingtalkConfigured ? t("settings.botConnectionConfigured") : t("settings.botConnectionDisconnected")}</strong>
2920 </div>
2921 </div>
2922 </section>
2923
2924 <section className="bot-detail-section bot-detail-section--runtime-primary">
2925 <SettingsField label={t("settings.botEnableBot")} hint={t("settings.botGatewayEnabled")}>
2926 <ToggleSegment
2927 value={draft.dingtalk.enabled}
2928 disabled={busy}
2929 onChange={(enabled) => {
2930 updateDingtalk({ enabled });
2931 void persistDingtalk({ enabled });
2932 }}
2933 />
2934 </SettingsField>
2935 <SettingsField label={t("settings.botToolApprovalMode")} hint={t("settings.botToolApprovalModeHint")}>
2936 <SettingsOptions className="provider-add-segmented" role="group" aria-label={t("settings.botToolApprovalMode")}>
2937 {TOOL_APPROVAL_MODES.map((mode) => (
2938 <button
2939 key={mode}
2940 type="button"
2941 className={normalizeBotToolApprovalMode(draft.dingtalk.toolApprovalMode, true) === mode ? "provider-add-segmented__item provider-add-segmented__item--active" : "provider-add-segmented__item"}
2942 disabled={busy}
2943 onClick={() => {
2944 updateDingtalk({ toolApprovalMode: mode });
2945 // 热更新运行中 gateway,不重启 bot runtime(避免面板跳变)。
2946 void app.SetBotDingtalkToolApprovalMode(mode).catch((e) => {
2947 console.warn("set dingtalk approval mode failed", e);
2948 });
2949 }}
2950 >
2951 {t(`settings.botToolApprovalMode.${mode}` as DictKey)}
2952 </button>
2953 ))}
2954 </SettingsOptions>
2955 </SettingsField>
2956 <SettingsField label={t("settings.botChannelModel")} hint={t("settings.botChannelModelHint")}>
2957 <ModelPicker
2958 s={s}
2959 refs={refs}
2960 value={toRef(draft.dingtalk.model, s)}
2961 disabled={busy}
2962 emptyOptionLabel={t("settings.botChannelModelAuto")}
2963 emptyOptionHint={settingsModelMeta(s, t)}
2964 onPick={(model) => {
2965 updateDingtalk({ model });
2966 void persistDingtalk({ model });
2967 }}
2968 />
2969 </SettingsField>
2970 </section>
2971
2972 {renderBotAccessSection(DINGTALK_CONNECTION_ID, draft.dingtalk.access, updateDingtalkAccess, (patch) => void persistDingtalkAccess(patch))}
2973
2974 <section className="bot-detail-section">
2975 <div className="bot-detail-section__head">{t("settings.botRuntimeSettings")}</div>
2976 <SettingsField label={t("settings.botWorkspaceRoot")} hint={t("settings.botWorkspaceRootHint")}>
2977 <input
2978 className="mem-input"
2979 value={draft.dingtalk.workspaceRoot}
2980 disabled={busy}
2981 placeholder={t("settings.botWorkspaceRootPlaceholder")}
2982 spellCheck={false}
2983 onChange={(event) => updateDingtalk({ workspaceRoot: event.target.value })}
2984 onBlur={(event) => void persistDingtalk({ workspaceRoot: event.currentTarget.value })}
2985 />
2986 </SettingsField>
2987 <SettingsField label={t("settings.botDingtalkBotName")} hint={t("settings.botDingtalkBotNameHint")}>
2988 <input
2989 className="mem-input"
2990 value={draft.dingtalk.botName}
2991 disabled={busy}
2992 placeholder={t("settings.botDingtalkBotName")}
2993 spellCheck={false}
2994 onChange={(event) => updateDingtalk({ botName: event.target.value })}
2995 onBlur={(event) => void persistDingtalk({ botName: event.currentTarget.value })}
2996 />
2997 </SettingsField>
2998 <SettingsField label={t("settings.botDingtalkRequireMention")} hint={t("settings.botDingtalkRequireMentionHint")}>
2999 <ToggleSegment
3000 value={draft.dingtalk.requireMention}
3001 disabled={busy}
3002 onLabel={t("settings.toggleOn")}
3003 offLabel={t("settings.toggleOff")}
3004 onChange={(requireMention) => {
3005 updateDingtalk({ requireMention });
3006 void persistDingtalk({ requireMention });
3007 }}
3008 />
3009 </SettingsField>
3010 </section>
3011
3012 <section className="bot-detail-section">
3013 <div className="bot-detail-section__head">{t("settings.botCredential")}</div>
3014 <div className="bot-credential-stack">
3015 <div className="bot-credential-line">
3016 <span>{draft.dingtalk.clientId.trim() ? t("settings.botCredentialClientId", { value: draft.dingtalk.clientId.trim() }) : t("settings.botCredentialConfigured")}</span>
3017 <strong>{draft.dingtalk.secretSet ? t("settings.botSecretSet") : t("settings.botSecretMissing")}</strong>
3018 </div>
3019 <div className="bot-secret-row bot-secret-row--dingtalk">
3020 <input
3021 className="mem-input"
3022 value={draft.dingtalk.clientId}
3023 disabled={busy}
3024 placeholder={t("settings.botDingtalkClientId")}
3025 spellCheck={false}
3026 aria-label={t("settings.botDingtalkClientId")}
3027 onChange={(event) => updateDingtalk({ clientId: event.target.value })}
3028 onBlur={(event) => void persistDingtalk({ clientId: event.currentTarget.value })}
3029 />
3030 <input
3031 className="mem-input"
3032 value={draft.dingtalk.clientSecretEnv || "DINGTALK_CLIENT_SECRET"}
3033 disabled={busy}
3034 placeholder="DINGTALK_CLIENT_SECRET"
3035 spellCheck={false}
3036 aria-label={t("settings.botSecretEnv")}
3037 onChange={(event) => updateDingtalk({ clientSecretEnv: event.target.value })}
3038 onBlur={(event) => void persistDingtalk({ clientSecretEnv: event.currentTarget.value || "DINGTALK_CLIENT_SECRET" })}
3039 />
3040 <input
3041 className="mem-input"
3042 type="password"
3043 value={dingtalkSecretValue}
3044 disabled={busy}
3045 placeholder={draft.dingtalk.secretSet ? t("settings.botSecretReplace") : t("settings.botSecretPaste")}
3046 aria-label={t("settings.botDingtalkClientSecret")}
3047 onChange={(event) => setDingtalkSecretValue(event.target.value)}
3048 />
3049 <div className="bot-secret-row__actions">
3050 <button type="button" className="btn btn--secondary btn--small" disabled={busy || !dingtalkCanSaveAndEnable} onClick={() => void saveDingtalkAndEnable()}>
3051 {draft.dingtalk.secretSet ? t("settings.saveKey") : t("settings.botSaveAndEnable")}
3052 </button>
3053 <button type="button" className="btn btn--secondary btn--small" disabled={busy || !draft.dingtalk.secretSet} onClick={() => void clearDingtalkSecret()}>
3054 {t("settings.clearKey")}
3055 </button>
3056 </div>
3057 </div>
3058 </div>
3059 </section>
3060
3061 {diagnosticMessage(diagnostics.dingtalk) ? (
3062 <div className="bot-detail-notice">
3063 <span>{diagnosticMessage(diagnostics.dingtalk)}</span>
3064 </div>
3065 ) : null}
3066
3067 <section className="bot-detail-section bot-detail-section--danger">
3068 <div>
3069 <div className="bot-detail-section__head">{t("settings.botDangerZone")}</div>
3070 <p>{t("settings.deleteBotHint")}</p>
3071 </div>
3072 <InlineConfirmButton
3073 label={t("settings.deleteBot")}
3074 confirmLabel={t("settings.confirmDeleteBot")}
3075 cancelLabel={t("common.cancel")}
3076 disabled={busy}
3077 danger
3078 onConfirm={() => void removeDingtalkBot()}
3079 />
3080 </section>
3081 </article>
3082 );
3083
3084 const connectionDetailCard = selectedConnection ? (
3085 <article className="bot-detail-card" aria-labelledby="bot-detail-title">
3086 <div className="bot-detail-card__head">
3087 <div className="bot-detail-card__identity">
3088 <div className="bot-detail-card__title" id="bot-detail-title">
3089 {selectedConnection.label || botConnectionLabel(selectedConnection, t)}
3090 <span className="badge badge--neutral">{botConnectionLabel(selectedConnection, t)}</span>
3091 <span className={`badge ${selectedConnection.status === "connected" ? "badge--project" : "badge--feedback"}`}>
3092 {selectedConnection.status === "connected" ? t("settings.botConnectionConnected") : selectedConnection.status || t("settings.botConnectionDisconnected")}
3093 </span>
3094 </div>
3095 <div className="bot-detail-card__desc">{t("settings.botAutoSaveHint")}</div>
3096 </div>
3097 <div className="bot-detail-card__actions">
3098 <button type="button" className="btn btn--small" disabled={busy} onClick={() => void diagnoseConnection(selectedConnection.id)}>
3099 {t("settings.botDiagnose")}
3100 </button>
3101 {(selectedConnection.provider === "feishu" || selectedConnection.provider === "weixin") ? (
3102 <button type="button" className="btn btn--small" disabled={busy || !selectedConnectionRemote} onClick={() => void testConnection(selectedConnection)}>
3103 {t("settings.botTest")}
3104 </button>
3105 ) : null}
3106 </div>
3107 </div>
3108
3109 {diagnosticMessage(selectedDiagnostic) ? (
3110 <div className="bot-detail-notice">
3111 <span>{diagnosticMessage(selectedDiagnostic)}</span>
3112 {selectedDiagnosticDetail ? (
3113 <div className="bot-diagnostic-actions">
3114 <button type="button" className="btn btn--secondary btn--small" disabled={busy} onClick={() => void copyConnectionDiagnostic(selectedConnection)}>
3115 <Clipboard aria-hidden="true" />
3116 {t("settings.botCopyDiagnostic")}
3117 </button>
3118 <button type="button" className="btn btn--primary btn--small" disabled={busy} onClick={() => void reportConnectionDiagnostic(selectedConnection)}>
3119 <Send aria-hidden="true" />
3120 {t("settings.botSendDiagnostic")}
3121 </button>
3122 <small>{t("settings.botDiagnosticPrivacy")}</small>
3123 </div>
3124 ) : null}
3125 </div>
3126 ) : null}
3127
3128 <section className="bot-detail-section">
3129 <div className="bot-detail-section__head">{t("settings.botConnectionSummary")}</div>
3130 <div className="bot-detail-summary">
3131 <div>
3132 <span>{t("settings.botConnectionColumnChannel")}</span>
3133 <strong>{botConnectionLabel(selectedConnection, t)}</strong>
3134 </div>
3135 <div>
3136 <span>{t("settings.botConnectionColumnRemote")}</span>
3137 <code title={selectedConnectionRemote || undefined}>{selectedConnectionRemote || "—"}</code>
3138 </div>
3139 <div>
3140 <span>{t("settings.botConnectionColumnScope")}</span>
3141 <strong>{botConnectionScopeLabel(selectedConnection, t)}</strong>
3142 </div>
3143 <div>
3144 <span>{t("settings.botConnectionColumnStatus")}</span>
3145 <strong>{selectedConnection.status === "connected" ? t("settings.botConnectionConnected") : selectedConnection.status || t("settings.botConnectionDisconnected")}</strong>
3146 </div>
3147 </div>
3148 </section>
3149
3150 <section className="bot-detail-section bot-detail-section--runtime-primary">
3151 <SettingsField label={t("settings.botEnableBot")} hint={t("settings.botGatewayEnabled")}>
3152 <ToggleSegment
3153 value={selectedConnection.enabled}
3154 disabled={busy}
3155 onChange={(enabled) => void persistConnection(selectedConnection.id, { enabled })}
3156 />
3157 </SettingsField>
3158 <SettingsField label={t("settings.botToolApprovalMode")} hint={t("settings.botToolApprovalModeHint")}>
3159 <SettingsOptions className="provider-add-segmented" role="group" aria-label={t("settings.botToolApprovalMode")}>
3160 {TOOL_APPROVAL_MODES.map((mode) => (
3161 <button
3162 key={mode}
3163 type="button"
3164 className={selectedConnectionToolApprovalMode === mode ? "provider-add-segmented__item provider-add-segmented__item--active" : "provider-add-segmented__item"}
3165 disabled={busy}
3166 onClick={() => persistConnectionToolApprovalMode(selectedConnection.id, mode)}
3167 >
3168 {t(`settings.botToolApprovalMode.${mode}` as DictKey)}
3169 </button>
3170 ))}
3171 </SettingsOptions>
3172 </SettingsField>
3173 <SettingsField label={t("settings.botChannelModel")} hint={t("settings.botChannelModelHint")}>
3174 <ModelPicker
3175 s={s}
3176 refs={refs}
3177 value={toRef(selectedConnection.model, s)}
3178 disabled={busy}
3179 emptyOptionLabel={t("settings.botChannelModelAuto")}
3180 emptyOptionHint={settingsModelMeta(s, t)}
3181 onPick={(model) => void persistConnection(selectedConnection.id, { model })}
3182 />
3183 </SettingsField>
3184 </section>
3185
3186 {renderBotAccessSection(
3187 selectedConnection.id,
3188 selectedConnection.access,
3189 (patch) => updateConnectionAccess(selectedConnection.id, patch),
3190 (patch) => void persistConnectionAccess(selectedConnection, patch),
3191 )}
3192
3193 <section className="bot-detail-section">
3194 <div className="bot-detail-section__head">{t("settings.botRuntimeSettings")}</div>
3195 <SettingsField label={t("settings.botWorkspaceRoot")} hint={t("settings.botWorkspaceRootHint")}>
3196 <input
3197 className="mem-input"
3198 value={selectedConnection.workspaceRoot}
3199 disabled={busy}
3200 placeholder={t("settings.botWorkspaceRootPlaceholder")}
3201 spellCheck={false}
3202 onChange={(event) => updateConnection(selectedConnection.id, { workspaceRoot: event.target.value })}
3203 onBlur={(event) => void persistConnection(selectedConnection.id, { workspaceRoot: event.currentTarget.value })}
3204 />
3205 </SettingsField>
3206 </section>
3207
3208 <section className="bot-detail-section">
3209 <div className="bot-detail-section__head">{t("settings.botCredential")}</div>
3210 <div className="bot-credential-stack">
3211 <div className="bot-credential-line">
3212 <span>{botConnectionCredentialSummary(selectedConnection, t)}</span>
3213 <strong>{selectedConnection.credential.secretSet ? t("settings.botSecretSet") : t("settings.botSecretMissing")}</strong>
3214 </div>
3215 {botConnectionSecretEnv(selectedConnection) ? (
3216 <div className="bot-secret-row">
3217 <input
3218 className="mem-input"
3219 value={botConnectionSecretEnv(selectedConnection)}
3220 disabled={busy}
3221 spellCheck={false}
3222 onChange={(event) => updateConnectionCredential(selectedConnection.id, botConnectionSecretPatch(selectedConnection, event.target.value))}
3223 onBlur={(event) => void persistConnectionCredential(selectedConnection.id, botConnectionSecretPatch(selectedConnection, event.currentTarget.value))}
3224 />
3225 <input
3226 className="mem-input"
3227 type="password"
3228 value={connectionSecrets[selectedConnection.id] ?? ""}
3229 disabled={busy}
3230 placeholder={selectedConnection.credential.secretSet ? t("settings.botSecretReplace") : t("settings.botSecretPaste")}
3231 onChange={(event) => setConnectionSecrets((prev) => ({ ...prev, [selectedConnection.id]: event.target.value }))}
3232 />
3233 <div className="bot-secret-row__actions">
3234 <button type="button" className="btn btn--secondary btn--small" disabled={busy || !(connectionSecrets[selectedConnection.id] ?? "").trim()} onClick={() => void saveConnectionSecret(selectedConnection)}>
3235 {t("settings.saveKey")}
3236 </button>
3237 <button type="button" className="btn btn--secondary btn--small" disabled={busy || !selectedConnection.credential.secretSet} onClick={() => void clearConnectionSecret(selectedConnection)}>
3238 {t("settings.clearKey")}
3239 </button>
3240 </div>
3241 </div>
3242 ) : null}
3243 </div>
3244 </section>
3245
3246 <section className="bot-detail-section bot-detail-section--danger">
3247 <div>
3248 <div className="bot-detail-section__head">{t("settings.botDangerZone")}</div>
3249 <p>{t("settings.deleteBotHint")}</p>
3250 </div>
3251 <InlineConfirmButton
3252 label={t("settings.deleteBot")}
3253 confirmLabel={t("settings.confirmDeleteBot")}
3254 cancelLabel={t("common.cancel")}
3255 disabled={busy}
3256 danger
3257 onConfirm={() => removeConnection(selectedConnection)}
3258 />
3259 </section>
3260 </article>
3261 ) : null;
3262
3263 const installPanelContent = (
3264 <>
3265 {isQQInstallTarget ? (
3266 <div className="bot-connect-panel bot-connect-panel--manual bot-connect-panel--qq">
3267 <div className="bot-connect-panel__body">
3268 <div className="bot-qq-simple__head">
3269 <div>
3270 <strong>{selectedInstallLabel}</strong>
3271 <p>{t("settings.botInstallManualQQ")}</p>
3272 </div>
3273 <span className={`bot-qq-simple__status${qqConfigured ? " bot-qq-simple__status--ready" : ""}`}>
3274 {qqConfigured ? <CheckCircle2 aria-hidden="true" /> : <KeyRound aria-hidden="true" />}
3275 {draft.qq.secretSet ? t("settings.botSecretSet") : t("settings.botSecretMissing")}
3276 </span>
3277 </div>
3278 <div className="bot-manual-form bot-manual-form--qq">
3279 <div className="bot-card-field">
3280 <span>{t("settings.botAppId")}</span>
3281 <div>
3282 <input
3283 className="mem-input"
3284 aria-label={t("settings.botAppId")}
3285 value={draft.qq.appId}
3286 disabled={busy}
3287 spellCheck={false}
3288 onChange={(event) => updateQQ({ appId: event.target.value })}
3289 onBlur={(event) => void persistQQ({ appId: event.currentTarget.value })}
3290 />
3291 </div>
3292 </div>
3293 <div className="bot-card-field">
3294 <span>{t("settings.botAppSecret")}</span>
3295 <div>
3296 <input
3297 className="mem-input"
3298 type="password"
3299 value={qqSecretValue}
3300 disabled={busy}
3301 placeholder={draft.qq.secretSet ? t("settings.botSecretSavedOptional") : t("settings.botSecretPaste")}
3302 spellCheck={false}
3303 aria-label={t("settings.botSecretValue")}
3304 onChange={(event) => setQQSecretValue(event.target.value)}
3305 />
3306 </div>
3307 </div>
3308 <div className="bot-qq-simple__actions">
3309 <button type="button" className="btn btn--primary btn--small" disabled={busy || !qqCanSaveAndEnable} onClick={() => void saveQQAndEnable()}>
3310 {t("settings.botSaveAndEnable")}
3311 </button>
3312 </div>
3313 {!qqCanEnableAccess ? <div className="bot-connect-panel__hint bot-connect-panel__hint--warning">{t("settings.botQQAccessRequired")}</div> : null}
3314 </div>
3315 </div>
3316 </div>
3317 ) : isDingtalkInstallTarget ? (
3318 <div className="bot-connect-panel bot-connect-panel--manual bot-connect-panel--dingtalk">
3319 <div className="bot-connect-panel__body">
3320 <div className="bot-qq-simple__head">
3321 <div>
3322 <strong>{selectedInstallLabel}</strong>
3323 <p>{t("settings.botInstallManualDingtalk")}</p>
3324 </div>
3325 <span className={`bot-qq-simple__status${dingtalkConfigured ? " bot-qq-simple__status--ready" : ""}`}>
3326 {dingtalkConfigured ? <CheckCircle2 aria-hidden="true" /> : <KeyRound aria-hidden="true" />}
3327 {draft.dingtalk.secretSet ? t("settings.botSecretSet") : t("settings.botSecretMissing")}
3328 </span>
3329 </div>
3330 <div className="bot-manual-form bot-manual-form--dingtalk">
3331 <div className="bot-card-field">
3332 <span>{t("settings.botDingtalkClientId")}</span>
3333 <div>
3334 <input
3335 className="mem-input"
3336 aria-label={t("settings.botDingtalkClientId")}
3337 value={draft.dingtalk.clientId}
3338 disabled={busy}
3339 spellCheck={false}
3340 onChange={(event) => updateDingtalk({ clientId: event.target.value })}
3341 onBlur={(event) => void persistDingtalk({ clientId: event.currentTarget.value })}
3342 />
3343 </div>
3344 </div>
3345 <div className="bot-card-field">
3346 <span>{t("settings.botDingtalkClientSecret")}</span>
3347 <div>
3348 <input
3349 className="mem-input"
3350 type="password"
3351 value={dingtalkSecretValue}
3352 disabled={busy}
3353 placeholder={draft.dingtalk.secretSet ? t("settings.botSecretSavedOptional") : t("settings.botSecretPaste")}
3354 spellCheck={false}
3355 aria-label={t("settings.botDingtalkClientSecret")}
3356 onChange={(event) => setDingtalkSecretValue(event.target.value)}
3357 />
3358 </div>
3359 </div>
3360 <div className="bot-card-field">
3361 <span>{t("settings.botDingtalkBotName")}</span>
3362 <div>
3363 <input
3364 className="mem-input"
3365 aria-label={t("settings.botDingtalkBotName")}
3366 value={draft.dingtalk.botName}
3367 disabled={busy}
3368 spellCheck={false}
3369 onChange={(event) => updateDingtalk({ botName: event.target.value })}
3370 onBlur={(event) => void persistDingtalk({ botName: event.currentTarget.value })}
3371 />
3372 </div>
3373 </div>
3374 <div className="bot-qq-simple__actions">
3375 <button type="button" className="btn btn--primary btn--small" disabled={busy || !dingtalkCanSaveAndEnable} onClick={() => void saveDingtalkAndEnable()}>
3376 {t("settings.botSaveAndEnable")}
3377 </button>
3378 </div>
3379 </div>
3380 </div>
3381 </div>
3382 ) : (
3383 <div className="bot-connect-panel bot-connect-panel--phone">
3384 <div className="bot-connect-panel__qr">
3385 {selectedInstallConnection ? (
3386 <div className="bot-connect-panel__state bot-connect-panel__state--success">
3387 <CheckCircle2 aria-hidden="true" />
3388 </div>
3389 ) : install.status === "showing" && installQrURL ? (
3390 installQrIsImage ? (
3391 <img src={installQrURL} alt={t("settings.botInstallQrAlt")} />
3392 ) : (
3393 <Suspense fallback={<div className="bot-connect-panel__state"><QrCode aria-hidden="true" /></div>}>
3394 <QRCodeSVG className="bot-connect-panel__qr-code" value={installQrURL} size={196} marginSize={1} />
3395 </Suspense>
3396 )
3397 ) : install.status === "starting" ? (
3398 <div className="bot-connect-panel__state">
3399 <Loader2 className="bot-spin" aria-hidden="true" />
3400 <span>{t("settings.botInstallStarting")}</span>
3401 </div>
3402 ) : install.status === "error" ? (
3403 <div className="bot-connect-panel__state bot-connect-panel__state--error">
3404 <RefreshCw aria-hidden="true" />
3405 </div>
3406 ) : (
3407 <div className="bot-connect-panel__state">
3408 <QrCode aria-hidden="true" />
3409 </div>
3410 )}
3411 </div>
3412 <div className="bot-connect-panel__body">
3413 <strong>{selectedInstallLabel}</strong>
3414 <p>
3415 {selectedInstallConnection
3416 ? t("settings.botInstallAlreadyConnected", { provider: selectedInstallLabel })
3417 : install.message || botTargetHint(installTarget, t)}
3418 </p>
3419 {install.status === "showing" && install.timeLeft > 0 ? (
3420 <span className="bot-connect-panel__timer">{t("settings.botInstallTimeLeft", { time: formatInstallTimeLeft(install.timeLeft) })}</span>
3421 ) : null}
3422 {installUserCode ? <code>{installUserCode}</code> : null}
3423 <div className="bot-connect-panel__actions">
3424 {!selectedInstallConnection && install.status !== "showing" && install.status !== "starting" ? (
3425 <button type="button" className="btn btn--primary btn--small" disabled={busy} onClick={() => void startInstall(installTarget)}>
3426 {install.status === "error" ? <RefreshCw aria-hidden="true" /> : <QrCode aria-hidden="true" />}
3427 {install.status === "error" ? t("settings.botInstallRetry") : t("settings.botInstallGenerate")}
3428 </button>
3429 ) : null}
3430 {install.status === "showing" ? (
3431 <button type="button" className="btn btn--secondary btn--small" disabled={busy} onClick={() => void pollInstall()}>
3432 {t("settings.botInstallCheck")}
3433 </button>
3434 ) : null}
3435 {selectedInstallConnection ? (
3436 <button type="button" className="btn btn--secondary btn--small" disabled={busy} onClick={() => void diagnoseConnection(selectedInstallConnection.id)}>
3437 {t("settings.botDiagnose")}
3438 </button>
3439 ) : null}
3440 </div>
3441 </div>
3442 </div>
3443 )}
3444 </>
3445 );
3446
3447 const botManager = (
3448 <section ref={stepConnectRef} id="bot-step-connect" className="bot-channel-manager-card">
3449 {browserPreviewBotConfigured ? (
3450 <div className="bot-connection-warning">{t("settings.botBrowserPreviewWarning")}</div>
3451 ) : null}
3452 <div className="bot-channel-manager">
3453 <div className="bot-channel-tabs" role="tablist" aria-label={t("settings.botChannelTabsLabel")}>
3454 {BOT_INSTALL_TARGETS.map((target) => {
3455 const configured = botChannelIsConfigured(target);
3456 const connected = target === "qq" ? qqOnline : botChannelConnectionForTarget(target)?.status === "connected";
3457 return (
3458 <button
3459 key={target}
3460 type="button"
3461 role="tab"
3462 aria-selected={installTarget === target}
3463 className={`bot-channel-tab${installTarget === target ? " bot-channel-tab--active" : ""}`}
3464 disabled={busy || install.status === "starting"}
3465 onClick={() => openBotChannel(target)}
3466 >
3467 <span className="bot-channel-tab__icon" aria-hidden="true">
3468 {(() => {
3469 const Icon = CHANNEL_ICONS[target];
3470 return <Icon className="bot-channel-tab__brand" />;
3471 })()}
3472 </span>
3473 <span className="bot-channel-tab__text">
3474 <strong>{botTargetLabel(target, t)}</strong>
3475 <small>{botTargetHint(target, t)}</small>
3476 </span>
3477 <span className={`bot-channel-tab__dot${connected ? " bot-channel-tab__dot--online" : configured ? " bot-channel-tab__dot--configured" : ""}`} />
3478 </button>
3479 );
3480 })}
3481 </div>
3482 <div className="bot-channel-manager__detail" role="tabpanel" aria-label={selectedInstallLabel}>
3483 {!selectedChannelConfigured ? (
3484 <article className="bot-channel-setup-card">
3485 {installPanelContent}
3486 </article>
3487 ) : selectedQQ ? (
3488 qqDetailCard
3489 ) : selectedDingtalk ? (
3490 dingtalkDetailCard
3491 ) : selectedConnection ? (
3492 connectionDetailCard
3493 ) : (
3494 <div className="bot-manager__empty">{t("settings.botSelectBotHint")}</div>
3495 )}
3496 </div>
3497 </div>
3498 </section>
3499 );
3500
3501 return (
3502 <div className="bot-phone-connect">
3503 {botManager}
3504
3505 <details
3506 id="bot-advanced-settings"
3507 className="bot-simple-advanced"
3508 open={advancedMode}
3509 onToggle={(event) => {
3510 const nextOpen = event.currentTarget.open;
3511 setAdvancedMode((current) => current === nextOpen ? current : nextOpen);
3512 }}
3513 >
3514 <summary className="bot-simple-advanced__summary">
3515 <span>
3516 <strong>{t("settings.botShowAdvancedSettings")}</strong>
3517 <small>{t("settings.botAdvancedSettingsHint")}</small>
3518 </span>
3519 <span className="bot-simple-advanced__toggle">
3520 {advancedMode ? t("common.collapse") : t("common.expand")}
3521 <ChevronDown aria-hidden="true" size={16} />
3522 </span>
3523 </summary>
3524 <div className="bot-simple-advanced__body">
3525 <details className="bot-access-panel bot-global-access-panel">
3526 <summary className="bot-access-panel__summary">
3527 <span>
3528 <strong>{t("settings.botGlobalAllowlist")}</strong>
3529 <small>{t("settings.botGlobalAllowlistHint")}</small>
3530 </span>
3531 <ChevronDown className="bot-access-panel__chevron" size={16} aria-hidden="true" />
3532 </summary>
3533 <div className="bot-access-panel__body">
3534 <div className="bot-choice-grid bot-choice-grid--access">
3535 <button
3536 type="button"
3537 className={`bot-choice-card${simpleAccessMode === "trusted" ? " bot-choice-card--active" : ""}`}
3538 disabled={busy}
3539 onClick={() => setSimpleAccessMode("trusted")}
3540 >
3541 <strong>{t("settings.botAccessTrusted")}</strong>
3542 <span>{t("settings.botAccessTrustedHint")}</span>
3543 </button>
3544 <button
3545 type="button"
3546 className={`bot-choice-card${simpleAccessMode === "everyone" ? " bot-choice-card--active" : ""}`}
3547 disabled={busy}
3548 onClick={() => setSimpleAccessMode("everyone")}
3549 >
3550 <strong>{t("settings.botAccessEveryone")}</strong>
3551 <span>{t("settings.botAccessEveryoneHint")}</span>
3552 </button>
3553 </div>
3554 <div className="bot-pairing-row">
3555 <div>
3556 <strong>{t("settings.botAccessPairing")}</strong>
3557 <span>{t("settings.botAccessPairingHint")}</span>
3558 </div>
3559 <ToggleSegment
3560 value={draft.pairing.enabled}
3561 disabled={busy}
3562 onChange={(enabled) => void persistBotSettings({ pairing: { ...draft.pairing, enabled } })}
3563 />
3564 </div>
3565 {draft.allowlist.allowAll ? (
3566 <div className="bot-access-panel__warning">{t("settings.botAllowAllWarn")}</div>
3567 ) : (
3568 <>
3569 <div className="bot-access-platforms">
3570 {visibleAccessPlatforms.map((platform) => (
3571 <div className="bot-access-platform" key={platform}>
3572 <div className="bot-access-platform__name">{botPlatformLabel(platform, t)}</div>
3573 <BotListInput
3574 label={t("settings.botListUsers")}
3575 value={allowlistText[botAllowlistKey(platform, "Users")]}
3576 disabled={busy}
3577 placeholder={t("settings.botListPlaceholder")}
3578 onChange={(value) => setAllowlistText((prev) => ({ ...prev, [botAllowlistKey(platform, "Users")]: value }))}
3579 onBlur={(value) => persistAllowlistText(botAllowlistKey(platform, "Users"), value)}
3580 />
3581 <BotListInput
3582 label={t("settings.botListGroups")}
3583 value={allowlistText[botAllowlistKey(platform, "Groups")]}
3584 disabled={busy}
3585 placeholder={t("settings.botListPlaceholder")}
3586 onChange={(value) => setAllowlistText((prev) => ({ ...prev, [botAllowlistKey(platform, "Groups")]: value }))}
3587 onBlur={(value) => persistAllowlistText(botAllowlistKey(platform, "Groups"), value)}
3588 />
3589 </div>
3590 ))}
3591 </div>
3592 {platformFilterAvailable ? (
3593 <button type="button" className="bot-access-platforms__toggle" onClick={() => setShowAllPlatforms((value) => !value)}>
3594 {showAllPlatforms ? t("settings.botAccessShowConnectedOnly") : t("settings.botAccessShowAllPlatforms")}
3595 </button>
3596 ) : null}
3597 </>
3598 )}
3599 <details className="bot-access-panel bot-simple-roles">
3600 <summary className="bot-access-panel__summary">
3601 <span>
3602 <strong>{t("settings.botRoleAccess")}</strong>
3603 <small>{t("settings.botRoleAccessHint")}</small>
3604 </span>
3605 <ChevronDown className="bot-access-panel__chevron" size={16} aria-hidden="true" />
3606 </summary>
3607 <div className="bot-access-panel__body">
3608 <div className="bot-access-platforms">
3609 {visibleAccessPlatforms.map((platform) => (
3610 <div className="bot-access-platform" key={platform}>
3611 <div className="bot-access-platform__name">{botPlatformLabel(platform, t)}</div>
3612 <BotListInput
3613 label={t("settings.botListApprovers")}
3614 value={allowlistText[botAllowlistKey(platform, "Approvers")]}
3615 disabled={busy || draft.allowlist.allowAll}
3616 placeholder={t("settings.botListPlaceholder")}
3617 onChange={(value) => setAllowlistText((prev) => ({ ...prev, [botAllowlistKey(platform, "Approvers")]: value }))}
3618 onBlur={(value) => persistAllowlistText(botAllowlistKey(platform, "Approvers"), value)}
3619 />
3620 <BotListInput
3621 label={t("settings.botListAdmins")}
3622 value={allowlistText[botAllowlistKey(platform, "Admins")]}
3623 disabled={busy || draft.allowlist.allowAll}
3624 placeholder={t("settings.botListPlaceholder")}
3625 onChange={(value) => setAllowlistText((prev) => ({ ...prev, [botAllowlistKey(platform, "Admins")]: value }))}
3626 onBlur={(value) => persistAllowlistText(botAllowlistKey(platform, "Admins"), value)}
3627 />
3628 </div>
3629 ))}
3630 </div>
3631 </div>
3632 </details>
3633 </div>
3634 </details>
3635 <details className="bot-access-panel bot-gateway-panel">
3636 <summary className="bot-access-panel__summary">
3637 <span>
3638 <strong>{t("settings.botGatewayDefaults")}</strong>
3639 <small>{t("settings.botGatewayDefaultsHint")}</small>
3640 </span>
3641 <ChevronDown className="bot-access-panel__chevron" size={16} aria-hidden="true" />
3642 </summary>
3643 <div className="bot-access-panel__body">
3644 <SettingsField label={t("settings.botRuntime")} hint={t("settings.botRuntimeHint")}>
3645 <div className="bot-inline-grid bot-inline-grid--runtime">
3646 <label>
3647 <span>{t("settings.botMaxSteps")}</span>
3648 <input
3649 className="mem-input"
3650 type="number"
3651 min={0}
3652 value={draft.maxSteps}
3653 disabled={busy}
3654 onChange={(event) => updateBotSettings({ maxSteps: Number(event.target.value) || 0 })}
3655 onBlur={(event) => void persistBotSettings({ maxSteps: Number(event.currentTarget.value) || 0 })}
3656 />
3657 </label>
3658 <label>
3659 <span>{t("settings.botDebounceMs")}</span>
3660 <input
3661 className="mem-input"
3662 type="number"
3663 min={0}
3664 value={draft.debounceMs}
3665 disabled={busy}
3666 onChange={(event) => updateBotSettings({ debounceMs: Number(event.target.value) || 0 })}
3667 onBlur={(event) => void persistBotSettings({ debounceMs: Number(event.currentTarget.value) || 0 })}
3668 />
3669 </label>
3670 <label>
3671 <span>{t("settings.botQueueCap")}</span>
3672 <input
3673 className="mem-input"
3674 type="number"
3675 min={0}
3676 value={draft.queueCap}
3677 disabled={busy}
3678 onChange={(event) => updateBotSettings({ queueCap: Number(event.target.value) || 0 })}
3679 onBlur={(event) => void persistBotSettings({ queueCap: Number(event.currentTarget.value) || 0 })}
3680 />
3681 </label>
3682 </div>
3683 </SettingsField>
3684 <SettingsField label={t("settings.botQueueModeSimple")} hint={t("settings.botQueueModeSimpleHint")}>
3685 <SettingsSelect
3686 className="mem-select"
3687 value={normalizeBotQueueMode(draft.queueMode)}
3688 disabled={busy}
3689 onValueChange={(value) => void persistBotSettings({ queueMode: value })}
3690 >
3691 {BOT_QUEUE_MODES.map((mode) => (
3692 <option key={mode} value={mode}>{t(`settings.botQueueMode.${mode}` as DictKey)}</option>
3693 ))}
3694 </SettingsSelect>
3695 </SettingsField>
3696 <SettingsField label={t("settings.botQueueDropLabel")} hint={t("settings.botQueueDropHint")}>
3697 <SettingsSelect
3698 className="mem-select"
3699 value={normalizeBotQueueDrop(draft.queueDrop)}
3700 disabled={busy}
3701 onValueChange={(value) => void persistBotSettings({ queueDrop: value })}
3702 >
3703 {BOT_QUEUE_DROPS.map((mode) => (
3704 <option key={mode} value={mode}>{t(`settings.botQueueDrop.${mode}` as DictKey)}</option>
3705 ))}
3706 </SettingsSelect>
3707 </SettingsField>
3708 <SettingsField label={t("settings.botIgnoreSelfMessages")} hint={t("settings.botIgnoreSelfMessagesHint")}>
3709 <ToggleSegment
3710 value={draft.ignoreSelfMessages}
3711 disabled={busy}
3712 onChange={(ignoreSelfMessages) => void persistBotSettings({ ignoreSelfMessages })}
3713 />
3714 </SettingsField>
3715 <SettingsField label={t("settings.botSelfUserIds")} hint={t("settings.botSelfUserIdsHint")}>
3716 <div className="bot-list-grid">
3717 <BotListInput
3718 label={t("settings.botQQUsers")}
3719 value={selfUserText.qq}
3720 disabled={busy}
3721 placeholder={t("settings.botListPlaceholder")}
3722 onChange={(value) => updateSelfUserText("qq", value)}
3723 onBlur={(value) => persistSelfUserText("qq", value)}
3724 />
3725 <BotListInput
3726 label={t("settings.botFeishuLarkUsers")}
3727 value={selfUserText.feishu}
3728 disabled={busy}
3729 placeholder={t("settings.botListPlaceholder")}
3730 onChange={(value) => updateSelfUserText("feishu", value)}
3731 onBlur={(value) => persistSelfUserText("feishu", value)}
3732 />
3733 <BotListInput
3734 label={t("settings.botWeixinUsers")}
3735 value={selfUserText.weixin}
3736 disabled={busy}
3737 placeholder={t("settings.botListPlaceholder")}
3738 onChange={(value) => updateSelfUserText("weixin", value)}
3739 onBlur={(value) => persistSelfUserText("weixin", value)}
3740 />
3741 </div>
3742 </SettingsField>
3743 <SettingsField label={t("settings.botPairing")} hint={t("settings.botPairingDetailHint")}>
3744 <div className="bot-inline-grid bot-inline-grid--runtime">
3745 <label>
3746 <span>{t("settings.botPairingTTL")}</span>
3747 <input
3748 className="mem-input"
3749 type="number"
3750 min={0}
3751 value={draft.pairing.requestTtlMinutes}
3752 disabled={busy}
3753 onChange={(event) => updateBotSettings({ pairing: { ...draft.pairing, requestTtlMinutes: Number(event.target.value) || 0 } })}
3754 onBlur={(event) => void persistBotSettings({ pairing: { ...draft.pairing, requestTtlMinutes: Number(event.currentTarget.value) || 0 } })}
3755 />
3756 </label>
3757 <label>
3758 <span>{t("settings.botPairingMaxPending")}</span>
3759 <input
3760 className="mem-input"
3761 type="number"
3762 min={0}
3763 value={draft.pairing.maxPendingPerPlatform}
3764 disabled={busy}
3765 onChange={(event) => updateBotSettings({ pairing: { ...draft.pairing, maxPendingPerPlatform: Number(event.target.value) || 0 } })}
3766 onBlur={(event) => void persistBotSettings({ pairing: { ...draft.pairing, maxPendingPerPlatform: Number(event.currentTarget.value) || 0 } })}
3767 />
3768 </label>
3769 </div>
3770 </SettingsField>
3771 </div>
3772 </details>
3773
3774 <details className="bot-access-panel bot-routes-panel">
3775 <summary className="bot-access-panel__summary">
3776 <span>
3777 <strong>{t("settings.botRoutes")}</strong>
3778 <small>{t("settings.botRoutesHint")}</small>
3779 </span>
3780 <ChevronDown className="bot-access-panel__chevron" size={16} aria-hidden="true" />
3781 </summary>
3782 <div className="bot-access-panel__body">
3783 {draft.routes.length === 0 ? (
3784 <div className="bot-route-empty">{t("settings.botRoutesEmpty")}</div>
3785 ) : (
3786 <div className="bot-route-list">
3787 {draft.routes.map((route, index) => (
3788 <div className="bot-route-row" key={index}>
3789 <div className="bot-route-row__head">
3790 <strong>{t("settings.botRouteTitle", { n: index + 1 })}</strong>
3791 <button type="button" className="btn btn--secondary btn--small" disabled={busy} onClick={() => removeRoute(index)}>
3792 {t("common.delete")}
3793 </button>
3794 </div>
3795 <div className="bot-route-grid">
3796 <label>
3797 <span>{t("settings.botRouteConnection")}</span>
3798 <SettingsSelect
3799 className="mem-select"
3800 value={route.connectionId}
3801 disabled={busy}
3802 onValueChange={(value) => {
3803 updateRoute(index, { connectionId: value });
3804 void persistRoute(index, { connectionId: value });
3805 }}
3806 >
3807 <option value="">{t("settings.botRouteAny")}</option>
3808 {routeConnectionOptions.map((option) => (
3809 <option key={option.id} value={option.id}>{option.label} · {option.id}</option>
3810 ))}
3811 </SettingsSelect>
3812 </label>
3813 <label>
3814 <span>{t("settings.botRoutePlatform")}</span>
3815 <SettingsSelect
3816 className="mem-select"
3817 value={route.platform}
3818 disabled={busy}
3819 onValueChange={(value) => {
3820 updateRoute(index, { platform: value });
3821 void persistRoute(index, { platform: value });
3822 }}
3823 >
3824 <option value="">{t("settings.botRouteAny")}</option>
3825 <option value="qq">QQ</option>
3826 <option value="feishu">{t("settings.botFeishu")}</option>
3827 <option value="weixin">{t("settings.botWeixin")}</option>
3828 </SettingsSelect>
3829 </label>
3830 <label>
3831 <span>{t("settings.botRouteChatType")}</span>
3832 <SettingsSelect
3833 className="mem-select"
3834 value={route.chatType}
3835 disabled={busy}
3836 onValueChange={(value) => {
3837 updateRoute(index, { chatType: value });
3838 void persistRoute(index, { chatType: value });
3839 }}
3840 >
3841 {BOT_ROUTE_CHAT_TYPES.map((chatType) => (
3842 <option key={chatType || "any"} value={chatType}>{t(`settings.botRouteChatType.${chatType || "any"}` as DictKey)}</option>
3843 ))}
3844 </SettingsSelect>
3845 </label>
3846 <label>
3847 <span>{t("settings.botRouteChatId")}</span>
3848 <input
3849 className="mem-input"
3850 value={route.chatId}
3851 disabled={busy}
3852 spellCheck={false}
3853 onChange={(event) => updateRoute(index, { chatId: event.target.value })}
3854 onBlur={(event) => void persistRoute(index, { chatId: event.currentTarget.value })}
3855 />
3856 </label>
3857 <label>
3858 <span>{t("settings.botRouteUserId")}</span>
3859 <input
3860 className="mem-input"
3861 value={route.userId}
3862 disabled={busy}
3863 spellCheck={false}
3864 onChange={(event) => updateRoute(index, { userId: event.target.value })}
3865 onBlur={(event) => void persistRoute(index, { userId: event.currentTarget.value })}
3866 />
3867 </label>
3868 <label>
3869 <span>{t("settings.botRouteThreadId")}</span>
3870 <input
3871 className="mem-input"
3872 value={route.threadId}
3873 disabled={busy}
3874 spellCheck={false}
3875 onChange={(event) => updateRoute(index, { threadId: event.target.value })}
3876 onBlur={(event) => void persistRoute(index, { threadId: event.currentTarget.value })}
3877 />
3878 </label>
3879 </div>
3880 <div className="bot-route-grid bot-route-grid--outputs">
3881 <label>
3882 <span>{t("settings.botWorkspaceRoot")}</span>
3883 <input
3884 className="mem-input"
3885 value={route.workspaceRoot}
3886 disabled={busy}
3887 placeholder={t("settings.botWorkspaceRootPlaceholder")}
3888 spellCheck={false}
3889 onChange={(event) => updateRoute(index, { workspaceRoot: event.target.value })}
3890 onBlur={(event) => void persistRoute(index, { workspaceRoot: event.currentTarget.value })}
3891 />
3892 </label>
3893 <label>
3894 <span>{t("settings.botChannelModel")}</span>
3895 <ModelPicker
3896 s={s}
3897 refs={refs}
3898 value={toRef(route.model, s)}
3899 disabled={busy}
3900 emptyOptionLabel={t("settings.botChannelModelAuto")}
3901 emptyOptionHint={settingsModelMeta(s, t)}
3902 onPick={(model) => void persistRoute(index, { model })}
3903 />
3904 </label>
3905 <label>
3906 <span>{t("settings.botToolApprovalMode")}</span>
3907 <SettingsSelect
3908 className="mem-select"
3909 value={route.toolApprovalMode}
3910 disabled={busy}
3911 onValueChange={(value) => {
3912 updateRoute(index, { toolApprovalMode: value });
3913 void persistRoute(index, { toolApprovalMode: value });
3914 }}
3915 >
3916 {BOT_TOOL_APPROVAL_MODES.map((mode) => (
3917 <option key={mode || "inherit"} value={mode}>{t(`settings.botToolApprovalMode.${mode || "inherit"}` as DictKey)}</option>
3918 ))}
3919 </SettingsSelect>
3920 </label>
3921 </div>
3922 </div>
3923 ))}
3924 </div>
3925 )}
3926 <button type="button" className="btn btn--secondary btn--small bot-route-add" disabled={busy} onClick={addRoute}>
3927 {t("settings.botAddRoute")}
3928 </button>
3929 </div>
3930 </details>
3931 </div>
3932 </details>
3933 </div>
3934 );
3935 }
3936
3937 function ToggleSegment({
3938 value,
3939 disabled,
3940 onLabel,
3941 offLabel,
3942 onChange,
3943 }: {
3944 value: boolean;
3945 disabled: boolean;
3946 onLabel?: string;
3947 offLabel?: string;
3948 onChange: (value: boolean) => void;
3949 }) {
3950 const t = useT();
3951 return (
3952 <SettingsOptions className="set-seg">
3953 <button
3954 type="button"
3955 className={`set-seg__btn${value ? " set-seg__btn--on" : ""}`}
3956 disabled={disabled}
3957 onClick={() => onChange(true)}
3958 >
3959 {onLabel ?? t("settings.toggleOn")}
3960 </button>
3961 <button
3962 type="button"
3963 className={`set-seg__btn${!value ? " set-seg__btn--on" : ""}`}
3964 disabled={disabled}
3965 onClick={() => onChange(false)}
3966 >
3967 {offLabel ?? t("settings.toggleOff")}
3968 </button>
3969 </SettingsOptions>
3970 );
3971 }
3972
3973 function BotListInput({
3974 label,
3975 value,
3976 disabled,
3977 placeholder,
3978 onChange,
3979 onBlur,
3980 }: {
3981 label: ReactNode;
3982 value: string;
3983 disabled: boolean;
3984 placeholder: string;
3985 onChange: (value: string) => void;
3986 onBlur: (value: string) => void;
3987 }) {
3988 return (
3989 <label className="bot-list-input">
3990 <span>{label}</span>
3991 <textarea
3992 className="mem-input bot-list-input__textarea"
3993 value={value}
3994 disabled={disabled}
3995 placeholder={placeholder}
3996 spellCheck={false}
3997 onChange={(event) => onChange(event.target.value)}
3998 onBlur={(event) => onBlur(event.currentTarget.value)}
3999 />
4000 </label>
4001 );
4002 }
4003
4004 function sanitizeBotDraft(draft: BotSettingsView): BotSettingsView {
4005 const bot = normalizeBotSettings(draft);
4006 return {
4007 ...bot,
4008 model: bot.model.trim(),
4009 toolApprovalMode: normalizeBotToolApprovalMode(bot.toolApprovalMode),
4010 maxSteps: Math.max(0, Math.floor(bot.maxSteps || 0)),
4011 debounceMs: Math.max(0, Math.floor(bot.debounceMs || 0)),
4012 queueMode: normalizeBotQueueMode(bot.queueMode),
4013 queueCap: Math.max(0, Math.floor(bot.queueCap || 0)),
4014 queueDrop: normalizeBotQueueDrop(bot.queueDrop),
4015 selfUserIds: {
4016 qq: uniqueStrings(bot.selfUserIds.qq.map((v) => v.trim())),
4017 feishu: uniqueStrings(bot.selfUserIds.feishu.map((v) => v.trim())),
4018 weixin: uniqueStrings(bot.selfUserIds.weixin.map((v) => v.trim())),
4019 dingtalk: uniqueStrings(bot.selfUserIds.dingtalk.map((v) => v.trim())),
4020 },
4021 control: {
4022 enabled: bot.control.enabled,
4023 addr: bot.control.addr.trim(),
4024 tokenEnv: bot.control.tokenEnv.trim(),
4025 },
4026 pairing: {
4027 enabled: bot.pairing.enabled,
4028 requestTtlMinutes: Math.max(0, Math.floor(bot.pairing.requestTtlMinutes || 0)),
4029 maxPendingPerPlatform: Math.max(0, Math.floor(bot.pairing.maxPendingPerPlatform || 0)),
4030 },
4031 routes: bot.routes.map(normalizeBotRoute).filter(botRouteHasValue),
4032 allowlist: {
4033 ...bot.allowlist,
4034 qqUsers: uniqueStrings(bot.allowlist.qqUsers.map((v) => v.trim())),
4035 feishuUsers: uniqueStrings(bot.allowlist.feishuUsers.map((v) => v.trim())),
4036 weixinUsers: uniqueStrings(bot.allowlist.weixinUsers.map((v) => v.trim())),
4037 qqApprovers: uniqueStrings(bot.allowlist.qqApprovers.map((v) => v.trim())),
4038 feishuApprovers: uniqueStrings(bot.allowlist.feishuApprovers.map((v) => v.trim())),
4039 weixinApprovers: uniqueStrings(bot.allowlist.weixinApprovers.map((v) => v.trim())),
4040 qqAdmins: uniqueStrings(bot.allowlist.qqAdmins.map((v) => v.trim())),
4041 feishuAdmins: uniqueStrings(bot.allowlist.feishuAdmins.map((v) => v.trim())),
4042 weixinAdmins: uniqueStrings(bot.allowlist.weixinAdmins.map((v) => v.trim())),
4043 qqGroups: uniqueStrings(bot.allowlist.qqGroups.map((v) => v.trim())),
4044 feishuGroups: uniqueStrings(bot.allowlist.feishuGroups.map((v) => v.trim())),
4045 weixinGroups: uniqueStrings(bot.allowlist.weixinGroups.map((v) => v.trim())),
4046 dingtalkUsers: uniqueStrings(bot.allowlist.dingtalkUsers.map((v) => v.trim())),
4047 dingtalkApprovers: uniqueStrings(bot.allowlist.dingtalkApprovers.map((v) => v.trim())),
4048 dingtalkAdmins: uniqueStrings(bot.allowlist.dingtalkAdmins.map((v) => v.trim())),
4049 dingtalkGroups: uniqueStrings(bot.allowlist.dingtalkGroups.map((v) => v.trim())),
4050 },
4051 qq: {
4052 ...bot.qq,
4053 appId: bot.qq.appId.trim(),
4054 appSecretEnv: bot.qq.appSecretEnv.trim(),
4055 model: bot.qq.model.trim(),
4056 toolApprovalMode: normalizeBotToolApprovalMode(bot.qq.toolApprovalMode),
4057 workspaceRoot: bot.qq.workspaceRoot.trim(),
4058 access: sanitizeBotAccess(bot.qq.access),
4059 },
4060 feishu: {
4061 ...bot.feishu,
4062 domain: bot.feishu.domain === "lark" ? "lark" : "feishu",
4063 appId: bot.feishu.appId.trim(),
4064 appSecretEnv: bot.feishu.appSecretEnv.trim(),
4065 verificationToken: bot.feishu.verificationToken.trim(),
4066 mode: bot.feishu.mode === "websocket" ? "websocket" : "webhook",
4067 webhookPort: Math.max(0, Math.floor(bot.feishu.webhookPort || 0)),
4068 },
4069 weixin: {
4070 ...bot.weixin,
4071 accountId: bot.weixin.accountId.trim(),
4072 tokenEnv: bot.weixin.tokenEnv.trim(),
4073 apiBase: bot.weixin.apiBase.trim().replace(/\/+$/, ""),
4074 },
4075 dingtalk: {
4076 ...bot.dingtalk,
4077 clientId: bot.dingtalk.clientId.trim(),
4078 clientSecretEnv: bot.dingtalk.clientSecretEnv.trim(),
4079 botName: bot.dingtalk.botName.trim(),
4080 toolApprovalMode: normalizeBotToolApprovalMode(bot.dingtalk.toolApprovalMode),
4081 access: sanitizeBotAccess(bot.dingtalk.access),
4082 },
4083 connections: bot.connections.map((conn) => ({ ...normalizeBotConnection(conn), access: sanitizeBotAccess(conn.access) })).filter((conn) => conn.id && conn.provider),
4084 };
4085 }
4086
4087 function sanitizeBotAccess(access: BotAccessView): BotAccessView {
4088 const normalized = normalizeBotAccess(access);
4089 return {
4090 ...normalized,
4091 users: uniqueStrings(normalized.users.map((v) => v.trim()).filter(Boolean)),
4092 groups: uniqueStrings(normalized.groups.map((v) => v.trim()).filter(Boolean)),
4093 approvers: uniqueStrings(normalized.approvers.map((v) => v.trim()).filter(Boolean)),
4094 admins: uniqueStrings(normalized.admins.map((v) => v.trim()).filter(Boolean)),
4095 };
4096 }
4097
4098 function botDraftWithDerivedGatewayState(draft: BotSettingsView): BotSettingsView {
4099 const bot = sanitizeBotDraft(draft);
4100 return {
4101 ...bot,
4102 enabled: bot.qq.enabled || bot.dingtalk.enabled || bot.connections.some((connection) => connection.enabled),
4103 };
4104 }
4105
4106 export function ModelsSection({ s, busy, apply, backgroundApply, subtab, onboarding, focusProvider, focusRequestId, onOnboardingComplete, onOpenProviders }: ModelsSectionProps) {
4107 const t = useT();
4108 const autoRefreshKeyRef = useRef("");
4109 const autoRefreshGenerationRef = useRef(0);
4110 const refs = useMemo(() => allRefs(s), [s.providers]);
4111 const defaultRef = toRef(s.defaultModel, s);
4112 const plannerRef = toRef(s.plannerModel, s);
4113 const subagentRef = toRef(s.subagentModel, s);
4114 const subagentOption = modelOptionFromRef(subagentRef, s);
4115 const subagentOverride = subagentOption?.providerView?.modelOverrides?.find(item => item.model === subagentOption.model);
4116 const subagentLevels = subagentRef ? (subagentOverride?.supportedEfforts?.length ? subagentOverride.supportedEfforts : subagentOption?.providerView?.supportedEfforts ?? []) : [];
4117
4118 const visionRef = s.visionModel === "auto" ? "auto" : toRef(s.visionModel, s);
4119 const plannerSelectRef = plannerRef === defaultRef ? "" : plannerRef;
4120 const [defaultProvider] = defaultRef.split("/");
4121 const defaultProviderView = s.providers.find((p) => p.name === defaultProvider);
4122 const modelIssue = !defaultProviderView
4123 ? t("settings.modelUnavailable", { ref: defaultRef || t("common.none") })
4124 : !providerIsConfigured(defaultProviderView)
4125 ? t("settings.modelNeedsKey", { provider: modelProviderLabel(defaultProvider, defaultProviderView, t) })
4126 : "";
4127 const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", reasoningLanguage: "auto", compactRatio: 0.80 };
4128 const compactRatio = agent.compactRatio ?? 0.80;
4129 const compactRatioPercent = Math.round(compactRatio * 1000) / 10;
4130 const compactRatioPreset = COMPACT_RATIO_PRESETS.find(([ratio]) => Math.abs(compactRatio - ratio) < 0.0001);
4131 const [compactRatioDraft, setCompactRatioDraft] = useState(() => compactRatioPreset ? "" : String(compactRatioPercent));
4132 const [compactRatioCustomEditing, setCompactRatioCustomEditing] = useState(false);
4133 const compactRatioCustomInputRef = useRef<HTMLInputElement>(null);
4134 const compactRatioCancelBlurRef = useRef(false);
4135 const compactRatioDraftPercent = Number(compactRatioDraft);
4136 const compactRatioDraftValid = compactRatioDraft !== ""
4137 && Number.isFinite(compactRatioDraftPercent)
4138 && compactRatioDraftPercent >= COMPACT_RATIO_MIN_PERCENT
4139 && compactRatioDraftPercent <= COMPACT_RATIO_MAX_PERCENT;
4140 const compactRatioImpact = t("settings.compactRatioImpact");
4141 const compactRatioOverrideHint = agent.compactRatioOverridden
4142 ? t("settings.compactRatioProjectOverride", { percent: Math.round((agent.effectiveCompactRatio ?? compactRatio) * 100) })
4143 : "";
4144 const subagentDepth = Number.isFinite(agent.maxSubagentDepth) && agent.maxSubagentDepth <= 1 ? 1 : 2;
4145 const subagentConcurrency = Number.isFinite(agent.maxSubagentConcurrency) && agent.maxSubagentConcurrency > 0
4146 ? Math.max(1, Math.min(32, Math.floor(agent.maxSubagentConcurrency)))
4147 : 6;
4148 const parallelWriters = Number.isFinite(agent.maxParallelWriters) && agent.maxParallelWriters > 0
4149 ? Math.max(1, Math.min(subagentConcurrency, Math.floor(agent.maxParallelWriters)))
4150 : Math.min(3, subagentConcurrency);
4151 const visionRefs = useMemo(() => {
4152 const defaultProviderName = defaultRef.split("/")[0];
4153 const candidates = refs.filter((ref) => {
4154 const [provider, ...parts] = ref.split("/");
4155 const model = parts.join("/");
4156 const view = s.providers.find((item) => item.name === provider);
4157 return Boolean(view && providerVisionModelsForView(view).includes(model));
4158 });
4159 return candidates.sort((a, b) => {
4160 const aSame = a.startsWith(`${defaultProviderName}/`);
4161 const bSame = b.startsWith(`${defaultProviderName}/`);
4162 return aSame === bSame ? a.localeCompare(b) : aSame ? -1 : 1;
4163 });
4164 }, [defaultRef, refs, s.providers]);
4165
4166 useEffect(() => {
4167 if (!compactRatioCustomEditing) setCompactRatioDraft(compactRatioPreset ? "" : String(compactRatioPercent));
4168 }, [compactRatioCustomEditing, compactRatioPercent, compactRatioPreset]);
4169
4170 const persistCompactRatio = async (ratio: number) => {
4171 return await apply(() => app.SetCompactRatio(ratio));
4172 };
4173
4174 const focusCompactRatioCustom = () => {
4175 setCompactRatioCustomEditing(true);
4176 requestAnimationFrame(() => compactRatioCustomInputRef.current?.focus());
4177 };
4178
4179 const selectCompactRatioPreset = async (ratio: number) => {
4180 // A preset click replaces the draft; do not also persist it on blur.
4181 if (document.activeElement === compactRatioCustomInputRef.current) {
4182 compactRatioCancelBlurRef.current = true;
4183 compactRatioCustomInputRef.current?.blur();
4184 }
4185 setCompactRatioCustomEditing(false);
4186 if (Math.abs(compactRatio - ratio) < 0.0001) {
4187 return;
4188 }
4189 await persistCompactRatio(ratio);
4190 };
4191
4192 const commitCompactRatioDraft = async (rawValue: string) => {
4193 const percent = Number(rawValue);
4194 const valid = rawValue !== ""
4195 && Number.isFinite(percent)
4196 && percent >= COMPACT_RATIO_MIN_PERCENT
4197 && percent <= COMPACT_RATIO_MAX_PERCENT;
4198 const dirty = valid && Math.abs(percent / 100 - compactRatio) > 0.0001;
4199 if (!valid && rawValue !== "") return;
4200 if (!valid || busy) {
4201 setCompactRatioCustomEditing(false);
4202 return;
4203 }
4204 if (dirty && !await persistCompactRatio(percent / 100)) return;
4205 setCompactRatioCustomEditing(false);
4206 };
4207
4208 useEffect(() => {
4209 const generation = ++autoRefreshGenerationRef.current;
4210 let cancelled = false;
4211 const stale = () => cancelled || autoRefreshGenerationRef.current !== generation;
4212 if (subtab !== "usage") return;
4213 const groups = providerAccessGroups(s.providers.filter((p) => p.added), t);
4214 const candidates = groups
4215 .map((group) => {
4216 const provider = group.providers.find((p) => providerIsConfigured(p) && p.baseUrl);
4217 return provider ? { group, provider } : null;
4218 })
4219 .filter((item): item is { group: ProviderAccessGroup; provider: ProviderView } => Boolean(item));
4220 // The backend token covers provider identity, current catalog, headers,
4221 // and credential revision without persisting sensitive header values in
4222 // sessionStorage. Older payloads without the token simply skip this
4223 // opportunistic background refresh; manual refresh remains available.
4224 if (candidates.some(({ provider }) => !provider.modelCatalogFingerprint?.trim())) return;
4225 const refreshKey = candidates.map(({ group, provider }) => JSON.stringify([
4226 group.id,
4227 provider.modelCatalogFingerprint!.trim(),
4228 ])).join("|");
4229 if (!refreshKey || autoRefreshKeyRef.current === refreshKey) return;
4230
4231 // Session-level cooldown per provider set: reopening the panel does not
4232 // refetch the same providers, while a changed set refreshes immediately.
4233 const autoRefreshStorageKey = `settings-auto-refresh-at:${refreshKey}`;
4234 const lastAutoRefresh = sessionStorage.getItem(autoRefreshStorageKey);
4235 if (lastAutoRefresh && Date.now() - Number(lastAutoRefresh) < 30_000) return;
4236
4237 // Respect slow network hints; background model-list refresh can wait.
4238 if (shouldSkipAutoRefresh()) return;
4239
4240 autoRefreshKeyRef.current = refreshKey;
4241 sessionStorage.setItem(autoRefreshStorageKey, String(Date.now()));
4242
4243 void backgroundApply(async () => {
4244 // Batch-fetch models for all candidates in one round-trip.
4245 const providersToFetch = candidates.map((c) => c.provider).filter((p) => p.models && p.models.length > 0);
4246 let batchResults: Record<string, ProviderModelCapabilityView[]> = {};
4247 try {
4248 batchResults = await app.FetchAllProviderModelCatalogs(providersToFetch);
4249 } catch {
4250 // Batch failed entirely — fall back to per-provider cached calls below.
4251 }
4252 if (stale()) return;
4253
4254 const updates: ProviderModelCatalogUpdate[] = [];
4255 for (const { provider } of candidates) {
4256 if (stale()) return;
4257 if (!provider.models || provider.models.length === 0) continue;
4258 try {
4259 const fetched = batchResults[provider.name]?.map((item) => item.model)
4260 ?? await cachedFetchProviderModels((p) => app.FetchProviderModels(p), provider);
4261 if (stale()) return;
4262 if (!fetched || fetched.length === 0) continue;
4263 const models = mergedFetchedProviderModels(provider.models, fetched, { preserveCurated: true });
4264 const currentDefault = providerDefaultModel(provider.default, models);
4265 const visionModels = provider.visionModels.filter((model) => models.includes(model));
4266 if (sameStringList(provider.models, models) && provider.default === currentDefault && sameStringList(provider.visionModels, visionModels)) continue;
4267 const expectedFingerprint = provider.modelCatalogFingerprint?.trim() ?? "";
4268 if (!expectedFingerprint) continue;
4269 updates.push({ name: provider.name, expectedFingerprint, models, default: currentDefault, visionModels });
4270 } catch {
4271 // Background discovery is opportunistic; manual refresh shows errors.
4272 }
4273 }
4274 if (updates.length > 0) {
4275 try {
4276 if (stale()) return;
4277 // Compare and apply narrow catalog updates in one transaction.
4278 await saveModelSettings(s, {kind: "catalogs", catalogs: updates});
4279 } catch {
4280 // Background discovery is opportunistic; explicit edits show errors.
4281 }
4282 }
4283 });
4284 return () => {
4285 cancelled = true;
4286 if (autoRefreshGenerationRef.current === generation) autoRefreshGenerationRef.current += 1;
4287 };
4288 }, [backgroundApply, s.providers, subtab, t]);
4289
4290 return (
4291 <>
4292 {subtab === "usage" ? (
4293 <div className="model-preferences">
4294 <SettingsSection className="model-assignment-section" title={t("settings.models.preferences")} description={t("settings.models.preferencesApplyHint")}>
4295 <div className="model-assignment-head"><span>{t("settings.modelPurpose")}</span><span>{t("settings.modelUsage")}</span><span>{t("settings.modelConnection")}</span></div>
4296 <SettingsField className="model-assignment-row" label={<ModelSettingHelp label={t("settings.defaultModel")} text={t("providerUI.defaultModelHelp")} />}>
4297 <ModelPicker
4298 s={s}
4299 refs={refs}
4300 value={toRef(s.defaultModel, s)}
4301 disabled={busy}
4302 ariaLabel={t("settings.defaultModel")}
4303 onPick={(ref) => void apply(() => saveModelSettings(s, {kind: "preference", field: "default", ref: ref}))}
4304 />
4305 <span className="model-assignment-connection">{toRef(s.defaultModel, s) && toRef(s.defaultModel, s) !== "auto" ? modelOptionMeta(modelOptionFromRef(toRef(s.defaultModel, s), s)!, t) : t("settings.connectionAutomatic")}</span>
4306 </SettingsField>
4307
4308 <SettingsField className="model-assignment-row" label={<ModelSettingHelp label={t("settings.plannerModel")} text={t("providerUI.plannerModelHelp")} />}>
4309 <ModelPicker
4310 s={s}
4311 refs={refs}
4312 value={plannerSelectRef}
4313 disabled={busy}
4314 ariaLabel={t("settings.plannerModel")}
4315 includeSameDefault
4316 onPick={(ref) => void apply(() => saveModelSettings(s, {kind: "preference", field: "planner", ref: ref}))}
4317 />
4318 <span className="model-assignment-connection">{plannerSelectRef && plannerSelectRef !== "auto" ? modelOptionMeta(modelOptionFromRef(plannerSelectRef, s)!, t) : t("settings.connectionFollowSession")}</span>
4319 </SettingsField>
4320
4321 <SettingsField className="model-assignment-row" label={<ModelSettingHelp label={t("settings.imageUnderstandingModel")} text={t("providerUI.visionModelHelp")} />}>
4322 <ModelPicker
4323 s={s}
4324 refs={visionRefs}
4325 value={visionRef}
4326 disabled={busy}
4327 ariaLabel={t("settings.imageUnderstandingModel")}
4328 emptyOptionLabel={t("common.none")}
4329 autoOptionLabel={t("common.auto")}
4330 onPick={(ref) => void apply(() => saveModelSettings(s, {kind: "preference", field: "vision", ref: ref}))}
4331 />
4332 <span className="model-assignment-connection">{visionRef && visionRef !== "auto" ? modelOptionMeta(modelOptionFromRef(visionRef, s)!, t) : (visionRef === "auto" ? t("settings.connectionAutomatic") : t("common.none"))}</span>
4333 </SettingsField>
4334
4335
4336 <SettingsField className="model-assignment-row" label={<ModelSettingHelp label={t("settings.webSearchModel")} text={t("providerUI.searchModelHelp")} />}>
4337 <div className="web-search-assignment-control">
4338 <ModelPicker s={s} refs={s.webSearchModels ?? []} value={s.webSearchModel || "auto"}
4339 disabled={busy} ariaLabel={t("settings.webSearchModel")} autoOptionLabel={t("common.auto")}
4340 onPick={(ref) => void apply(() => saveModelSettings(s, {kind: "preference", field: "search", ref: ref}))} />
4341 {(s.webSearchModelStatus === "invalid" || Boolean(s.webSearchModel && s.webSearchModel !== "auto" && !(s.webSearchModels ?? []).includes(s.webSearchModel))) && <p role="status" className="web-search-assignment-hint">{t("settings.webSearchModelUnavailable")} {s.webSearchModelReason}</p>}
4342 {s.webSearchModelOverridden && <p className="web-search-assignment-hint">{t("settings.webSearchModelOverride", { model: s.effectiveWebSearchModel || t("common.auto") })}</p>}
4343 {(s.webSearchModels ?? []).length === 0 && <p className="web-search-assignment-hint">{t("settings.webSearchModelEmpty")} {onOpenProviders && <button type="button" className="btn btn--small" onClick={onOpenProviders}>{t("settings.webSearchModelConnections")}</button>}</p>}
4344 </div>
4345 <span className="model-assignment-connection">{!s.webSearchModel || s.webSearchModel === "auto" ? t("settings.webSearchModelAutomatic") : (s.providers.find(p => p.name === s.webSearchModel?.split("/")[0])?.displayName || s.webSearchModel.split("/")[0])}</span>
4346 </SettingsField>
4347
4348 <SettingsField className="model-assignment-row" label={<ModelSettingHelp label={t("settings.subagentModel")} text={t("providerUI.subagentModelHelp")} />}>
4349 <ModelPicker
4350 s={s}
4351 refs={refs}
4352 value={subagentRef}
4353 disabled={busy}
4354 ariaLabel={t("settings.subagentModel")}
4355 emptyOptionLabel={t("settings.subagentModelDefault")}
4356 emptyOptionHint={t("settings.subagentModelFollowHint")}
4357 onPick={(ref) => void apply(() => saveModelSettings(s, {kind: "preference", field: "subagent", ref: ref}))}
4358 />
4359 <span className="model-assignment-connection">{subagentRef && subagentRef !== "auto" ? modelOptionMeta(modelOptionFromRef(subagentRef, s)!, t) : t("settings.connectionFollowParent")}</span>
4360 </SettingsField>
4361
4362 <SettingsField className="model-assignment-effort" label={<ModelSettingHelp label={t("settings.subagentReasoning")} text={t("providerUI.subagentEffortHelp")} />}>
4363 <SettingsSelect
4364 className="mem-select set-grow"
4365 aria-label={t("settings.subagentReasoning")}
4366 value={s.subagentEffort || ""}
4367 disabled={busy}
4368 onValueChange={(value) => void apply(() => saveModelSettings(s, {kind: "preference", field: "subagent_effort", ref: value}))}
4369 >
4370 <option value="">{t("settings.subagentEffortDefault")}</option>
4371 {s.subagentEffort && !subagentLevels.includes(s.subagentEffort) && <option value={s.subagentEffort} disabled>{s.subagentEffort}</option>}
4372 {subagentLevels.map((level) => (
4373 <option key={level} value={level}>
4374 {level}
4375 </option>
4376 ))}
4377 </SettingsSelect>
4378 </SettingsField>
4379
4380 <details className="settings-subagent-advanced"><summary>{t("settings.subagentAdvanced")}</summary>
4381 <SettingsField label={t("settings.subagentDepth")} hint={t("settings.subagentDepthHint")}>
4382 <SettingsOptions className="provider-add-segmented" role="group" aria-label={t("settings.subagentDepth")}>
4383 {[1, 2].map((depth) => (
4384 <button
4385 key={depth}
4386 type="button"
4387 className={subagentDepth === depth ? "provider-add-segmented__item provider-add-segmented__item--active" : "provider-add-segmented__item"}
4388 disabled={busy}
4389 aria-pressed={subagentDepth === depth}
4390 onClick={() => void apply(() => saveModelSettings(s, {kind: "preference", field: "depth", number: depth}))}
4391 >
4392 {depth === 1 ? t("settings.subagentDepthOne") : t("settings.subagentDepthTwo")}
4393 </button>
4394 ))}
4395 </SettingsOptions>
4396 </SettingsField>
4397
4398 <SettingsField label={t("settings.subagentConcurrency")} hint={t("settings.subagentConcurrencyHint")}>
4399 <input
4400 className="mem-input"
4401 type="number"
4402 min={1}
4403 max={32}
4404 value={subagentConcurrency}
4405 disabled={busy}
4406 onChange={(e) => {
4407 const n = Number(e.target.value);
4408 if (!Number.isFinite(n)) return;
4409 void apply(() => saveModelSettings(s, {kind: "preference", field: "concurrency", number: n}));
4410 }}
4411 />
4412 </SettingsField>
4413
4414 <SettingsField label={t("settings.parallelWriters")} hint={t("settings.parallelWritersHint")}>
4415 <input
4416 className="mem-input"
4417 type="number"
4418 min={1}
4419 max={subagentConcurrency}
4420 value={parallelWriters}
4421 disabled={busy}
4422 onChange={(e) => {
4423 const n = Number(e.target.value);
4424 if (!Number.isFinite(n)) return;
4425 void apply(() => saveModelSettings(s, {kind: "preference", field: "writers", number: n}));
4426 }}
4427 />
4428 </SettingsField>
4429
4430 {modelIssue && <div className="provider-fetch-banner provider-fetch-banner--warn">{modelIssue}</div>}
4431 </details>
4432 </SettingsSection>
4433 <SettingsSection className="model-runtime-preferences" title={t("settings.runtimePreferences")}>
4434 <SettingsField label={t("settings.reasoningLanguage")} hint={t("settings.reasoningLanguageHint")}>
4435 <SettingsOptions layout="field" className="set-seg" role="radiogroup" aria-label={t("settings.reasoningLanguage")}>
4436 {(["auto", "zh", "en"] as const).map((lang) => (
4437 <button
4438 key={lang}
4439 type="button"
4440 role="radio"
4441 aria-checked={agent.reasoningLanguage === lang}
4442 className={`set-seg__btn${agent.reasoningLanguage === lang ? " set-seg__btn--on" : ""}`}
4443 disabled={busy}
4444 onClick={() => void apply(() => app.SetReasoningLanguage(lang))}
4445 >
4446 {t(`settings.reasoningLanguage.${lang}`)}
4447 </button>
4448 ))}
4449 </SettingsOptions>
4450 </SettingsField>
4451 <SettingsField label={t("settings.compactRatio")} hint={t("settings.compactRatioHint")} stacked>
4452 <div className="compact-ratio-controls">
4453 <fieldset className="compact-ratio-choice-list">
4454 <legend className="sr-only">{t("settings.compactRatio")}</legend>
4455 {COMPACT_RATIO_PRESETS.map(([ratio, labelKey, effectKey]) => {
4456 const selected = Math.abs(compactRatio - ratio) < 0.0001;
4457 const active = selected;
4458 const [percent, name] = t(labelKey).split(" · ");
4459 return (
4460 <div key={ratio} className="compact-ratio-choice" data-selected={active || undefined}>
4461 <label className="compact-ratio-choice__row"
4462 onPointerDown={(event) => {
4463 if (event.button === 0 && document.activeElement === compactRatioCustomInputRef.current) event.preventDefault();
4464 }}
4465 onMouseDown={(event) => {
4466 // Keep focus until click so blur cannot disable the intended preset.
4467 if (event.button === 0 && document.activeElement === compactRatioCustomInputRef.current) event.preventDefault();
4468 }}
4469 onClick={() => {
4470 if (selected && !busy) void selectCompactRatioPreset(ratio);
4471 }}
4472 >
4473 <input
4474 type="radio"
4475 name="settings-compact-ratio"
4476 value={ratio}
4477 checked={active}
4478 disabled={busy}
4479 aria-label={t(labelKey)}
4480 onChange={() => void selectCompactRatioPreset(ratio)}
4481 />
4482 <span className="compact-ratio-choice__name">{name}</span>
4483 <span className="compact-ratio-choice__percent">{percent}</span>
4484 <span className="compact-ratio-choice__effect">— {t(effectKey)}</span>
4485 {Math.abs(ratio - 0.8) < 0.0001 && <span className="compact-ratio-choice__badge">{t("settings.recommended")}</span>}
4486 </label>
4487 </div>
4488 );
4489 })}
4490 <div className="compact-ratio-choice" data-selected={!compactRatioPreset || undefined}>
4491 <div className="compact-ratio-choice__row compact-ratio-choice__row--custom">
4492 <input
4493 id="settings-compact-ratio-custom-choice"
4494 type="radio"
4495 name="settings-compact-ratio"
4496 value="custom"
4497 checked={!compactRatioPreset}
4498 disabled={busy}
4499 aria-label={t("settings.compactRatioCustomOption")}
4500 onChange={focusCompactRatioCustom}
4501 />
4502 <label className="compact-ratio-choice__name" htmlFor="settings-compact-ratio-custom-choice">
4503 {t("settings.typography.customized")}
4504 </label>
4505 <label className="compact-ratio-choice__inline-input" htmlFor="settings-compact-ratio-custom">
4506 <input
4507 ref={compactRatioCustomInputRef}
4508 id="settings-compact-ratio-custom"
4509 type="number"
4510 min={COMPACT_RATIO_MIN_PERCENT}
4511 max={COMPACT_RATIO_MAX_PERCENT}
4512 step={0.1}
4513 inputMode="decimal"
4514 value={compactRatioDraft}
4515 placeholder={t("settings.compactRatioCustomPlaceholder")}
4516 disabled={busy}
4517 aria-label={t("settings.compactRatioCustomAria")}
4518 aria-invalid={compactRatioDraft !== "" && !compactRatioDraftValid}
4519 onFocus={() => setCompactRatioCustomEditing(true)}
4520 onInput={(event) => setCompactRatioDraft(event.currentTarget.value)}
4521 onBlur={(event) => {
4522 if (compactRatioCancelBlurRef.current) {
4523 compactRatioCancelBlurRef.current = false;
4524 return;
4525 }
4526 void commitCompactRatioDraft(event.currentTarget.value);
4527 }}
4528 onKeyDown={(event) => {
4529 if (event.key === "Enter") {
4530 event.preventDefault();
4531 event.currentTarget.blur();
4532 }
4533 if (event.key === "Escape") {
4534 event.preventDefault();
4535 compactRatioCancelBlurRef.current = true;
4536 setCompactRatioDraft(compactRatioPreset ? "" : String(compactRatioPercent));
4537 setCompactRatioCustomEditing(false);
4538 event.currentTarget.blur();
4539 }
4540 }}
4541 />
4542 <span aria-hidden="true">%</span>
4543 </label>
4544 </div>
4545 </div>
4546 </fieldset>
4547 <div className="compact-ratio-impact">{compactRatioImpact}</div>
4548 </div>
4549 </SettingsField>
4550 {compactRatioOverrideHint && <div className="provider-fetch-banner provider-fetch-banner--warn">{compactRatioOverrideHint}</div>}
4551 </SettingsSection>
4552 </div>
4553 ) : null}
4554 <div className="model-access-page" hidden={subtab !== "access"}>
4555 <ProvidersSection s={s} busy={busy} apply={apply} onboarding={onboarding} focusProvider={focusProvider} focusRequestId={focusRequestId} onOnboardingComplete={onOnboardingComplete} />
4556 </div>
4557 {subtab === "stats" && (
4558 <Suspense fallback={<div className="empty">{t("settings.loading")}</div>}>
4559 <UsageStatsPanel />
4560 </Suspense>
4561 )}
4562 </>
4563 );
4564 }
4565
4566 type ModelPickerOption = {
4567 ref: string;
4568 provider: string;
4569 model: string;
4570 providerView?: ProviderView;
4571 };
4572
4573 export function ModelPicker({
4574 s,
4575 refs,
4576 value,
4577 disabled,
4578 includeSameDefault = false,
4579 ariaLabel,
4580 emptyOptionLabel,
4581 emptyOptionHint,
4582 autoOptionLabel,
4583 autoOptionHint,
4584 onPick,
4585 }: {
4586 s: SettingsView;
4587 refs: string[];
4588 value: string;
4589 disabled: boolean;
4590 includeSameDefault?: boolean;
4591 ariaLabel?: string;
4592 emptyOptionLabel?: string;
4593 emptyOptionHint?: string;
4594 autoOptionLabel?: string;
4595 autoOptionHint?: string;
4596 onPick: (ref: string) => void;
4597 }) {
4598 const t = useT();
4599 const emptyLabel = includeSameDefault ? t("settings.plannerNone") : emptyOptionLabel;
4600 const emptyHint = includeSameDefault ? t("settings.plannerNoneHint") : emptyOptionHint;
4601 const emptyMeta = includeSameDefault ? t("settings.plannerNoneHintShort") : emptyOptionHint;
4602 const autoLabel = autoOptionLabel;
4603 const autoHint = autoOptionHint;
4604 const selected = refs.includes(value) ? modelOptionFromRef(value, s) : null;
4605 const selectedLabel = value === "auto" && autoLabel
4606 ? autoLabel
4607 : value === "" && emptyLabel
4608 ? emptyLabel
4609 : selected?.model || value || t("common.none");
4610 const selectedMeta = value === "auto" && autoLabel
4611 ? autoHint || ""
4612 : value === "" && emptyLabel
4613 ? emptyMeta || ""
4614 : selected
4615 ? modelOptionMeta(selected, t)
4616 : t("settings.noModelsConfigured");
4617
4618 const groups = useMemo(() => {
4619 const providerOrder: string[] = [];
4620 const providerSeen = new Set<string>();
4621 for (const p of s.providers) {
4622 const id = p.name;
4623 if (!providerSeen.has(id)) {
4624 providerOrder.push(id);
4625 providerSeen.add(id);
4626 }
4627 }
4628 const options = refs
4629 .map((ref) => modelOptionFromRef(ref, s))
4630 .filter((opt): opt is ModelPickerOption => Boolean(opt))
4631 ;
4632 for (const opt of options) {
4633 const groupID = modelOptionGroupID(opt);
4634 if (!providerSeen.has(groupID)) {
4635 providerOrder.push(groupID);
4636 providerSeen.add(groupID);
4637 }
4638 }
4639 return providerOrder
4640 .map((groupID) => {
4641 const providerViews = s.providers.filter((p) => p.name === groupID);
4642 const firstProvider = providerViews[0];
4643 return {
4644 groupID,
4645 label: firstProvider ? (firstProvider.displayName || firstProvider.name) : groupID,
4646 keySet: providerViews.some((p) => p.keySet),
4647 requiresKey: providerViews.every((p) => providerRequiresKey(p)),
4648 options: uniqueModelOptions(options.filter((opt) => modelOptionGroupID(opt) === groupID)),
4649 };
4650 })
4651 .filter((group) => group.options.length > 0);
4652 }, [refs, s, t]);
4653
4654 return (
4655 <div className="settings-model-picker">
4656 <SettingsSelect
4657 value={value}
4658 selectedLabel={selectedLabel}
4659 title={selectedMeta || undefined}
4660 disabled={disabled || (!emptyLabel && !autoLabel && refs.length === 0)}
4661 aria-label={ariaLabel}
4662 searchPlaceholder={t("settings.searchModels")}
4663 emptyLabel={t("settings.noMatchingModels")}
4664 onValueChange={onPick}
4665 options={[
4666 ...(emptyLabel ? [{ value: "", label: emptyLabel, hint: emptyHint }] : []),
4667 ...(autoLabel ? [{ value: "auto", label: autoLabel, hint: autoHint }] : []),
4668 ...groups.flatMap(group => group.options.map(opt => ({
4669 value: opt.ref,
4670 label: opt.model,
4671 group: group.groupID,
4672 groupLabel: group.requiresKey && !group.keySet ? `${group.label} · ${t("settings.noKey")}` : group.label,
4673 searchText: `${opt.ref} ${modelProviderLabel(opt.provider, opt.providerView, t)}`,
4674 }))),
4675 ]}
4676 />
4677 </div>
4678 );
4679 }
4680
4681 function modelOptionFromRef(ref: string, s: SettingsView): ModelPickerOption | null {
4682 if (!ref) return null;
4683 const [provider, ...modelParts] = ref.split("/");
4684 const model = modelParts.join("/") || ref;
4685 return {
4686 ref,
4687 provider,
4688 model,
4689 providerView: s.providers.find((p) => p.name === provider),
4690 };
4691 }
4692
4693 function modelOptionMeta(option: ModelPickerOption, t: ReturnType<typeof useT>): string {
4694 const key = option.providerView ? providerKeyStatusLabel(option.providerView, t) : t("settings.noKey");
4695 return `${option.providerView?.displayName || option.provider}${option.providerView && providerRequiresKey(option.providerView) && !option.providerView.keySet ? ` · ${key}` : ""}`;
4696 }
4697
4698 function providerKeyStatusLabel(provider: { keySet: boolean; requiresKey?: boolean; apiKeyEnv?: string }, t: ReturnType<typeof useT>): string {
4699 if (!providerRequiresKey(provider)) return t("settings.noKeyRequired");
4700 return provider.keySet ? t("settings.keySet") : t("settings.noKey");
4701 }
4702
4703 function modelProviderLabel(provider: string, providerView: ProviderView | undefined, t: ReturnType<typeof useT>): string {
4704 return providerView?.displayName || (providerView ? providerGroupLabel(providerView, t) : provider);
4705 }
4706
4707 function modelOptionGroupID(option: ModelPickerOption): string {
4708 return option.provider;
4709 }
4710
4711 function uniqueModelOptions(options: ModelPickerOption[]): ModelPickerOption[] {
4712 const indexByModel = new Map<string, number>();
4713 const out: ModelPickerOption[] = [];
4714 for (const option of options) {
4715 const existingIndex = indexByModel.get(option.model);
4716 if (existingIndex !== undefined) {
4717 const existing = out[existingIndex];
4718 if (!existing?.providerView?.webSearch && option.providerView?.webSearch) {
4719 out[existingIndex] = option;
4720 }
4721 continue;
4722 }
4723 indexByModel.set(option.model, out.length);
4724 out.push(option);
4725 }
4726 return out;
4727 }
4728
4729 function sameStringList(a: string[], b: string[]): boolean {
4730 if (a.length !== b.length) return false;
4731 return a.every((value, i) => value === b[i]);
4732 }
4733
4734 function proxyModeLabel(mode: ProxyMode, t: ReturnType<typeof useT>): string {
4735 switch (mode) {
4736 case "auto":
4737 return t("settings.proxyMode.auto");
4738 case "custom":
4739 return t("settings.proxyMode.custom");
4740 case "off":
4741 return t("settings.proxyMode.off");
4742 }
4743 }
4744
4745 export function ProvidersSection({ s, busy, apply, onboarding, focusProvider, focusRequestId, onOnboardingComplete }: SectionProps & { onboarding?: boolean; focusProvider?: string; focusRequestId?: number; onOnboardingComplete?: () => void }) {
4746 const t = useT();
4747 const existingConnection = s.providers.find(p => p.added);
4748 const [editing, setEditing] = useState<string | null>(() => onboarding ? existingConnection?.name ?? null : null);
4749 const [adding, setAdding] = useState<AddProviderMode>(() => onboarding && !existingConnection ? "official" : null);
4750 const [revealedProvider, setRevealedProvider] = useState<string | null>(() => onboarding ? existingConnection?.name ?? null : null);
4751 useEffect(() => {
4752 if (!focusProvider) return;
4753 if (s.providers.some((provider) => provider.name === focusProvider)) {
4754 setRevealedProvider(focusProvider);
4755 setEditing(focusProvider);
4756 } else {
4757 setRevealedProvider(null);
4758 setEditing(null);
4759 }
4760 }, [focusProvider, focusRequestId, s.providers]);
4761 const readyConnection = s.providers.find(p => p.added && providerIsConfigured(p) && p.models.length > 0);
4762 const startUsing = async () => {
4763 if (!readyConnection) return;
4764 const model = providerDefaultModel(readyConnection.default, readyConnection.models);
4765 if (await apply(() => saveModelSettings(s, {kind: "preference", field: "default", ref: `${readyConnection.name}/${model}`}))) onOnboardingComplete?.();
4766 };
4767 const [fetchingProviders, setFetchingProviders] = useState<Set<string>>(() => new Set());
4768 const fetchGate = useMemo(createLatestRequestGate, []);
4769 const [fetchResults, setFetchResults] = useState<Record<string, ProviderFetchResult>>({});
4770 const [modelDrafts, setModelDrafts] = useState<Record<string, ProviderModelDraft>>({});
4771 const visibleProviders = useMemo(() => s.providers.filter((p) => p.added || p.name === revealedProvider), [s.providers, revealedProvider]);
4772 const groups = useMemo(() => visibleProviders.map(p => ({...providerAccessGroups([p], t)[0], id: `connection:${p.name}`, label: p.displayName || p.name})), [visibleProviders, t]);
4773
4774 useEffect(() => {
4775 if (revealedProvider && !s.providers.some((p) => p.name === revealedProvider)) {
4776 setRevealedProvider(null);
4777 if (editing === revealedProvider) setEditing(null);
4778 }
4779 }, [editing, revealedProvider, s.providers]);
4780
4781 const setGroupFetchResult = (groupID: string, result: ProviderFetchResult | null) => {
4782 setFetchResults((prev) => {
4783 const next = { ...prev };
4784 if (result) next[groupID] = result;
4785 else delete next[groupID];
4786 return next;
4787 });
4788 };
4789
4790 const setGroupModelDraft = (groupID: string, draft: ProviderModelDraft | null) => {
4791 setModelDrafts((prev) => {
4792 const next = { ...prev };
4793 if (draft) next[groupID] = draft;
4794 else delete next[groupID];
4795 return next;
4796 });
4797 };
4798
4799 const beginGroupFetch = (groupID: string): number => {
4800 const generation = fetchGate.begin(groupID);
4801 setFetchingProviders((current) => {
4802 if (current.has(groupID)) return current;
4803 const next = new Set(current);
4804 next.add(groupID);
4805 return next;
4806 });
4807 return generation;
4808 };
4809
4810 const groupFetchIsCurrent = (groupID: string, generation: number): boolean => (
4811 fetchGate.isCurrent(groupID, generation)
4812 );
4813
4814 const finishGroupFetch = (groupID: string, generation: number) => {
4815 if (!groupFetchIsCurrent(groupID, generation)) return;
4816 setFetchingProviders((current) => {
4817 if (!current.has(groupID)) return current;
4818 const next = new Set(current);
4819 next.delete(groupID);
4820 return next;
4821 });
4822 };
4823
4824 const cancelGroupFetch = (groupID: string) => {
4825 fetchGate.cancel(groupID);
4826 setFetchingProviders((current) => {
4827 if (!current.has(groupID)) return current;
4828 const next = new Set(current);
4829 next.delete(groupID);
4830 return next;
4831 });
4832 };
4833
4834 const modelDraftForFetch = (p: ProviderView, fetched: string[], capabilities: ProviderModelCapabilityView[] = []): ProviderModelDraft => {
4835 const candidates = providerModelCandidates(p.models, fetched);
4836 const selected = mergedFetchedProviderModels(p.models, fetched, { preserveCurated: true });
4837 const configuredVision = providerVisionModelsForView(p, candidates);
4838 return {
4839 providerName: p.name,
4840 candidates,
4841 selected: candidates.filter((model) => selected.includes(model)),
4842 visionModels: configuredVision,
4843 visionModelsConfigured: p.visionModelsConfigured,
4844 modelCapabilities: capabilities,
4845 };
4846 };
4847
4848 const updateModelDraftSelection = (groupID: string, nextSelected: (draft: ProviderModelDraft) => string[]) => {
4849 setModelDrafts((prev) => {
4850 const draft = prev[groupID];
4851 if (!draft) return prev;
4852 const selectedSet = new Set(nextSelected(draft));
4853 return {
4854 ...prev,
4855 [groupID]: {
4856 ...draft,
4857 selected: draft.candidates.filter((model) => selectedSet.has(model)),
4858 },
4859 };
4860 });
4861 };
4862
4863
4864 const refreshModels = async (group: ProviderAccessGroup, p: ProviderView) => {
4865 const generation = beginGroupFetch(group.id);
4866 setGroupFetchResult(group.id, null);
4867 setGroupModelDraft(group.id, null);
4868 try {
4869 let fetched: string[];
4870 let fetchedCapabilities: ProviderModelCapabilityView[] = [];
4871 try {
4872 fetchedCapabilities = await cachedFetchProviderModelCatalog((provider) => app.FetchProviderModelCatalog(provider), p, true);
4873 fetched = fetchedCapabilities.map((item) => item.model);
4874 } catch (e) {
4875 if (!groupFetchIsCurrent(group.id, generation)) return;
4876 setGroupFetchResult(group.id, {
4877 kind: "warn",
4878 text: t("settings.fetchModelsFailedForProvider", { provider: group.label, err: String((e as Error)?.message ?? e) }),
4879 });
4880 return;
4881 }
4882 if (!groupFetchIsCurrent(group.id, generation)) return;
4883 if (fetched.length === 0) {
4884 setGroupFetchResult(group.id, {
4885 kind: "warn",
4886 text: t("settings.fetchModelsEmptyForProvider", { provider: group.label }),
4887 });
4888 return;
4889 }
4890 const draft = modelDraftForFetch(p, fetched, fetchedCapabilities);
4891 startTransition(() => {
4892 setGroupModelDraft(group.id, draft);
4893 setGroupFetchResult(group.id, {
4894 kind: "ok",
4895 text: t("settings.fetchModelsReadyForProvider", { provider: group.label, n: draft.candidates.length }),
4896 });
4897 });
4898 } finally {
4899 finishGroupFetch(group.id, generation);
4900 }
4901 };
4902
4903 const saveProviderKey = async (group: ProviderAccessGroup, apiKeyEnv: string, value: string) => {
4904 if (!apiKeyEnv) return;
4905 cancelGroupFetch(group.id);
4906 setGroupFetchResult(group.id, null);
4907 setGroupModelDraft(group.id, null);
4908 const saved = await apply(async () => {
4909 const warning = await saveModelSettings(s, {kind: "credential", names: group.providers.filter(p => p.apiKeyEnv === apiKeyEnv).map(p => p.name), key: value});
4910 invalidateProviderCacheByAPIKeyEnv(apiKeyEnv);
4911 return warning;
4912 });
4913 if (!saved) throw new Error(t("settings.models.keySaveFailed"));
4914 };
4915
4916 const clearProviderKey = async (group: ProviderAccessGroup, apiKeyEnv: string) => {
4917 if (!apiKeyEnv) return;
4918 cancelGroupFetch(group.id);
4919 await apply(async () => {
4920 await saveModelSettings(s, {kind: "credential", names: group.providers.filter(p => p.apiKeyEnv === apiKeyEnv).map(p => p.name), key: ""});
4921 invalidateProviderCacheByAPIKeyEnv(apiKeyEnv);
4922 });
4923 };
4924
4925 const saveProvider = async (provider: ProviderView, key: string, create = false) => {
4926 if (create || !s.providers.some(p => p.name === provider.name)) {
4927 const id = crypto.randomUUID().replaceAll("-", "");
4928 provider = {...provider, displayName: provider.displayName || provider.name, name: `${provider.name}-${id}`, apiKeyEnv: `REASONIX_CONNECTION_${id.toUpperCase()}_KEY`};
4929 }
4930 if (key) {
4931 provider = {...provider, apiKeyEnv: `REASONIX_CONNECTION_${crypto.randomUUID().replaceAll("-", "").toUpperCase()}_KEY`};
4932 const warning = await saveModelSettings(s, {kind: "provider_save", provider, key});
4933 invalidateProviderCacheByAPIKeyEnv(provider.apiKeyEnv);
4934 return warning;
4935 }
4936 return saveModelSettings(s, {kind: "provider_save", provider});
4937 };
4938
4939 const saveModelDraft = async (group: ProviderAccessGroup) => {
4940 const draft = modelDrafts[group.id];
4941 const provider = draft ? group.providers.find((p) => p.name === draft.providerName) : null;
4942 const models = uniqueStrings(draft?.selected ?? []);
4943 if (!draft || !provider || models.length === 0) return;
4944 let saved = false;
4945 await apply(async () => {
4946 const result = await saveModelSettings(s, {kind: "provider_save", provider: {
4947 ...provider,
4948 models,
4949 // Vision capability is derived from model metadata. Keep legacy
4950 // fields untouched so old configurations remain readable.
4951 default: providerDefaultModel(provider.default, models),
4952 }});
4953 saved = true;
4954 return result;
4955 });
4956 if (!saved) return;
4957 setGroupModelDraft(group.id, null);
4958 setGroupFetchResult(group.id, {
4959 kind: "ok",
4960 text: t("settings.enabledModelsSavedForProvider", { provider: group.label, n: models.length }),
4961 });
4962 };
4963
4964 return (
4965 <SettingsSection
4966 title={t("settings.providerAccess")}
4967 description={t("settings.providerAccessHint")}
4968 actions={
4969 <button className="btn btn--small" disabled={busy || adding !== null} onClick={() => setAdding("official")}>
4970 {t("settings.addProvider")}
4971 </button>
4972 }
4973 >
4974 {onboarding && <div id="provider-onboarding" className="banner banner--actionable" role="status">
4975 <span className="banner__msg">{t(readyConnection ? "onboarding.connectionReady" : existingConnection ? "onboarding.repairConnection" : "onboarding.addConnection")}</span>
4976 {readyConnection && <button type="button" className="btn btn--primary" disabled={busy} onClick={() => void startUsing()}>{t("onboarding.startUsing")}</button>}
4977 </div>}
4978 <div className="provider-access-grid">
4979 {groups.length === 0 && adding === null && (
4980 <div className="provider-empty">
4981 <strong>{t("settings.providerAccessEmptyTitle")}</strong>
4982 <span>{t("settings.providerAccessEmptyHint")}</span>
4983 <div className="provider-empty__actions">
4984 <button type="button" className="btn btn--small" disabled={busy} onClick={() => setAdding("official")}>
4985 {t("settings.addProvider.officialChoice")}
4986 </button>
4987 <button type="button" className="btn btn--small" disabled={busy} onClick={() => setAdding("custom")}>
4988 {t("settings.addProvider.customChoice")}
4989 </button>
4990 </div>
4991 </div>
4992 )}
4993 {adding !== null && (
4994 <AddProviderPanel
4995 mode={adding}
4996 kinds={s.providerKinds}
4997 officialProviders={s.officialProviders}
4998 providerPresets={s.providerPresets}
4999 busy={busy}
5000 onMode={setAdding}
5001 onCancel={() => onboarding ? onOnboardingComplete?.() : setAdding(null)}
5002 onAddOfficial={(kind, key, baseURL, format) => apply(() => saveModelSettings(s, {kind: "connection_add", name: s.providers.find(p => officialProviderKind(p) === kind)?.name ?? "deepseek-flash", key, baseURL, protocol: format})).then(saved => { if (saved) setAdding(null); })}
5003 onAddPreset={(id, key, baseURL, format) => apply(() => saveModelSettings(s, {kind: "connection_add", presetId: id, key, baseURL, protocol: format})).then(saved => { if (saved) setAdding(null); })}
5004 onViewPresetConflict={(providerName) => {
5005 setRevealedProvider(providerName);
5006 setEditing(providerName);
5007 setAdding(null);
5008 }}
5009 onResetPreset={(id) => apply(() => saveModelSettings(s, {kind: "preset_reset", presetId: id})).then(saved => { if (saved) setAdding(null); })}
5010 onAddCustom={(pv, key) => apply(() => saveProvider(pv, key ?? "", true)).then(saved => { if (saved) setAdding(null); })}
5011 />
5012 )}
5013 <ProviderConnections groups={groups} presets={s.providerPresets} revealedProvider={revealedProvider} hidden={adding !== null} busy={busy} onAdd={() => setAdding("official")} renderDetail={(group) => (
5014 <ProviderAccessCard
5015 key={group.id}
5016 detail
5017 onCopy={() => void apply(() => saveModelSettings(s, {kind: "connection_add", name: group.providers[0].name, key: ""}))}
5018 onRename={(label) => apply(() => {
5019 if (typeof app.RenameProviderConnections !== "function") throw new Error(t("settings.connections.restartRequired"));
5020 return saveModelSettings(s, {kind: "rename", names: group.providers.map(p => p.name), ref: label});
5021 })}
5022 group={group}
5023 providerPresets={s.providerPresets}
5024 busy={busy}
5025 fetching={fetchingProviders.has(group.id)}
5026 fetchResult={fetchResults[group.id]}
5027 modelDraft={modelDrafts[group.id]}
5028 editing={editing}
5029 kinds={s.providerKinds}
5030 onEdit={setEditing}
5031 onCancelEdit={() => setEditing(null)}
5032 onSave={(pv, key) => {
5033 cancelGroupFetch(group.id);
5034 return apply(() => saveProvider(pv, key ?? "")).then((saved) => {
5035 if (!saved) throw new Error(t("settings.connections.renameFailed"));
5036 setGroupModelDraft(group.id, null);
5037 });
5038 }}
5039 onRefresh={(provider) => void refreshModels(group, provider)}
5040 onToggleDraftModel={(model) => updateModelDraftSelection(group.id, (draft) => (
5041 draft.selected.includes(model)
5042 ? draft.selected.filter((candidate) => candidate !== model)
5043 : [...draft.selected, model]
5044 ))}
5045 onSelectAllDraftModels={() => updateModelDraftSelection(group.id, (draft) => draft.candidates)}
5046 onClearDraftModels={() => updateModelDraftSelection(group.id, () => [])}
5047 onCancelDraftModels={() => {
5048 setGroupModelDraft(group.id, null);
5049 setGroupFetchResult(group.id, null);
5050 }}
5051 onSaveDraftModels={() => void saveModelDraft(group)}
5052 onToggleWebSearch={(enabled) => {
5053 if (group.id === "custom:opencode-go") {
5054 const searchProviderNames = group.providers
5055 .map((provider) => provider.name)
5056 .filter((name) => name.startsWith("opencode-go-deepseek-"));
5057 if (enabled) {
5058 void apply(() => saveModelSettings(s, {kind: "preset_add", presetId: "opencode-go-deepseek-responses", key: ""}));
5059 } else if (searchProviderNames.length > 0) {
5060 void apply(() => saveModelSettings(s, {kind: "access_remove", names: searchProviderNames}));
5061 }
5062 return;
5063 }
5064 const providerNames = group.providers.map((provider) => provider.name);
5065 if (providerNames.length === 0) return;
5066 void apply(() => saveModelSettings(s, {kind: "web_search_capability", names: providerNames, enabled}));
5067 }}
5068 onUpgradeRecommended={(name) => {
5069 cancelGroupFetch(group.id);
5070 return apply(() => saveModelSettings(s, {kind: "protocol_upgrade", name})).then((upgraded) => {
5071 if (upgraded) {
5072 setEditing(null);
5073 setGroupModelDraft(group.id, null);
5074 }
5075 });
5076 }}
5077 onSaveEditorKey={(env, value) => saveProviderKey(group, env, value)}
5078 onClearEditorKey={(env) => clearProviderKey(group, env)}
5079 onDelete={(providers) => {
5080 cancelGroupFetch(group.id);
5081 const providerNames = providers.map(({ name }) => name);
5082 return apply(() => saveModelSettings(s, {kind: "access_remove", names: providerNames})).then(() => {
5083 if (revealedProvider && providerNames.includes(revealedProvider)) {
5084 setRevealedProvider(null);
5085 setEditing(null);
5086 }
5087 });
5088 }}
5089 />
5090 )} />
5091 </div>
5092 </SettingsSection>
5093 );
5094 }
5095
5096 export type ProviderAccessGroup = {
5097 id: string;
5098 label: string;
5099 description: string;
5100 builtIn: boolean;
5101 providers: ProviderView[];
5102 apiKeyEnv: string;
5103 keySet: boolean;
5104 requiresKey: boolean;
5105 configured: boolean;
5106 keySource?: string;
5107 keySourcePath?: string;
5108 baseUrl: string;
5109 kind: string;
5110 models: string[];
5111 recommendedUpgradeAvailable: boolean;
5112 };
5113
5114 type ProviderFetchResult = {
5115 kind: "ok" | "warn";
5116 text: string;
5117 };
5118
5119 type ProviderModelDraft = {
5120 providerName: string;
5121 candidates: string[];
5122 selected: string[];
5123 visionModels: string[];
5124 visionModelsConfigured: boolean;
5125 modelCapabilities: ProviderModelCapabilityView[];
5126 };
5127
5128 type AddProviderMode = null | "official" | "custom";
5129 type OfficialProviderKind = "deepseek";
5130
5131 const OFFICIAL_PROVIDER_CHOICES: Array<{ kind: OfficialProviderKind; labelKey: DictKey; descKey: DictKey; keyEnv: string }> = [
5132 { kind: "deepseek", labelKey: "settings.addProvider.official.deepseek", descKey: "settings.addProvider.official.deepseekDesc", keyEnv: "DEEPSEEK_API_KEY" },
5133 ];
5134
5135 type ProviderTemplateChoice =
5136 | { id: string; source: "official"; kind: OfficialProviderKind; label: string; description: string; keyEnv: string; added: boolean; keySet: boolean }
5137 | {
5138 id: string; source: "preset"; presetID: string; label: string; description: string; keyEnv: string;
5139 added: boolean; status: ProviderPresetStatus; statusProviderNames: string[]; missingProviderNames: string[]; keySet: boolean;
5140 recommended: boolean; billingMode: string; displayGroup: string; displaySection: string;
5141 displayTier: string; routeKind: string; optional: boolean; displayOrder: number;
5142 };
5143
5144 export function AddProviderPanel({
5145 mode,
5146 kinds,
5147 officialProviders,
5148 providerPresets,
5149 busy,
5150 onMode,
5151 onCancel,
5152 onAddOfficial,
5153 onAddPreset,
5154 onViewPresetConflict,
5155 onResetPreset,
5156 onAddCustom,
5157 }: {
5158 mode: AddProviderMode; kinds: string[]; officialProviders: ProviderView[];
5159 providerPresets: ProviderPresetView[];
5160 busy: boolean;
5161 onMode: (mode: AddProviderMode) => void;
5162 onCancel: () => void;
5163 onAddOfficial: (kind: OfficialProviderKind, key: string, baseURL?: string, format?: string) => Promise<void>;
5164 onAddPreset: (id: string, key: string, baseURL?: string, format?: string) => Promise<void>;
5165 onViewPresetConflict: (providerName: string) => void;
5166 onResetPreset: (id: string) => Promise<void>;
5167 onAddCustom: (p: ProviderView, key?: string) => void | Promise<void>;
5168 }) {
5169 const t = useT();
5170 const choices: CatalogChoice[] = [
5171 ...OFFICIAL_PROVIDER_CHOICES.map(choice => {
5172 const provider = officialProviders.find(p => officialProviderKind(p) === choice.kind);
5173
5174 return {
5175 id: `official:${choice.kind}`,
5176 catalog: { brandId: "deepseek", brandLabel: "DeepSeek", region: "global", product: "api", format: "anthropic", baseUrl: "https://api.deepseek.com/anthropic" },
5177 keyEnv: provider?.apiKeyEnv || choice.keyEnv, keySet: Boolean(provider?.keySet),
5178 models: provider?.models ?? ["deepseek-v4-flash", "deepseek-v4-pro"],
5179 canAdd: true, status: "available",
5180 statusLabel: "",
5181 actionLabel: t("settings.addProvider.confirm"),
5182 };
5183 }),
5184 ...providerPresets.map(preset => {
5185 const choice: ProviderTemplateChoice = {
5186 id: `preset:${preset.id}`, source: "preset", presetID: preset.id,
5187 label: preset.label, description: preset.description, keyEnv: preset.keyEnv,
5188 added: preset.added, status: normalizeProviderPresetStatus(preset.status, preset.added),
5189 statusProviderNames: asArray(preset.statusProviderNames), missingProviderNames: asArray(preset.missingProviderNames),
5190 keySet: preset.keySet, recommended: Boolean(preset.recommended), billingMode: preset.billingMode ?? "",
5191 displayGroup: preset.displayGroup ?? "", displaySection: preset.displaySection ?? "",
5192 displayTier: preset.displayTier ?? "", routeKind: preset.routeKind ?? "", optional: Boolean(preset.optional), displayOrder: preset.displayOrder ?? 0,
5193 };
5194 return {
5195 id: choice.id, label: preset.label,
5196 // Older hosts can omit the additive catalog field; their presets remain usable.
5197 catalog: catalogForPreset(preset),
5198 keyEnv: preset.keyEnv, keySet: preset.keySet, models: preset.models,
5199 canAdd: true, status: "available",
5200 statusLabel: "", actionLabel: t("settings.addProvider.confirm"),
5201 conflictName: undefined,
5202 };
5203 }),
5204 ];
5205 return <div className="provider-add-panel">
5206 <div className="provider-add-panel__head">
5207 <div><strong>{t("settings.addProvider.chooseTitle")}</strong><span>{t("settings.catalog.hint")}</span></div>
5208 <button type="button" className="btn btn--small" disabled={busy} onClick={onCancel}>{t("common.cancel")}</button>
5209 </div>
5210 <SettingsOptions className="provider-add-segmented" role="tablist" aria-label={t("settings.addProvider.chooseTitle")}>
5211 <button type="button" role="tab" aria-selected={mode === "official"} className="provider-add-segmented__item" disabled={busy} onClick={() => onMode("official")}>{t("settings.addProvider.officialChoice")}</button>
5212 <button type="button" role="tab" aria-selected={mode === "custom"} className="provider-add-segmented__item" disabled={busy} onClick={() => onMode("custom")}>{t("settings.addProvider.customChoice")}</button>
5213 </SettingsOptions>
5214 {mode === "official" && <ProviderCatalogPicker choices={choices} busy={busy}
5215 onConnect={(id, key, baseURL, format) => { if (id.startsWith("official:")) void onAddOfficial("deepseek", key, baseURL, format); else void onAddPreset(id.slice(7), key, baseURL, format); }}
5216 onView={onViewPresetConflict} onReset={id => { if (id.startsWith("preset:")) void onResetPreset(id.slice(7)); }} />}
5217 {mode === "custom" && <ProviderEditor kinds={kinds} busy={busy} onCancel={onCancel} onSave={onAddCustom} />}
5218 </div>;
5219 }
5220
5221 export function ProviderAccessCard({
5222 detail = false,
5223 onCopy,
5224 onRename,
5225 group,
5226 providerPresets,
5227 busy,
5228 fetching,
5229 fetchResult,
5230 modelDraft,
5231 editing,
5232 kinds,
5233 onEdit,
5234 onCancelEdit,
5235 onSave,
5236 onRefresh,
5237 onToggleDraftModel,
5238 onSelectAllDraftModels,
5239 onClearDraftModels,
5240 onCancelDraftModels,
5241 onSaveDraftModels,
5242 onToggleWebSearch,
5243 onUpgradeRecommended,
5244 onSaveEditorKey,
5245 onClearEditorKey,
5246 onDelete,
5247 }: {
5248 detail?: boolean;
5249 onCopy?: () => void;
5250 onRename?: (label: string) => Promise<boolean>;
5251 group: ProviderAccessGroup;
5252 providerPresets?: ProviderPresetView[];
5253 busy: boolean;
5254 fetching: boolean;
5255 fetchResult?: ProviderFetchResult;
5256 modelDraft?: ProviderModelDraft;
5257 editing: string | null;
5258 kinds: string[];
5259 onEdit: (name: string) => void;
5260 onCancelEdit: () => void;
5261 onSave: (p: ProviderView, key?: string) => void | Promise<void>;
5262 onRefresh: (p: ProviderView) => void;
5263 onToggleDraftModel: (model: string) => void;
5264 onSelectAllDraftModels: () => void;
5265 onClearDraftModels: () => void;
5266 onCancelDraftModels: () => void;
5267 onSaveDraftModels: () => void;
5268 onToggleWebSearch: (enabled: boolean) => void;
5269 onUpgradeRecommended: (name: string) => void | Promise<void>;
5270 onSaveEditorKey: (apiKeyEnv: string, value: string) => Promise<void>;
5271 onClearEditorKey?: (apiKeyEnv: string) => Promise<void>;
5272 onDelete?: (providers: ProviderView[]) => Promise<void>;
5273 }) {
5274 const t = useT();
5275 const editableProvider = group.providers[0];
5276 const isOpenCodeGoConnection = group.id === "custom:opencode-go";
5277 const [editorRevision, setEditorRevision] = useState(0);
5278 const editingProvider = group.providers.find((p) => editing === p.name) ?? (detail && !isOpenCodeGoConnection ? editableProvider : undefined);
5279 const upgradeProvider = group.providers.find((p) => p.recommendedUpgradeAvailable);
5280 const primaryProviderExpanded = Boolean(editableProvider && editing === editableProvider.name);
5281 const supportsServerWebSearch = isOpenCodeGoConnection
5282 || (group.providers.length > 0 && group.providers.every(providerSupportsServerWebSearchForView));
5283 const webSearchEnabled = supportsServerWebSearch && (isOpenCodeGoConnection
5284 ? group.providers.some((provider) => provider.name.startsWith("opencode-go-deepseek-") && Boolean(provider.webSearch))
5285 : group.providers.every((provider) => Boolean(provider.webSearch)));
5286 const visibleModels = group.models.slice(0, 6);
5287 const hiddenModelCount = Math.max(0, group.models.length - visibleModels.length);
5288 const providerProfiles = (
5289 <div className="provider-profiles">
5290 {group.providers.map((p) => {
5291 const profileExpanded = editing === p.name;
5292 return (
5293 <div className="provider-profile-row" key={p.name}>
5294 <span>{p.name}</span>
5295 <span>{p.models.join(", ") || t("common.none")}</span>
5296 <button
5297 className="btn btn--small provider-profile-row__refresh"
5298 disabled={busy || fetching || !p.baseUrl || !providerIsConfigured(p)}
5299 onClick={() => onRefresh(p)}
5300 >
5301 {fetching ? t("settings.fetchingModels") : t("settings.fetchModels")}
5302 </button>
5303 <button
5304 className="btn btn--small provider-profile-row__configure"
5305 disabled={busy}
5306 aria-expanded={profileExpanded}
5307 onClick={() => profileExpanded ? onCancelEdit() : onEdit(p.name)}
5308 >
5309 {profileExpanded ? t("common.collapse") : t("settings.configureProfile")}
5310 </button>
5311 </div>
5312 );
5313 })}
5314 </div>
5315 );
5316 return (
5317 <article className={`provider-access-card${detail ? " provider-access-card--detail" : ""}${group.builtIn ? " provider-access-card--builtin" : ""}`}>
5318 <div className="provider-access-card__head">
5319 <div className="provider-access-card__identity">
5320 <div className="provider-access-card__title">
5321 {onRename ? <ConnectionTitle label={group.label} busy={busy} onSave={onRename} /> : group.label}
5322 {!detail && <span className={`badge ${group.builtIn ? "badge--project" : "badge--neutral"}`}>
5323 {group.builtIn ? t("settings.builtinProviderBadge") : t("settings.customProviderBadge")}
5324 </span>}
5325 <span className={`badge ${group.keySet ? "badge--project" : "badge--feedback"}`}>
5326 {providerKeyStatusLabel(group, t)}
5327 </span>
5328 </div>
5329 </div>
5330 <div className="provider-access-card__actions">
5331 {onCopy && <button type="button" className="btn provider-icon-action" disabled={busy} onClick={onCopy} title={t("settings.connections.copy")} aria-label={t("settings.connections.copy")}><Files size={17} /></button>}
5332 {editableProvider && !isOpenCodeGoConnection && !detail && (
5333 <button
5334 className="btn btn--small"
5335 disabled={busy}
5336 aria-expanded={primaryProviderExpanded}
5337 onClick={() => primaryProviderExpanded ? onCancelEdit() : onEdit(editableProvider.name)}
5338 >
5339 {primaryProviderExpanded ? t("common.collapse") : t("settings.configureProvider")}
5340 </button>
5341 )}
5342 {editableProvider && group.providers.length === 1 && !detail && (
5343 <button
5344 className="btn btn--small"
5345 disabled={busy || fetching || !editableProvider.baseUrl || !group.configured}
5346 onClick={() => onRefresh(editableProvider)}
5347 >
5348 {fetching ? t("settings.fetchingModels") : t("settings.fetchModels")}
5349 </button>
5350 )}
5351 {editableProvider && onDelete && (
5352 <ProviderAccessMoreMenu
5353 busy={busy}
5354 builtIn={group.builtIn}
5355 onRemove={() => onDelete(group.providers)}
5356 />
5357 )}
5358 </div>
5359 </div>
5360 {group.description && !editingProvider && <div className="provider-access-card__desc">{group.description}</div>}
5361
5362 {upgradeProvider && (
5363 <div className="provider-protocol-upgrade">
5364 <div className="provider-protocol-upgrade__copy">
5365 <div className="provider-protocol-upgrade__title">
5366 {t("settings.providerProtocol")}: OpenAI Chat Completions
5367 </div>
5368 <div className="provider-protocol-upgrade__desc">{t("settings.addProvider.official.deepseekDesc")}</div>
5369 </div>
5370 <div className="provider-protocol-upgrade__actions">
5371 <InlineConfirmButton
5372 label={<>{t("settings.upgradeRecommendedProtocol")}<ArrowRight size={13} aria-hidden="true" /></>}
5373 confirmLabel={t("common.confirm")}
5374 cancelLabel={t("common.cancel")}
5375 disabled={busy}
5376 primary
5377 onConfirm={() => onUpgradeRecommended(canonicalOfficialProviderName(upgradeProvider.name))}
5378 />
5379 </div>
5380 </div>
5381 )}
5382
5383 {!supportsServerWebSearch && !(detail && editingProvider) && (
5384 <ProviderModelSummary
5385 configured={group.configured}
5386 models={visibleModels}
5387 hiddenModelCount={hiddenModelCount}
5388 />
5389 )}
5390
5391 {!group.configured && group.requiresKey && (
5392 <div className="provider-card-status provider-card-status--warn">
5393 {t("settings.modelsRequireKey")}
5394 </div>
5395 )}
5396 {fetchResult && (
5397 <div className={`provider-card-status provider-card-status--${fetchResult.kind}`}>
5398 {fetchResult.text}
5399 </div>
5400 )}
5401
5402 {modelDraft && (
5403 <ProviderModelDraftPicker
5404 draft={modelDraft}
5405 busy={busy}
5406 fetching={fetching}
5407 onToggle={onToggleDraftModel}
5408 onSelectAll={onSelectAllDraftModels}
5409 onClear={onClearDraftModels}
5410 onCancel={onCancelDraftModels}
5411 onSave={onSaveDraftModels}
5412 />
5413 )}
5414
5415 {editableProvider && !(detail && editingProvider) && (
5416 <ProviderServiceCapabilities
5417 supported={supportsServerWebSearch}
5418 configured={group.configured}
5419 models={visibleModels}
5420 hiddenModelCount={hiddenModelCount}
5421 showModelSummary
5422 enabled={webSearchEnabled}
5423 disabled={busy}
5424 onChange={onToggleWebSearch}
5425 />
5426 )}
5427
5428 {!detail && <ProviderTechnicalDetails group={group} />}
5429
5430 {group.providers.length > 1 && (isOpenCodeGoConnection ? (
5431 <details className="provider-route-settings">
5432 <summary>{t("settings.addProvider.manageRoutes")}</summary>
5433 {providerProfiles}
5434 </details>
5435 ) : providerProfiles)}
5436
5437 {editingProvider && (
5438 <ProviderEditor
5439 key={`${editingProvider.name}:${editorRevision}`}
5440 initial={editingProvider}
5441 hideConnectionName={detail}
5442 providerPresets={providerPresets}
5443 kinds={kinds}
5444 busy={busy}
5445 onCancel={() => { onCancelEdit(); setEditorRevision((n) => n + 1); }}
5446 onSave={onSave}
5447 onSaveKey={onSaveEditorKey}
5448 onClearKey={onClearEditorKey}
5449 />
5450 )}
5451 </article>
5452 );
5453 }
5454
5455 function ProviderModelSummary({
5456 configured,
5457 models,
5458 hiddenModelCount,
5459 compact = false,
5460 }: {
5461 configured: boolean;
5462 models: string[];
5463 hiddenModelCount: number;
5464 compact?: boolean;
5465 }) {
5466 const t = useT();
5467 const label = t(configured ? "settings.enabledModels" : "settings.modelList");
5468 return (
5469 <div className={`provider-card-block${compact ? " provider-card-block--inline" : ""}`}>
5470 <div className="provider-card-block__label">{label}</div>
5471 <div className="provider-model-chips" aria-label={label}>
5472 {models.length > 0 ? models.map((model) => (
5473 <span className="provider-model-chip" key={model}>
5474 {model}
5475 </span>
5476 )) : <span className="provider-model-chip provider-model-chip--empty">{t("settings.noModelsConfigured")}</span>}
5477 {hiddenModelCount > 0 && (
5478 <span className="provider-model-chip provider-model-chip--more">
5479 {t("settings.moreModels", { n: hiddenModelCount })}
5480 </span>
5481 )}
5482 </div>
5483 </div>
5484 );
5485 }
5486
5487 function ProviderTechnicalDetails({ group }: { group: ProviderAccessGroup }) {
5488 const t = useT();
5489 const imageInputUnsupported = group.providers.length > 0 && group.providers.every((provider) => providerVisionCapabilityForView(provider) === "unsupported");
5490 return (
5491 <details className="provider-technical-details">
5492 <summary>{t("settings.providerAccess")}</summary>
5493 <dl>
5494 {group.providers.length === 1 ? (
5495 <>
5496 <div><dt>{t("settings.providerProtocol")}</dt><dd>{providerProtocolDisplayName(group.kind)}</dd></div>
5497 <div><dt>{t("settings.providerBaseUrlLabel")}</dt><dd>{group.baseUrl || t("common.none")}</dd></div>
5498 </>
5499 ) : group.providers.map((provider) => (
5500 <div key={provider.name}><dt>{provider.name}</dt><dd>{providerProtocolDisplayName(provider.kind)} · {provider.baseUrl || t("common.none")}</dd></div>
5501 ))}
5502 <div>
5503 <dt>{t("settings.providerApiKeyEnv")}</dt>
5504 <dd>{group.apiKeyEnv || t("common.none")}</dd>
5505 </div>
5506 {imageInputUnsupported && (
5507 <div>
5508 <dt>{t("settings.visionModel")}</dt>
5509 <dd>{t("settings.imageInputUnsupported")}</dd>
5510 </div>
5511 )}
5512 {group.keySource && (
5513 <div>
5514 <dt>{t("settings.providerKey")}</dt>
5515 <dd title={group.keySourcePath || undefined}>{group.keySource}</dd>
5516 </div>
5517 )}
5518 </dl>
5519 </details>
5520 );
5521 }
5522
5523 function providerProtocolDisplayName(kind: string): string {
5524 return providerProtocolLabel(kind);
5525 }
5526
5527 function ProviderAccessMoreMenu({
5528 busy,
5529 builtIn,
5530 onRemove,
5531 }: {
5532 busy: boolean;
5533 builtIn: boolean;
5534 onRemove: () => void | Promise<void>;
5535 }) {
5536 const t = useT();
5537 const [open, setOpen] = useState(false);
5538 const triggerRef = useRef<HTMLButtonElement>(null);
5539 const disabled = busy;
5540 const tooltip = t("settings.themeGallery.moreActions");
5541
5542 return (
5543 <div className="provider-access-more">
5544 <Tooltip label={tooltip}>
5545 <button
5546 ref={triggerRef}
5547 type="button"
5548 className="btn btn--small provider-access-more__trigger"
5549 aria-label={t("settings.themeGallery.moreActions")}
5550 aria-haspopup="menu"
5551 aria-expanded={open}
5552 disabled={disabled}
5553 onClick={() => setOpen((current) => !current)}
5554 >
5555 <MoreHorizontal size={16} aria-hidden="true" />
5556 </button>
5557 </Tooltip>
5558 <AnchoredPopover
5559 open={open && !disabled}
5560 anchorRef={triggerRef}
5561 onClose={() => setOpen(false)}
5562 className="provider-access-more__menu"
5563 align="end"
5564 placement="bottom"
5565 >
5566 <div className="provider-access-more__items" role="menu" aria-label={t("settings.themeGallery.moreActions")}>
5567 <InlineConfirmButton
5568 label={<><Trash2 size={14} aria-hidden="true" />{t("settings.removeProviderAccess")}</>}
5569 confirmLabel={builtIn ? t("settings.confirmRemoveProviderAccess") : t("settings.confirmDeleteProvider")}
5570 cancelLabel={t("common.cancel")}
5571 danger={!builtIn}
5572 buttonRole="menuitem"
5573 onConfirm={async () => {
5574 setOpen(false);
5575 await onRemove();
5576 }}
5577 />
5578 </div>
5579 </AnchoredPopover>
5580 </div>
5581 );
5582 }
5583
5584 function ProviderModelDraftPicker({
5585 draft,
5586 busy,
5587 fetching,
5588 onToggle,
5589 onSelectAll,
5590 onClear,
5591 onCancel,
5592 onSave,
5593 }: {
5594 draft: ProviderModelDraft;
5595 busy: boolean;
5596 fetching: boolean;
5597 onToggle: (model: string) => void;
5598 onSelectAll: () => void;
5599 onClear: () => void;
5600 onCancel: () => void;
5601 onSave: () => void;
5602 }) {
5603 const t = useT();
5604 const [query, setQuery] = useState("");
5605 const [debouncedQuery, setDebouncedQuery] = useState("");
5606 // Debounce search to avoid expensive filtering on every keystroke
5607 useEffect(() => {
5608 const timer = setTimeout(() => setDebouncedQuery(query), 150);
5609 return () => clearTimeout(timer);
5610 }, [query]);
5611 const selected = new Set(draft.selected);
5612 const q = debouncedQuery.trim().toLowerCase();
5613 const visibleCandidates = useMemo(
5614 () => (q ? draft.candidates.filter((model) => model.toLowerCase().includes(q)) : draft.candidates),
5615 [draft.candidates, q],
5616 );
5617 const deferredCandidates = useDeferredValue(visibleCandidates);
5618 const disabled = busy || fetching;
5619
5620 return (
5621 <div className="provider-model-draft">
5622 <div className="provider-model-draft__head">
5623 <div>
5624 <span>{t("settings.modelCandidatesSelected", { n: draft.selected.length })}</span>
5625 </div>
5626 <div className="provider-model-draft__tools">
5627 <button type="button" className="btn btn--small" disabled={disabled || draft.selected.length === draft.candidates.length} onClick={onSelectAll} title={t("settings.selectAllModels")} aria-label={t("settings.selectAllModels")}>
5628 <ListChecks size={17} />
5629 </button>
5630 <button type="button" className="btn btn--small" disabled={disabled || draft.selected.length === 0} onClick={onClear} title={t("settings.clearModelSelection")} aria-label={t("settings.clearModelSelection")}>
5631 <Check size={17} style={{ opacity: .4 }} />
5632 </button>
5633 </div>
5634 </div>
5635 <input
5636 className="mem-input provider-model-draft__search"
5637 placeholder={t("settings.modelCandidateSearch")}
5638 value={query}
5639 disabled={disabled}
5640 onChange={(e) => setQuery(e.target.value)}
5641 />
5642 <div className="provider-model-draft__list" role="list" aria-label={t("settings.modelList")}>
5643 {deferredCandidates.length > 0 ? deferredCandidates.map((model) => {
5644 const enabled = selected.has(model);
5645 const capability = providerModelVisionCapability(
5646 { visionModelsConfigured: draft.visionModelsConfigured, modelCapabilities: draft.modelCapabilities },
5647 model,
5648 draft.visionModels,
5649 );
5650 return (
5651 <div className="provider-model-draft__option" key={model} role="listitem">
5652 <label className="provider-model-draft__model">
5653 <input
5654 type="checkbox"
5655 checked={enabled}
5656 disabled={disabled}
5657 onChange={() => onToggle(model)}
5658 />
5659 <span>{model}</span>
5660 </label>
5661 <div className="provider-model-draft__capabilities" aria-label={t("settings.modelCapabilitiesAria", { model })}>
5662 <span>{t("settings.textInput")}</span>
5663 <span>{capability === "supported"
5664 ? t("settings.visionModel")
5665 : capability === "unsupported"
5666 ? t("settings.imageInputUnsupported")
5667 : t("settings.imageInputUnknown")}</span>
5668 </div>
5669 </div>
5670 );
5671 }) : (
5672 <div className="provider-model-draft__empty">{t("settings.noMatchingCandidateModels")}</div>
5673 )}
5674 </div>
5675 <div className="provider-model-draft__actions">
5676 <button type="button" className="btn btn--small" disabled={disabled} onClick={onCancel}>
5677 {t("common.cancel")}
5678 </button>
5679 <button type="button" className="btn btn--primary btn--small" disabled={disabled || draft.selected.length === 0} onClick={onSave}>
5680 {t("settings.saveEnabledModels")}
5681 </button>
5682 </div>
5683 </div>
5684 );
5685 }
5686
5687 function ProviderServiceCapabilities({
5688 supported,
5689 configured,
5690 models,
5691 hiddenModelCount,
5692 showModelSummary = false,
5693 enabled,
5694 disabled,
5695 onChange,
5696 }: {
5697 supported: boolean;
5698 configured?: boolean;
5699 models: string[];
5700 hiddenModelCount?: number;
5701 showModelSummary?: boolean;
5702 enabled: boolean;
5703 disabled: boolean;
5704 onChange: (enabled: boolean) => void;
5705 }) {
5706 const t = useT();
5707 const capabilityID = useId();
5708 if (!supported) return null;
5709 return (
5710 <section className="provider-capabilities" aria-labelledby={capabilityID}>
5711 <div className="provider-card-block__label" id={capabilityID}>
5712 {t("settings.providerCapabilities")}
5713 </div>
5714 {showModelSummary && (
5715 <ProviderModelSummary
5716 configured={Boolean(configured)}
5717 models={models}
5718 hiddenModelCount={hiddenModelCount ?? 0}
5719 compact
5720 />
5721 )}
5722 <label className="provider-capability-row">
5723 <span className="provider-capability-row__copy">
5724 <span className="provider-capability-row__title">
5725 {t("settings.serverWebSearch")}
5726 <span className="badge badge--project">{t("settings.recommended")}</span>
5727 </span>
5728 <span>{t("settings.serverWebSearchHint")}</span>
5729 </span>
5730 <input
5731 className="provider-capability-row__switch"
5732 type="checkbox"
5733 role="switch"
5734 checked={enabled}
5735 disabled={disabled}
5736 onChange={(event) => onChange(event.target.checked)}
5737 />
5738 </label>
5739 </section>
5740 );
5741 }
5742
5743 export function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof useT>): ProviderAccessGroup[] {
5744 const groups = new Map<string, ProviderAccessGroup>();
5745 for (const p of providers) {
5746 const id = providerGroupID(p);
5747 const builtIn = id.startsWith("builtin:");
5748 const existing = groups.get(id);
5749 if (existing) {
5750 existing.providers.push(p);
5751 existing.keySet = existing.keySet || p.keySet;
5752 existing.requiresKey = existing.requiresKey && providerRequiresKey(p);
5753 existing.configured = existing.configured || providerIsConfigured(p);
5754 existing.recommendedUpgradeAvailable = existing.recommendedUpgradeAvailable || Boolean(p.recommendedUpgradeAvailable);
5755 if (existing.recommendedUpgradeAvailable && existing.id === "builtin:deepseek") {
5756 existing.description = "";
5757 }
5758 if (!existing.keySource && p.keySource) existing.keySource = p.keySource;
5759 if (!existing.keySourcePath && p.keySourcePath) existing.keySourcePath = p.keySourcePath;
5760 existing.models = uniqueStrings([...existing.models, ...p.models]);
5761 continue;
5762 }
5763 groups.set(id, {
5764 id,
5765 label: providerGroupLabel(p, t),
5766 description: providerGroupDescription(p, t),
5767 builtIn,
5768 providers: [p],
5769 apiKeyEnv: p.apiKeyEnv,
5770 keySet: p.keySet,
5771 requiresKey: providerRequiresKey(p),
5772 configured: providerIsConfigured(p),
5773 keySource: p.keySource,
5774 keySourcePath: p.keySourcePath,
5775 baseUrl: p.baseUrl,
5776 kind: p.kind,
5777 models: uniqueStrings(p.models),
5778 recommendedUpgradeAvailable: Boolean(p.recommendedUpgradeAvailable),
5779 });
5780 }
5781 return Array.from(groups.values());
5782 }
5783
5784 function providerBaseHost(baseUrl: string): string {
5785 try {
5786 return new URL(baseUrl).hostname.toLowerCase();
5787 } catch {
5788 return "";
5789 }
5790 }
5791
5792 type ProviderVisionCapability = "configurable" | "unsupported";
5793
5794
5795
5796 export function providerSupportsServerWebSearchForView(
5797 provider: Pick<ProviderView, "kind" | "baseUrl" | "serverWebSearchCapability">,
5798 ): boolean {
5799 if (typeof provider.serverWebSearchCapability === "boolean") {
5800 return provider.serverWebSearchCapability;
5801 }
5802 return providerSupportsServerWebSearch(provider.kind, provider.baseUrl);
5803 }
5804
5805 export function providerVisionCapabilityForView(
5806 provider: Pick<ProviderView, "kind" | "baseUrl" | "visionCapability">,
5807 ): ProviderVisionCapability {
5808 if (provider.visionCapability === "unsupported") {
5809 return "unsupported";
5810 }
5811 return "configurable";
5812 }
5813
5814 function canonicalOfficialProviderName(name: string): string {
5815 switch (name.trim()) {
5816 case "deepseek-flash":
5817 case "deepseek-pro":
5818 return "deepseek";
5819 default:
5820 return name.trim();
5821 }
5822 }
5823
5824 function officialProviderKind(p: ProviderView): string {
5825 if (!p.builtIn) return "";
5826 const name = canonicalOfficialProviderName(p.name);
5827 const host = providerBaseHost(p.baseUrl);
5828 if (name === "deepseek" && host === "api.deepseek.com") return "deepseek";
5829 return "";
5830 }
5831
5832 function providerGroupID(p: ProviderView): string {
5833 const official = officialProviderKind(p);
5834 if (official) return `builtin:${official}`;
5835 if (isOpenCodeGoProviderName(p.name)) return "custom:opencode-go";
5836 if (p.name === "opencode-zen-anthropic") return "custom:opencode-zen";
5837 return `custom:${p.name}`;
5838 }
5839
5840 function providerGroupLabel(p: ProviderView, t?: ReturnType<typeof useT>): string {
5841 if (p.displayName?.trim()) return p.displayName.trim();
5842 const id = providerGroupID(p);
5843 if (id === "builtin:deepseek") return t ? t("settings.providerLabel.deepseek") : "DeepSeek";
5844 if (id === "custom:opencode-go") return t ? t("settings.providerLabel.opencodeGo") : "OpenCode Go";
5845 if (id === "custom:opencode-zen") return t ? t("settings.providerLabel.opencodeZen") : "OpenCode Zen";
5846 return p.name;
5847 }
5848
5849 function providerGroupDescription(p: ProviderView, t: ReturnType<typeof useT>): string {
5850 const id = providerGroupID(p);
5851 if (id === "builtin:deepseek") {
5852 return p.recommendedUpgradeAvailable ? "" : providerProtocolLabel(p.kind);
5853 }
5854 if (id === "custom:opencode-go") return t("settings.providerDesc.opencodeGo");
5855 if (id === "custom:opencode-zen") return t("settings.providerDesc.opencodeZen");
5856 return "";
5857 }
5858
5859 function isOpenCodeGoProviderName(name: string): boolean {
5860 switch (name.trim()) {
5861 case "opencode-go":
5862 case "opencode-go-anthropic":
5863 case "opencode-go-responses":
5864 case "opencode-go-deepseek-anthropic":
5865 case "opencode-go-deepseek-responses":
5866 return true;
5867 default:
5868 return false;
5869 }
5870 }
5871
5872 function uniqueStrings(values: string[]): string[] {
5873 const seen = new Set<string>();
5874 const out: string[] = [];
5875 for (const value of values) {
5876 if (value && !seen.has(value)) {
5877 seen.add(value);
5878 out.push(value);
5879 }
5880 }
5881 return out;
5882 }
5883
5884 function parseProviderListInput(value: string): string[] {
5885 return uniqueStrings(value
5886 .split(/[,,]/)
5887 .map((entry) => entry.trim())
5888 .filter(Boolean));
5889 }
5890
5891 function botAllowlistTextValues(allowlist: BotAllowlistView): Record<BotAllowlistTextKey, string> {
5892 return {
5893 qqUsers: allowlist.qqUsers.join("\n"),
5894 feishuUsers: allowlist.feishuUsers.join("\n"),
5895 weixinUsers: allowlist.weixinUsers.join("\n"),
5896 qqApprovers: allowlist.qqApprovers.join("\n"),
5897 feishuApprovers: allowlist.feishuApprovers.join("\n"),
5898 weixinApprovers: allowlist.weixinApprovers.join("\n"),
5899 qqAdmins: allowlist.qqAdmins.join("\n"),
5900 feishuAdmins: allowlist.feishuAdmins.join("\n"),
5901 weixinAdmins: allowlist.weixinAdmins.join("\n"),
5902 qqGroups: allowlist.qqGroups.join("\n"),
5903 feishuGroups: allowlist.feishuGroups.join("\n"),
5904 weixinGroups: allowlist.weixinGroups.join("\n"),
5905 dingtalkUsers: allowlist.dingtalkUsers.join("\n"),
5906 dingtalkApprovers: allowlist.dingtalkApprovers.join("\n"),
5907 dingtalkAdmins: allowlist.dingtalkAdmins.join("\n"),
5908 dingtalkGroups: allowlist.dingtalkGroups.join("\n"),
5909 };
5910 }
5911
5912 function botSelfUserTextValues(selfUserIds: BotSettingsView["selfUserIds"]): Record<BotSelfUserTextKey, string> {
5913 return {
5914 qq: selfUserIds.qq.join("\n"),
5915 feishu: selfUserIds.feishu.join("\n"),
5916 weixin: selfUserIds.weixin.join("\n"),
5917 dingtalk: selfUserIds.dingtalk.join("\n"),
5918 };
5919 }
5920
5921 function parseBotListInput(value: string): string[] {
5922 return uniqueStrings(value
5923 .split(/[\n,,]+/)
5924 .map((entry) => entry.trim())
5925 .filter(Boolean));
5926 }
5927
5928 const ProviderModelDialog = lazy(() => import("./ProviderModelDialog"));
5929
5930 export const ProviderEditorModelPicker = memo(function ProviderEditorModelPicker({
5931 actions,
5932 candidates,
5933 selectedModels,
5934 visionModels,
5935 visionModelsConfigured,
5936 modelCapabilities,
5937 contextWindows,
5938 inheritedContextWindow,
5939 disabled,
5940 onToggleModel,
5941 onEditModel,
5942 onSelectAll,
5943 onClear,
5944 }: {
5945 actions?: ReactNode;
5946 candidates: string[];
5947 selectedModels: string[];
5948 visionModels: string[];
5949 visionModelsConfigured: boolean;
5950 modelCapabilities: ProviderModelCapabilityView[];
5951 contextWindows: Record<string, string>;
5952 inheritedContextWindow?: number;
5953 disabled: boolean;
5954 onToggleModel: (model: string) => void;
5955 onEditModel?: (model: string) => void;
5956 onSelectAll: () => void;
5957 onClear: () => void;
5958 }) {
5959 const t = useT();
5960 const [query, setQuery] = useState("");
5961 const [debouncedQuery, setDebouncedQuery] = useState("");
5962 useEffect(() => {
5963 const timer = setTimeout(() => setDebouncedQuery(query), 150);
5964 return () => clearTimeout(timer);
5965 }, [query]);
5966 const q = debouncedQuery.trim().toLowerCase();
5967 const visibleCandidates = q
5968 ? candidates.filter((model) => model.toLowerCase().includes(q))
5969 : candidates;
5970 const deferredCandidates = useDeferredValue(visibleCandidates);
5971 const selected = new Set(selectedModels);
5972 return (
5973 <div className="provider-model-draft provider-model-draft--inline">
5974 <div className="provider-model-draft__head provider-model-toolbar">
5975 <strong>{t("settings.modelList")}</strong>
5976 <span>{t("settings.modelCandidatesSelected", { n: selectedModels.length })}</span>
5977 <div className="provider-model-draft__tools">
5978 {actions}
5979 <details className="provider-key-compact__more">
5980 <summary className="btn provider-icon-action" title={t("settings.themeGallery.moreActions")} aria-label={t("settings.themeGallery.moreActions")}><MoreHorizontal size={17} /></summary>
5981 <div className="provider-key-compact__menu">
5982 <button type="button" className="btn" disabled={disabled || selectedModels.length === candidates.length} onClick={onSelectAll}>{t("settings.selectAllModels")}</button>
5983 <button type="button" className="btn" disabled={disabled || selectedModels.length === 0} onClick={onClear}>{t("settings.clearModelSelection")}</button>
5984 </div>
5985 </details>
5986 </div>
5987 </div>
5988
5989 {candidates.length > 8 && (
5990 <input
5991 className="mem-input provider-model-draft__search"
5992 placeholder={t("settings.modelCandidateSearch")}
5993 value={query}
5994 disabled={disabled}
5995 onChange={(e) => setQuery(e.target.value)}
5996 />
5997 )}
5998 <div className="provider-model-draft__list" role="list" aria-label={t("settings.modelList")}>
5999 {deferredCandidates.length > 0 ? deferredCandidates.map((model) => {
6000 const enabled = selected.has(model);
6001 const capability = providerModelVisionCapability(
6002 { visionModelsConfigured, modelCapabilities },
6003 model,
6004 visionModels,
6005 );
6006 return (
6007 <div className="provider-model-draft__option" key={model} role="listitem">
6008 <label className="provider-model-draft__model">
6009 <input
6010 type="checkbox"
6011 checked={enabled}
6012 disabled={disabled}
6013 onChange={() => onToggleModel(model)}
6014 />
6015 <span>{model}</span>
6016 </label>
6017 <div className="provider-model-draft__capabilities" aria-label={t("settings.modelCapabilitiesAria", { model })}>
6018 {capability === "supported" && <span>{t("settings.visionModel")}</span>}
6019 {capability === "unknown" && <span>{t("settings.imageInputUnknown")}</span>}
6020 </div>
6021 <span className="provider-context-badge">{Number(contextWindows[model]) || inheritedContextWindow ? new Intl.NumberFormat("en", {notation:"compact",maximumFractionDigits:1}).format(Number(contextWindows[model]) || inheritedContextWindow!) : t("settings.models.inherit")}</span>
6022 <button type="button" className="btn provider-icon-action" title={t("settings.models.options")} aria-label={`${t("settings.models.options")}: ${model}`} aria-haspopup="dialog" disabled={disabled || !onEditModel} onClick={()=>onEditModel?.(model)}><SlidersHorizontal size={17}/></button>
6023 </div>
6024 );
6025 }) : (
6026 <div className="provider-model-draft__empty">{t("settings.noMatchingCandidateModels")}</div>
6027 )}
6028 </div>
6029 </div>
6030 );
6031 });
6032
6033 export function ProviderEditor({
6034 hideConnectionName = false,
6035 initial,
6036 providerPresets = [],
6037 kinds,
6038 busy,
6039 onCancel,
6040 onSave,
6041 }: {
6042 hideConnectionName?: boolean;
6043 initial?: ProviderView;
6044 providerPresets?: ProviderPresetView[];
6045 kinds: string[];
6046 busy: boolean;
6047 onCancel: () => void;
6048 onSave: (p: ProviderView, key?: string) => void | Promise<void>;
6049 onSaveKey?: (apiKeyEnv: string, value: string) => Promise<void>;
6050 onClearKey?: (apiKeyEnv: string) => Promise<void>;
6051 }) {
6052 const t = useT();
6053 const [name, setName] = useState(initial?.name ?? "");
6054 const [displayName, setDisplayName] = useState(initial?.displayName ?? "");
6055 const [kind, setKind] = useState(initial?.kind ?? "openai");
6056 const [requestUrl, setRequestUrl] = useState(() => providerRequestURLFromConfig(
6057 initial?.kind ?? "openai",
6058 initial?.baseUrl ?? "",
6059 initial?.requestUrl ?? "",
6060 initial?.chatUrl ?? "",
6061 ));
6062 const providerNameInputId = useId();
6063 const providerUrlInputId = useId();
6064 const providerUrlHelpId = useId();
6065 const [models, setModels] = useState((initial?.models ?? []).join(", "));
6066 const [modelDialog, setModelDialog] = useState<string | null>(null);
6067 const [modelOverrides, setModelOverrides] = useState(initial?.modelOverrides ?? []);
6068 const [showKey, setShowKey] = useState(false);
6069 const [modelCandidates, setModelCandidates] = useState<string[]>(initial?.models ?? []);
6070 const [legacyVisionModels, setLegacyVisionModels] = useState(initial?.visionModels ?? []);
6071 const [modelCapabilities, setModelCapabilities] = useState<ProviderModelCapabilityView[]>(initial?.modelCapabilities ?? []);
6072 const visionModelsConfigured = Boolean(initial?.visionModelsConfigured ?? legacyVisionModels.length > 0);
6073 const [modelsUrl, setModelsUrl] = useState(initial?.modelsUrl ?? "");
6074 const [apiKeyEnv, setApiKeyEnv] = useState(initial?.apiKeyEnv ?? "");
6075 useEffect(() => { if (initial) setApiKeyEnv(initial.apiKeyEnv); }, [initial?.apiKeyEnv]);
6076 const [headersDraft, setHeadersDraft] = useState(formatProviderHeaders(initial?.headers));
6077 const [extraBodyDraft, setExtraBodyDraft] = useState(formatProviderExtraBody(initial?.extraBody));
6078 const [authHeader, setAuthHeader] = useState(Boolean(initial?.authHeader));
6079 const [noProxy, setNoProxy] = useState(Boolean(initial?.noProxy));
6080 const [keyDraft, setKeyDraft] = useState("");
6081 const [balanceUrl, setBalanceUrl] = useState(initial?.balanceUrl ?? "");
6082 // Empty when unset so the placeholder (and its "0 = disabled" hint) reads instead
6083 // of a bare "0"; saved back as 0.
6084 const [ctx, setCtx] = useState(initial?.contextWindow ? String(initial.contextWindow) : "");
6085 const [modelContextWindows, setModelContextWindows] = useState<Record<string, string>>(
6086 () => providerModelContextWindowDrafts(initial?.modelOverrides),
6087 );
6088 const [reasoningProtocol, setReasoningProtocol] = useState(normalizeReasoningProtocol(initial?.reasoningProtocol));
6089 const [thinking, setThinking] = useState(normalizeThinkingMode(initial?.thinking));
6090 const [webSearch, setWebSearch] = useState(Boolean(initial?.webSearch));
6091 const [supportedEfforts] = useState<string[]>(initial?.supportedEfforts ?? []);
6092 const [defaultEffort] = useState(initial?.defaultEffort ?? "");
6093 const [fetchingModels, setFetchingModels] = useState(false);
6094 const [fetchStatus, setFetchStatus] = useState<string | null>(null);
6095 const [fetchFallback, setFetchFallback] = useState<string | null>(null);
6096 const [advancedOpen, setAdvancedOpen] = useState(false);
6097 const draftSnapshot = JSON.stringify([name, hideConnectionName ? "" : displayName, kind, requestUrl, models, modelsUrl, headersDraft, extraBodyDraft, authHeader, noProxy, keyDraft, balanceUrl, ctx, modelContextWindows, modelOverrides, modelCapabilities, legacyVisionModels, reasoningProtocol, thinking, webSearch]);
6098 const [savedSnapshot, setSavedSnapshot] = useState(draftSnapshot);
6099 const latestDraftSnapshot = useRef(draftSnapshot);
6100 const saveGeneration = useRef(0);
6101 latestDraftSnapshot.current = draftSnapshot;
6102 const dirty = draftSnapshot !== savedSnapshot;
6103 const isNewCustomProvider = !initial;
6104 const editorCatalog = useMemo(() => {
6105 if (initial?.catalog) return initial.catalog;
6106 if (initial?.presetId) {
6107 const preset = providerPresets.find(item => item.id === initial.presetId);
6108 if (preset) return catalogForPreset(preset);
6109 }
6110 const catalogs = providerPresets.map(catalogForPreset);
6111 return catalogs.find(catalog => Object.entries(catalog.protocols ?? {}).some(([routeKind, route]) =>
6112 providerRequestURLFromConfig(routeKind, route.baseUrl, "") === requestUrl,
6113 ));
6114 }, [initial?.catalog, initial?.presetId, providerPresets, requestUrl]);
6115 const providerKindChoices = useMemo(() => {
6116 const registered = editorCatalog ? Object.keys(editorCatalog.protocols ?? {}) : kinds;
6117 const choices = providerProtocolChoices(kind, initial?.kind, registered, Boolean(editorCatalog));
6118 return choices.length > 0 ? choices : ["openai"];
6119 }, [editorCatalog, kind, initial?.kind, kinds]);
6120 const effectiveKind = providerEditorEffectiveKind(isNewCustomProvider, kind, providerKindChoices);
6121 const effectiveRequestUrl = requestUrl.trim();
6122 const endpointMismatch = providerEndpointMismatchDetail(effectiveKind, effectiveRequestUrl, editorCatalog);
6123 const effectiveBaseUrl = providerBaseURLForSave(initial, effectiveKind, effectiveRequestUrl);
6124 const effectiveLegacyChatUrl = effectiveKind.toLowerCase() === "openai" ? effectiveRequestUrl : initial?.chatUrl ?? "";
6125 const effectiveModelsUrl = modelsUrl.trim();
6126 const initialEffectiveBaseUrl = initial ? trimmedBaseURL(initial.baseUrl) : "";
6127 const retainedServerWebSearchCapability = initial &&
6128 effectiveKind.trim().toLowerCase() === initial.kind.trim().toLowerCase() &&
6129 trimmedBaseURL(effectiveBaseUrl) === initialEffectiveBaseUrl
6130 ? initial.serverWebSearchCapability
6131 : undefined;
6132 const effectiveServerWebSearchCapability = retainedServerWebSearchCapability ??
6133 providerSupportsServerWebSearch(effectiveKind, effectiveBaseUrl);
6134 const effectiveHeaders = parseProviderHeaders(headersDraft);
6135 const extraBodyParse = useMemo(() => {
6136 try {
6137 return { value: parseProviderExtraBody(extraBodyDraft, t), error: "" };
6138 } catch (e) {
6139 return { value: {}, error: providerExtraBodyParseError(e, t) };
6140 }
6141 }, [extraBodyDraft, t]);
6142 const effectiveExtraBody = extraBodyParse.value;
6143 const extraBodyInvalid = Boolean(extraBodyDraft.trim() && extraBodyParse.error);
6144 const modelNames = useMemo(
6145 () => parseProviderListInput(models),
6146 [models],
6147 );
6148 const modelCandidateNames = useMemo(
6149 () => uniqueStrings([...modelCandidates, ...modelNames]),
6150 [modelCandidates, modelNames],
6151 );
6152 const visionModelNames = useMemo(
6153 () => providerVisionModelsForView({models:modelNames, visionModels:legacyVisionModels, modelOverrides, modelCapabilities}),
6154 [modelNames, legacyVisionModels, modelOverrides, modelCapabilities],
6155 );
6156
6157 // Empty supportedEfforts means "use protocol defaults". The simplified
6158 // provider flow no longer edits these levels directly, but it preserves
6159 // existing advanced TOML unless the user explicitly disables reasoning.
6160 const cleanedSupportedEfforts = reasoningProtocol !== "none"
6161 ? uniqueStrings(
6162 supportedEfforts
6163 .map((level) => level.toLowerCase().trim())
6164 .filter((level) => level && level !== "auto")
6165 )
6166 : [];
6167 const normalizedDefaultEffort = defaultEffort.toLowerCase().trim();
6168 const cleanDefaultEffort = cleanedSupportedEfforts.includes(normalizedDefaultEffort) ? normalizedDefaultEffort : "";
6169
6170 const fetchModels = async () => {
6171 if (extraBodyInvalid) return;
6172 setFetchingModels(true);
6173 setFetchStatus(null);
6174 setFetchFallback(null);
6175 try {
6176 const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
6177 if (!apiKeyEnv.trim()) setApiKeyEnv(effectiveApiKeyEnv);
6178 const fetchCatalog = (provider: ProviderView) => keyDraft.trim()
6179 ? app.FetchProviderModelCatalogDraft(provider, keyDraft.trim())
6180 : cachedFetchProviderModelCatalog(p => app.FetchProviderModelCatalog(p), provider, true);
6181 const fetchedCapabilities = await fetchCatalog({
6182 name: name.trim() || t("settings.newProviderDraftName"),
6183 builtIn: initial?.builtIn ?? false,
6184 added: initial?.added ?? true,
6185 kind: effectiveKind,
6186 baseUrl: effectiveBaseUrl,
6187 chatUrl: effectiveLegacyChatUrl,
6188 requestUrl: effectiveRequestUrl,
6189 modelsUrl: effectiveModelsUrl,
6190 models: [],
6191 visionModels: [],
6192 visionModelsConfigured: false,
6193 default: "",
6194 apiKeyEnv: effectiveApiKeyEnv,
6195 headers: effectiveHeaders,
6196 extraBody: effectiveExtraBody,
6197 authHeader,
6198 noProxy,
6199 keySet: Boolean(keyDraft.trim()) || (initial?.keySet ?? false),
6200 balanceUrl: balanceUrl.trim(),
6201 contextWindow: Number(ctx) || 0,
6202 reasoningProtocol,
6203 thinking,
6204 webSearch: effectiveServerWebSearchCapability && webSearch,
6205 serverWebSearchCapability: effectiveServerWebSearchCapability,
6206 supportedEfforts: cleanedSupportedEfforts,
6207 defaultEffort: cleanDefaultEffort,
6208 modelOverrides: mergeProviderModelContextWindows(modelOverrides, parseProviderListInput(models), modelContextWindows),
6209 });
6210 const fetched = fetchedCapabilities.map((item) => item.model);
6211 if (fetched.length === 0) {
6212 setFetchFallback(t("settings.fetchModelsManualFallbackEmpty"));
6213 return;
6214 }
6215 setModelCandidates(current => uniqueStrings([...current, ...fetched]));
6216 setModelCapabilities(current => [...current.filter(item => !fetched.includes(item.model)), ...fetchedCapabilities]);
6217 setFetchStatus(t("settings.fetchModelsSuccess", { n: fetched.length }));
6218 } catch (e) {
6219 setFetchFallback(providerModelFetchFallbackMessage(e, t));
6220 } finally {
6221 setFetchingModels(false);
6222 }
6223 };
6224
6225 const save = async () => {
6226 if (extraBodyInvalid) return;
6227 const generation = ++saveGeneration.current;
6228 setFetchStatus(null);
6229 setFetchFallback(null);
6230 const ms = parseProviderListInput(models);
6231 const vms = legacyVisionModels.filter((model) => ms.includes(model));
6232 const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
6233 const provider: ProviderView = {
6234 name: name.trim(),
6235 ...(hideConnectionName ? {} : { displayName: displayName.trim() }),
6236 ...(initial?.presetId ? { presetId: initial.presetId } : {}),
6237 ...(initial?.catalog ? { catalog: initial.catalog } : {}),
6238 builtIn: initial?.builtIn ?? false,
6239 added: initial?.added ?? true,
6240 kind: effectiveKind,
6241 baseUrl: effectiveBaseUrl,
6242 chatUrl: effectiveLegacyChatUrl,
6243 requestUrl: effectiveRequestUrl,
6244 models: ms,
6245 visionModels: vms,
6246 visionModelsConfigured,
6247 default: ms[0] ?? "",
6248 apiKeyEnv: effectiveApiKeyEnv,
6249 headers: effectiveHeaders,
6250 extraBody: effectiveExtraBody,
6251 authHeader,
6252 noProxy,
6253 modelsUrl: effectiveModelsUrl,
6254 keySet: Boolean(keyDraft.trim()) || (initial?.keySet ?? false),
6255 balanceUrl: balanceUrl.trim(),
6256 contextWindow: Number(ctx) || 0,
6257 reasoningProtocol,
6258 thinking,
6259 webSearch: effectiveServerWebSearchCapability && webSearch,
6260 serverWebSearchCapability: effectiveServerWebSearchCapability,
6261 supportedEfforts: cleanedSupportedEfforts,
6262 // Clear the stored default if no levels are selected; the backend's
6263 // NormalizeEffort would otherwise silently ignore an unsupported value.
6264 defaultEffort: cleanedSupportedEfforts.length > 0 ? cleanDefaultEffort : "",
6265 modelOverrides: mergeProviderModelContextWindows(modelOverrides, ms, modelContextWindows),
6266 modelCapabilities,
6267 };
6268 try {
6269 await onSave(provider, keyDraft.trim() || undefined);
6270 if (generation !== saveGeneration.current) return;
6271 setShowKey(false);
6272 if (latestDraftSnapshot.current === draftSnapshot) {
6273 const committed = JSON.parse(draftSnapshot) as unknown[];
6274 committed[10] = ""; // The key draft is cleared only if no newer edit exists.
6275 setKeyDraft("");
6276 setSavedSnapshot(JSON.stringify(committed));
6277 } else {
6278 setSavedSnapshot(draftSnapshot);
6279 }
6280 } catch (e) {
6281 if (generation === saveGeneration.current && latestDraftSnapshot.current === draftSnapshot) {
6282 setFetchFallback(String((e as Error)?.message ?? e));
6283 }
6284 }
6285 };
6286
6287 const canFetch = Boolean(name.trim() && effectiveBaseUrl);
6288
6289 const setModelsFromList = (nextModels: string[]) => {
6290 setModels(uniqueStrings(nextModels).join(", "));
6291 };
6292
6293 const toggleEditorModel = (model: string) => {
6294 const selected = new Set(modelNames);
6295 if (selected.has(model)) selected.delete(model);
6296 else selected.add(model);
6297 setModelsFromList(modelCandidateNames.filter((candidate) => selected.has(candidate)));
6298 };
6299
6300 const applyModelDetails = (draft: ModelDetailsDraft) => {
6301 if (modelDialog === "") {
6302 setModelCandidates(current => uniqueStrings([...current, draft.model]));
6303 setModels(current => uniqueStrings([...parseProviderListInput(current), draft.model]).join(", "));
6304 }
6305 setModelContextWindows(current => ({...current, [draft.model]:draft.contextWindow}));
6306 setModelOverrides(current => {
6307 const previous = current.find(item => item.model === draft.model);
6308 return [...current.filter(item => item.model !== draft.model), {...previous, model:draft.model, reasoningProtocol:previous?.reasoningProtocol ?? "", supportedEfforts:draft.supportedEfforts, defaultEffort:draft.defaultEffort, vision:draft.vision, maxOutputTokens:draft.maxOutputTokens}];
6309 });
6310 setModelDialog(null);
6311 };
6312
6313 const deleteModel = () => {
6314 if (!modelDialog || busy || fetchingModels) return;
6315 const model = modelDialog;
6316 setModels(current => parseProviderListInput(current).filter(item => item !== model).join(", "));
6317 setModelCandidates(current => current.filter(item => item !== model));
6318 setModelOverrides(current => current.filter(item => item.model !== model));
6319 setModelCapabilities(current => current.filter(item => item.model !== model));
6320 setLegacyVisionModels(current => current.filter(item => item !== model));
6321 setModelContextWindows(current => Object.fromEntries(Object.entries(current).filter(([key]) => key !== model)));
6322 setModelDialog(null);
6323 };
6324
6325 const selectAllEditorModels = () => {
6326 setModelsFromList(modelCandidateNames);
6327 };
6328
6329 const clearEditorModels = () => {
6330 setModels("");
6331 };
6332
6333 const advancedFields = (
6334 <details className="provider-editor-advanced" open={advancedOpen} onToggle={(e) => setAdvancedOpen(e.currentTarget.open)}>
6335 <summary>
6336 <span className="provider-editor-advanced__title">
6337 <ChevronDown className="provider-editor-advanced__icon" size={16} aria-hidden="true" />
6338 {t("settings.providerAdvancedSettings")}
6339 </span>
6340 <span className="provider-editor-advanced__hint">
6341 {advancedOpen ? t("settings.providerAdvancedCollapseHint") : t("settings.providerAdvancedExpandHint")}
6342 </span>
6343 </summary>
6344 <div className="provider-editor-advanced__body">
6345 <label className="set-label">{t("settings.providerModelsUrl")}</label>
6346 <input
6347 className="mem-input"
6348 placeholder={t("settings.providerModelsUrlPlaceholder")}
6349 value={modelsUrl}
6350 onChange={(e) => setModelsUrl(e.target.value)}
6351 />
6352 <div className="mem-hint">{t("settings.providerModelsUrlHint")}</div>
6353 <label className="set-label">{t("settings.providerHeaders")}</label>
6354 <textarea
6355 className="mem-textarea provider-headers-textarea"
6356 placeholder={t("settings.providerHeadersPlaceholder")}
6357 value={headersDraft}
6358 onChange={(e) => setHeadersDraft(e.target.value)}
6359 rows={3}
6360 />
6361 <div className="mem-hint">{t("settings.providerHeadersHint")}</div>
6362 <label className="set-label">{t("settings.providerExtraBody")}</label>
6363 <textarea
6364 className="mem-textarea provider-headers-textarea"
6365 placeholder={t("settings.providerExtraBodyPlaceholder")}
6366 value={extraBodyDraft}
6367 onChange={(e) => setExtraBodyDraft(e.target.value)}
6368 rows={4}
6369 />
6370 <div className={`mem-hint${extraBodyInvalid ? " mem-hint--error" : ""}`}>
6371 {extraBodyInvalid ? extraBodyParse.error : t("settings.providerExtraBodyHint")}
6372 </div>
6373 <label className="set-check">
6374 <input
6375 type="checkbox"
6376 checked={authHeader}
6377 onChange={(e) => setAuthHeader(e.target.checked)}
6378 />
6379 {t("settings.providerAuthHeader")}
6380 </label>
6381 <div className="mem-hint">{t("settings.providerAuthHeaderHint")}</div>
6382 <label className="set-check">
6383 <input
6384 type="checkbox"
6385 checked={noProxy}
6386 onChange={(e) => setNoProxy(e.target.checked)}
6387 />
6388 {t("settings.providerNoProxy")}
6389 </label>
6390 <div className="mem-hint">{t("settings.providerNoProxyHint")}</div>
6391 <label className="set-label">{t("settings.reasoningProtocol")}</label>
6392 <SettingsSelect className="mem-select" aria-label={t("settings.reasoningProtocol")} value={reasoningProtocol} onValueChange={(value) => setReasoningProtocol(value)}>
6393 {REASONING_PROTOCOLS.map((protocol) => (
6394 <option key={protocol || "auto"} value={protocol}>
6395 {reasoningProtocolLabel(protocol, t)}
6396 </option>
6397 ))}
6398 </SettingsSelect>
6399 <div className="mem-hint">{t("settings.reasoningProtocolHint")}</div>
6400 <label className="set-label">{t("settings.thinkingMode")}</label>
6401 <SettingsSelect className="mem-select" aria-label={t("settings.thinkingMode")} value={thinking} onValueChange={(value) => setThinking(normalizeThinkingMode(value))}>
6402 {THINKING_MODES.map((mode) => (
6403 <option key={mode || "auto"} value={mode}>
6404 {thinkingModeLabel(mode, t)}
6405 </option>
6406 ))}
6407 </SettingsSelect>
6408 <div className="mem-hint">{t("settings.thinkingModeHint")}</div>
6409 <label className="set-label">{t("settings.providerBalanceUrl")}</label>
6410 <input
6411 className="mem-input"
6412 placeholder={t("settings.balanceUrlPlaceholder")}
6413 value={balanceUrl}
6414 onChange={(e) => setBalanceUrl(e.target.value)}
6415 />
6416 <div className="mem-hint">{t("settings.balanceUrlHint")}</div>
6417 <label className="set-label">{t("settings.providerContextWindow")}</label>
6418 <input
6419 className="mem-input"
6420 inputMode="numeric"
6421 min={0}
6422 placeholder={t("settings.contextWindowPlaceholder")}
6423 type="number"
6424 value={ctx}
6425 onChange={(e) => setCtx(e.target.value)}
6426 />
6427 <div className="mem-hint">{t("settings.contextWindowHint")}</div>
6428 </div>
6429 </details>
6430 );
6431
6432 return (
6433 <div className={`provider-editor provider-editor--compact${isNewCustomProvider ? " provider-editor--wizard" : ""}`}>
6434 <div className="provider-editor__body">
6435 {!hideConnectionName && <>
6436 <label className="set-label" htmlFor={providerNameInputId}>{t(initial ? "settings.connections.name" : "settings.customProviderName")}</label>
6437 <input id={providerNameInputId} className="mem-input provider-name-input"
6438 placeholder={initial ? initial.name : t("settings.customProviderNamePlaceholder")}
6439 value={initial ? displayName : name} disabled={busy}
6440 onChange={(e) => initial ? setDisplayName(e.target.value) : setName(e.target.value)} />
6441 {initial && <details className="mem-hint"><summary>{t("settings.connections.id")}</summary><code>{initial.name}</code></details>}
6442 </>}
6443 <section className="provider-connection-fields">
6444 <div className="provider-connection-field">
6445 <label className="set-label" htmlFor={providerUrlInputId}>
6446 {t("settings.providerBaseUrlLabel")}
6447 </label>
6448 <input
6449 id={providerUrlInputId}
6450 className="mem-input provider-url-input"
6451 aria-describedby={providerUrlHelpId}
6452 placeholder={t("settings.providerChatUrlPlaceholder")}
6453 value={requestUrl}
6454 onChange={(e) => setRequestUrl(e.target.value)}
6455 />
6456 <span id={providerUrlHelpId} className="provider-field-help">{t("settings.providerRequestUrlHint")}</span>
6457 </div>
6458 <div className="provider-connection-field">
6459 <label className="set-label">{t("settings.providerProtocol")}</label>
6460 <SettingsSelect className="mem-select" aria-label={t("settings.providerProtocol")} title={providerKindHint(effectiveKind, t)} value={kind} disabled={busy || fetchingModels} onValueChange={(value) => {
6461 const nextKind = value;
6462 setRequestUrl(current => {
6463 return providerRequestURLForCatalogFormatChange(kind, nextKind, current, editorCatalog);
6464 });
6465 setKind(nextKind);
6466 }}>
6467 {providerKindChoices.map((choice) => (
6468 <option key={choice} value={choice}>
6469 {providerKindLabel(choice, t)}
6470 </option>
6471 ))}
6472 </SettingsSelect>
6473 {endpointMismatch.mismatch && <div role="alert" className="banner banner--warning">
6474 <span>{t("settings.providerProtocolMismatch")}</span>
6475 {endpointMismatch.recommendedUrl && <button type="button" className="btn btn--small" title={endpointMismatch.recommendedUrl} onClick={() => setRequestUrl(endpointMismatch.recommendedUrl)}>{t("settings.compactRatioApply")}</button>}
6476 </div>}
6477 </div>
6478 <div className="provider-key-single">
6479 <label htmlFor={`provider-key-${initial?.name ?? "new"}`}>API Key</label>
6480 <div className="provider-key-single__input">
6481 <input id={`provider-key-${initial?.name ?? "new"}`} className="mem-input"
6482 type={showKey ? "text" : "password"} autoComplete="new-password" spellCheck={false}
6483 placeholder={initial?.keySet ? "••••••••••••••••••••" : "API Key"}
6484 value={keyDraft} disabled={busy}
6485 onChange={e => setKeyDraft(e.target.value)} />
6486 <button type="button" className="btn provider-icon-action" aria-label={showKey ? "Hide API Key" : "Show API Key"}
6487 title={showKey ? "Hide API Key" : "Show API Key"} aria-pressed={showKey}
6488 disabled={!keyDraft} onClick={() => setShowKey(value => !value)}>
6489 {showKey ? <EyeOff size={17} /> : <Eye size={17} />}
6490 </button>
6491 </div>
6492 </div>
6493 </section>
6494 {fetchStatus && <div role="status" className="provider-fetch-status provider-fetch-status--ok">{fetchStatus}</div>}
6495 {fetchFallback && <div role="alert" className="provider-fetch-status provider-fetch-status--warn">{fetchFallback}</div>}
6496 {modelDialog !== null && <Suspense fallback={null}><ProviderModelDialog
6497 baseURL={effectiveRequestUrl} candidates={modelCandidateNames} contextDefault={Number(ctx) || undefined}
6498 effortOptions={Array.from(new Set([...(modelCapabilities.find(item=>item.model === modelDialog)?.reasoning?.options ?? []).map(option=>option.id), ...(modelOverrides.find(item=>item.model === modelDialog)?.supportedEfforts ?? supportedEfforts ?? [])])).filter(Boolean)}
6499 initial={modelDialog ? (() => { const override = modelOverrides.find(item=>item.model === modelDialog); return {model:modelDialog, contextWindow:modelContextWindows[modelDialog] ?? "", maxOutputTokens:override?.maxOutputTokens ?? 0, vision:override?.vision ?? null, supportedEfforts:override?.supportedEfforts ?? [], defaultEffort:override?.defaultEffort ?? ""}; })() : undefined}
6500 capability={modelCapabilities.find(item=>item.model === modelDialog)} busy={busy || fetchingModels}
6501 onClose={()=>setModelDialog(null)} onApply={applyModelDetails} onDelete={deleteModel}/></Suspense>}
6502 <ProviderEditorModelPicker
6503 actions={<>
6504 <button type="button" className="btn provider-icon-action" title={t("settings.fetchModels")} aria-label={t(fetchingModels ? "settings.fetchingModels" : "settings.fetchModels")} disabled={busy || fetchingModels || !canFetch || extraBodyInvalid} onClick={() => void fetchModels()}>{fetchingModels ? <Loader2 size={17} /> : <RefreshCw size={17} />}</button>
6505 <button className="btn btn--small" disabled={busy || fetchingModels} onClick={() => setModelDialog("")}>{t("settings.models.add")}</button>
6506 </>}
6507 candidates={modelCandidateNames}
6508 selectedModels={modelNames}
6509 visionModels={visionModelNames}
6510 visionModelsConfigured={visionModelsConfigured}
6511 modelCapabilities={modelCandidateNames.map(model => { const override = modelOverrides.find(item=>item.model === model)?.vision; const info = modelCapabilities.find(item=>item.model === model); return override == null ? info : {...info, model, state:override ? "supported" : "unsupported", inputModalities:override ? ["text","image"] : ["text"], source:"override"}; }).filter((item): item is ProviderModelCapabilityView => Boolean(item))}
6512 contextWindows={modelContextWindows}
6513 inheritedContextWindow={Number(ctx) || undefined}
6514 disabled={busy || fetchingModels}
6515 onToggleModel={toggleEditorModel}
6516 onEditModel={setModelDialog}
6517 onSelectAll={selectAllEditorModels}
6518 onClear={clearEditorModels}
6519 />
6520 <ProviderServiceCapabilities
6521 supported={effectiveServerWebSearchCapability}
6522 models={modelNames}
6523 enabled={webSearch}
6524 disabled={busy || fetchingModels}
6525 onChange={setWebSearch}
6526 />
6527 {advancedFields}
6528 </div>
6529 <div className="prov-card__actions provider-editor-footer">
6530 <span role="status">{t(dirty ? "settings.models.unsaved" : "settings.models.saved")}</span>
6531 <button className="btn btn--small" onClick={onCancel} disabled={busy}>
6532 {t("common.cancel")}
6533 </button>
6534 <button className="btn btn--primary btn--small" onClick={() => void save()} disabled={busy || fetchingModels || (Boolean(initial) && !dirty) || !name.trim() || !effectiveBaseUrl || !models.trim() || extraBodyInvalid || endpointMismatch.mismatch}>
6535 {t("settings.models.saveChanges")}
6536 </button>
6537 </div>
6538 </div>
6539 );
6540 }
6541
6542 function PermissionsSection({ s, busy, apply }: SectionProps) {
6543 const t = useT();
6544 return (
6545 <SettingsSection title={t("settings.permissionRules")} description={t("settings.ruleForm")}>
6546 <div className="set-rules-grid">
6547 {(["deny", "ask", "allow"] as const).map((list) => (
6548 <RuleList
6549 key={list}
6550 list={list}
6551 rules={s.permissions[list]}
6552 busy={busy}
6553 onAdd={async (rule) => { await apply(() => app.AddPermissionRule(list, rule)); }}
6554 onRemove={async (rule) => { await apply(() => app.RemovePermissionRule(list, rule)); }}
6555 />
6556 ))}
6557 </div>
6558 </SettingsSection>
6559 );
6560 }
6561
6562 function RuleList({
6563 list,
6564 rules,
6565 busy,
6566 onAdd,
6567 onRemove,
6568 }: {
6569 list: string;
6570 rules: string[];
6571 busy: boolean;
6572 onAdd: (rule: string) => Promise<void>;
6573 onRemove: (rule: string) => Promise<void>;
6574 }) {
6575 const t = useT();
6576 const [draft, setDraft] = useState("");
6577 const add = () => {
6578 const r = draft.trim();
6579 if (r) {
6580 void onAdd(r);
6581 setDraft("");
6582 }
6583 };
6584 return (
6585 <div className="set-rules">
6586 <div className="set-rules__head">
6587 <div className="set-rules__label">{ruleListLabel(list, t)}</div>
6588 {ruleListHint(list, t) && <div className="set-rules__hint">{ruleListHint(list, t)}</div>}
6589 </div>
6590 <div className="set-rules__chips">
6591 {rules.length === 0 && <span className="mem-empty">{t("common.none")}</span>}
6592 {rules.map((r) => (
6593 <span className="set-rule" key={r}>
6594 <span className="set-rule__text" title={r}>{r}</span>
6595 <Tooltip label={t("common.delete")}>
6596 <button className="set-rule__x" disabled={busy} onClick={() => void onRemove(r)}>
6597
6598 </button>
6599 </Tooltip>
6600 </span>
6601 ))}
6602 </div>
6603 <div className="set-rules__add">
6604 <input
6605 className="mem-input"
6606 placeholder={t("settings.addRule", { list })}
6607 value={draft}
6608 onChange={(e) => setDraft(e.target.value)}
6609 onKeyDown={(e) => {
6610 if (e.key === "Enter") add();
6611 }}
6612 />
6613 <button className="btn btn--small" disabled={busy || !draft.trim()} onClick={add}>
6614 {t("common.add")}
6615 </button>
6616 </div>
6617 </div>
6618 );
6619 }
6620
6621 function ruleListLabel(list: string, t: ReturnType<typeof useT>): string {
6622 switch (list) {
6623 case "deny":
6624 return t("settings.ruleDeny");
6625 case "ask":
6626 return t("settings.ruleAsk");
6627 case "allow":
6628 return t("settings.ruleAllow");
6629 case "allow_write":
6630 return t("settings.ruleAllowWrite");
6631 default:
6632 return list;
6633 }
6634 }
6635
6636 function ruleListHint(list: string, t: ReturnType<typeof useT>): string {
6637 switch (list) {
6638 case "deny":
6639 return t("settings.ruleDenyHint");
6640 case "ask":
6641 return t("settings.ruleAskHint");
6642 case "allow":
6643 return t("settings.ruleAllowHint");
6644 default:
6645 return "";
6646 }
6647 }
6648
6649 type HookScope = "global" | "project";
6650
6651 function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | null) => void }) {
6652 const t = useT();
6653 const [scope, setScope] = useState<HookScope>("global");
6654 const [view, setView] = useState<HooksSettingsView | null>(null);
6655 const [jsonText, setJsonText] = useState("");
6656 const [jsonMessage, setJsonMessage] = useState<string | null>(null);
6657 const [jsonError, setJsonError] = useState<string | null>(null);
6658 const [pathMessage, setPathMessage] = useState<string | null>(null);
6659 const [busy, setBusy] = useState(false);
6660 const [err, setErr] = useState<string | null>(null);
6661
6662 const load = useCallback(async (nextScope: HookScope) => {
6663 setBusy(true);
6664 setErr(null);
6665 try {
6666 const next = normalizeHooksSettingsView(await app.HooksSettings(nextScope), nextScope);
6667 setView(next);
6668 setJsonText(formatHooksJSON(next.hooks, next.events));
6669 setJsonMessage(null);
6670 setJsonError(null);
6671 setPathMessage(null);
6672 } catch (e) {
6673 setErr(String((e as Error)?.message ?? e));
6674 setView(null);
6675 setJsonText("");
6676 setJsonMessage(null);
6677 setJsonError(null);
6678 setPathMessage(null);
6679 } finally {
6680 setBusy(false);
6681 }
6682 }, []);
6683
6684 useEffect(() => {
6685 void load(scope);
6686 }, [load, scope]);
6687
6688 const parseHooksEditorJSON = (raw = jsonText): { hooks: HookConfigView[]; text: string } | null => {
6689 try {
6690 const hooks = parseHooksJSON(raw, view?.events ?? [], t);
6691 const text = formatHooksJSON(hooks, view?.events ?? []);
6692 setJsonText(text);
6693 setJsonError(null);
6694 return { hooks, text };
6695 } catch (e) {
6696 setJsonError(t("settings.hooksJsonInvalid", { error: String((e as Error)?.message ?? e) }));
6697 setJsonMessage(null);
6698 return null;
6699 }
6700 };
6701 const copyHooksJSON = async () => {
6702 const parsed = parseHooksEditorJSON();
6703 if (!parsed) return;
6704 try {
6705 await navigator.clipboard?.writeText(parsed.text);
6706 setJsonMessage(t("settings.hooksJsonCopied"));
6707 } catch {
6708 setJsonMessage(t("settings.hooksJsonClipboardUnavailable"));
6709 }
6710 };
6711 const formatHooksEditorJSON = (raw = jsonText) => {
6712 const parsed = parseHooksEditorJSON(raw);
6713 if (parsed) setJsonMessage(t("settings.hooksJsonFormatted"));
6714 };
6715 const pasteHooksJSON = async () => {
6716 try {
6717 const raw = await navigator.clipboard?.readText();
6718 if (!raw) throw new Error(t("settings.hooksJsonClipboardEmpty"));
6719 setJsonText(raw);
6720 formatHooksEditorJSON(raw);
6721 } catch (e) {
6722 setJsonError(t("settings.hooksJsonPasteFailed", { error: String((e as Error)?.message ?? e) }));
6723 setJsonMessage(null);
6724 }
6725 };
6726 const copyHooksPath = async () => {
6727 const path = view?.path?.trim();
6728 if (!path) {
6729 setPathMessage(t("settings.hooksPathUnavailable"));
6730 return;
6731 }
6732 try {
6733 await navigator.clipboard?.writeText(path);
6734 setPathMessage(t("settings.hooksPathCopied"));
6735 } catch {
6736 setPathMessage(t("settings.hooksJsonClipboardUnavailable"));
6737 }
6738 };
6739 const save = async () => {
6740 setBusy(true);
6741 setErr(null);
6742 try {
6743 const parsed = parseHooksEditorJSON();
6744 if (!parsed) return;
6745 await app.SaveHooksSettingsForRoot(scope, view?.projectRoot?.trim() ?? "", parsed.hooks);
6746 await load(scope);
6747 onChanged();
6748 } catch (e) {
6749 setErr(String((e as Error)?.message ?? e));
6750 } finally {
6751 setBusy(false);
6752 }
6753 };
6754 return (
6755 <>
6756 {err && <div className="banner banner--error">{err}</div>}
6757 <SettingsSection title={t("settings.hooksScopeSection")} description={t("settings.hooksScopeHint")}>
6758 <SettingsField label={t("settings.hooksScopeField")}>
6759 <SettingsSelect name="hooks-scope" className="mem-select set-grow" value={scope} disabled={busy} onValueChange={(value) => setScope(value === "project" ? "project" : "global")}>
6760 <option value="global">{t("settings.hooksGlobal")}</option>
6761 <option value="project">{t("settings.hooksProject")}</option>
6762 </SettingsSelect>
6763 </SettingsField>
6764 <SettingsField label={t("settings.hooksPath")} hint={scope === "project" ? t("settings.hooksPathProjectHint") : t("settings.hooksPathGlobalHint")}>
6765 <div className="hooks-path-stack">
6766 <div className={`hooks-path-display${view?.path ? "" : " hooks-path-display--empty"}`}>
6767 <code className="hooks-path-display__value" title={view?.path || t("settings.hooksPathUnavailable")}>
6768 {view?.path || t("settings.hooksPathUnavailable")}
6769 </code>
6770 <button className="btn btn--small" disabled={busy || !view?.path} onClick={() => void copyHooksPath()}>{t("settings.hooksPathCopy")}</button>
6771 </div>
6772 {pathMessage && <div className="hooks-path-display__message">{pathMessage}</div>}
6773 </div>
6774 </SettingsField>
6775 </SettingsSection>
6776
6777 <SettingsSection
6778 title={t("settings.hooks")}
6779 description={scope === "project" ? t("settings.hooksProjectHint") : t("settings.hooksGlobalHint")}
6780 >
6781 {view && (
6782 <div className="hooks-json-panel">
6783 <div className="hooks-json-panel__head">
6784 <div>
6785 <div className="set-rules__label">{t("settings.hooksJsonTitle")}</div>
6786 <div className="set-rules__hint">{t("settings.hooksJsonHint")}</div>
6787 </div>
6788 <div className="hooks-json-panel__actions">
6789 <button className="btn btn--small" disabled={busy} onClick={() => void copyHooksJSON()}>{t("settings.hooksJsonCopy")}</button>
6790 <button className="btn btn--small" disabled={busy} onClick={() => void pasteHooksJSON()}>{t("settings.hooksJsonPaste")}</button>
6791 <button className="btn btn--small" disabled={busy || !jsonText.trim()} onClick={() => formatHooksEditorJSON()}>{t("settings.hooksJsonApply")}</button>
6792 </div>
6793 </div>
6794 <textarea
6795 name="hooks-json"
6796 className="mem-textarea hooks-json-panel__textarea"
6797 value={jsonText}
6798 disabled={busy}
6799 spellCheck={false}
6800 onChange={(e) => {
6801 setJsonText(e.target.value);
6802 setJsonMessage(null);
6803 setJsonError(null);
6804 }}
6805 />
6806 {jsonError && <div className="hooks-json-panel__message hooks-json-panel__message--error">{jsonError}</div>}
6807 {jsonMessage && <div className="hooks-json-panel__message">{jsonMessage}</div>}
6808 </div>
6809 )}
6810 {!view && <div className="empty">{t("settings.loading")}</div>}
6811 <div className="settings-save-bar">
6812 <span role="status">{t(view && jsonText !== formatHooksJSON(view.hooks, view.events) ? "settings.models.unsaved" : "settings.models.saved")}</span>
6813 <button className="btn" disabled={busy || !view} onClick={() => { if (view) { setJsonText(formatHooksJSON(view.hooks, view.events)); setJsonError(null); setJsonMessage(null); } }}>{t("common.cancel")}</button>
6814 <button className="btn btn--primary" disabled={busy || !view || jsonText === formatHooksJSON(view.hooks, view.events)} onClick={() => void save()}>{t("common.save")}</button>
6815 </div>
6816 </SettingsSection>
6817 </>
6818 );
6819 }
6820
6821 function normalizeHooksSettingsView(view: HooksSettingsView, scope: HookScope): HooksSettingsView {
6822 const events = asArray(view?.events).filter(Boolean);
6823 return {
6824 scope: view?.scope === "project" ? "project" : scope,
6825 path: view?.path ?? "",
6826 projectRoot: view?.projectRoot ?? "",
6827 trusted: !!view?.trusted,
6828 events,
6829 hooks: asArray(view?.hooks).map(normalizeHookConfig).filter((h) => h.event),
6830 };
6831 }
6832
6833 function formatHooksJSON(hooks: HookConfigView[], eventOrder: string[]): string {
6834 const grouped: Record<string, Array<Record<string, string | number>>> = {};
6835 const events = new Set(eventOrder);
6836 for (const hook of hooks.map(normalizeHookConfig).filter((h) => h.event)) {
6837 events.add(hook.event);
6838 const entry: Record<string, string | number> = { command: hook.command };
6839 if (hook.match) entry.match = hook.match;
6840 if (hook.description) entry.description = hook.description;
6841 if ((hook.timeout ?? 0) > 0) entry.timeout = hook.timeout ?? 0;
6842 if (hook.cwd) entry.cwd = hook.cwd;
6843 (grouped[hook.event] ||= []).push(entry);
6844 }
6845 const ordered: typeof grouped = {};
6846 for (const event of [...eventOrder, ...Array.from(events).sort()]) {
6847 if (grouped[event]?.length && !ordered[event]) ordered[event] = grouped[event];
6848 }
6849 return JSON.stringify({ hooks: ordered }, null, 2);
6850 }
6851
6852 function parseHooksJSON(raw: string, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView[] {
6853 const trimmed = raw.trim();
6854 if (!trimmed) return [];
6855 let parsed: unknown;
6856 try {
6857 parsed = JSON.parse(trimmed);
6858 } catch (e) {
6859 throw new Error(String((e as Error)?.message ?? e));
6860 }
6861 if (Array.isArray(parsed)) {
6862 return parsed.map((item) => normalizeHookConfig(parseHookArrayItem(item, validEvents, t))).filter((h) => h.event);
6863 }
6864 if (!parsed || typeof parsed !== "object") {
6865 throw new Error(t("settings.hooksJsonExpectedObjectArray"));
6866 }
6867 const obj = parsed as Record<string, unknown>;
6868 const hooksValue = obj.hooks && typeof obj.hooks === "object" && !Array.isArray(obj.hooks) ? obj.hooks : obj;
6869 return flattenHooksMap(hooksValue as Record<string, unknown>, validEvents, t);
6870 }
6871
6872 function parseHookArrayItem(item: unknown, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView {
6873 if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(t("settings.hooksJsonItemObject"));
6874 const obj = item as Record<string, unknown>;
6875 const event = stringField(obj, "event") || "PreToolUse";
6876 if (validEvents.length > 0 && !validEvents.includes(event)) throw new Error(t("settings.hooksJsonUnknownEvent", { event }));
6877 return {
6878 event,
6879 match: stringField(obj, "match"),
6880 command: stringField(obj, "command"),
6881 description: stringField(obj, "description"),
6882 timeout: numberField(obj, "timeout"),
6883 cwd: stringField(obj, "cwd"),
6884 };
6885 }
6886
6887 function flattenHooksMap(hooks: Record<string, unknown>, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView[] {
6888 const valid = new Set(validEvents);
6889 const out: HookConfigView[] = [];
6890 for (const [event, value] of Object.entries(hooks)) {
6891 if (valid.size > 0 && !valid.has(event)) throw new Error(t("settings.hooksJsonUnknownEvent", { event }));
6892 const items = Array.isArray(value) ? value : [value];
6893 for (const item of items) {
6894 if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(t("settings.hooksJsonEventItemObject", { event }));
6895 const obj = item as Record<string, unknown>;
6896 out.push(normalizeHookConfig({
6897 event,
6898 match: stringField(obj, "match"),
6899 command: stringField(obj, "command"),
6900 description: stringField(obj, "description"),
6901 timeout: numberField(obj, "timeout"),
6902 cwd: stringField(obj, "cwd"),
6903 }));
6904 }
6905 }
6906 return out.filter((h) => h.event);
6907 }
6908
6909 function stringField(obj: Record<string, unknown>, key: string): string {
6910 const value = obj[key];
6911 return typeof value === "string" ? value : "";
6912 }
6913
6914 function numberField(obj: Record<string, unknown>, key: string): number {
6915 const value = obj[key];
6916 return typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : 0;
6917 }
6918
6919 function normalizeHookConfig(h: HookConfigView): HookConfigView {
6920 return {
6921 event: h.event || "PreToolUse",
6922 match: h.match ?? "",
6923 command: h.command ?? "",
6924 description: h.description ?? "",
6925 timeout: h.timeout && h.timeout > 0 ? Math.floor(h.timeout) : 0,
6926 cwd: h.cwd ?? "",
6927 };
6928 }
6929
6930 function SandboxSection({ s, busy, apply, windows }: SectionProps & { windows: boolean }) {
6931 const t = useT();
6932 const sb = s.sandbox;
6933 const [root, setRoot] = useState(sb.workspaceRoot);
6934 const effectiveWriteRoots = asArray(sb.effectiveWriteRoots).filter((path) => String(path).trim());
6935 const set = (next: Partial<typeof sb>) =>
6936 apply(() => app.SetSandbox(next.bash ?? sb.bash, next.network ?? sb.network, next.workspaceRoot ?? sb.workspaceRoot, next.allowWrite ?? sb.allowWrite, next.shell ?? sb.shell));
6937 const reloadSession = () => apply(() => app.ReloadSettings());
6938
6939 return (
6940 <SettingsSection
6941 title={t("settings.sandboxTitle")}
6942 description={t(windows ? "settings.sandboxBoundaryHintWindows" : "settings.sandboxBoundaryHint")}
6943 >
6944 <ShellInterpreterFields sb={sb} windows={windows} busy={busy} setShell={(prefer) => void apply(() => app.SetShellPreference(prefer))} reloadSession={() => void reloadSession()} />
6945 <SettingsField label={t("settings.allowNetwork")} hint={windows ? t("settings.allowNetworkWindowsHint") : undefined}>
6946 <label className="set-check set-check--inline">
6947 <input type="checkbox" checked={sb.network} disabled={busy || windows} onChange={(e) => void set({ network: e.target.checked })} />
6948 {t("settings.allowNetwork")}
6949 </label>
6950 </SettingsField>
6951 <SettingsField label={t("settings.workspaceRoot")}>
6952 <input
6953 className="mem-input set-grow"
6954 placeholder={t("settings.workspaceDefault")}
6955 value={root}
6956 disabled={busy}
6957 onChange={(e) => setRoot(e.target.value)}
6958 onBlur={() => root !== sb.workspaceRoot && void set({ workspaceRoot: root })}
6959 />
6960 </SettingsField>
6961 <RuleList
6962 list="allow_write"
6963 rules={sb.allowWrite}
6964 busy={busy}
6965 onAdd={async (d) => { await set({ allowWrite: [...sb.allowWrite, d] }); }}
6966 onRemove={async (d) => { await set({ allowWrite: sb.allowWrite.filter((x) => x !== d) }); }}
6967 />
6968 <ShellEnvironmentDetails
6969 sb={sb}
6970 windows={windows}
6971 busy={busy}
6972 effectiveWriteRoots={effectiveWriteRoots}
6973 reloadSession={() => void reloadSession()}
6974 />
6975 </SettingsSection>
6976 );
6977 }
6978
6979 const MB = 1024 * 1024;
6980 const mb = (n: number) => (n / MB).toFixed(1);
6981
6982 // AboutSection always exposes build, privacy, configuration, changelog, and
6983 // feedback information. Updater controls are an exact stable-build capability.
6984 function AboutSection({
6985 configPath,
6986 shadowedByPath,
6987 checkUpdates,
6988 updaterEnabled,
6989 telemetry,
6990 metrics,
6991 settingsBusy,
6992 applySettings,
6993 }: {
6994 configPath: string;
6995 shadowedByPath?: string;
6996 checkUpdates: boolean;
6997 updaterEnabled: boolean;
6998 telemetry: boolean;
6999 metrics: boolean;
7000 settingsBusy: boolean;
7001 applySettings: (fn: () => Promise<void>) => Promise<boolean>;
7002 }) {
7003 const t = useT();
7004 const { status, check, apply: applyUpdate, openDownload, abandonPending } = useUpdater();
7005 const [version, setVersion] = useState("");
7006 useEffect(() => {
7007 app.Version().then(setVersion).catch(() => {});
7008 }, []);
7009
7010 const updaterBusy =
7011 status.kind === "checking" ||
7012 status.kind === "downloading" ||
7013 status.kind === "verifying" ||
7014 status.kind === "authorizing" ||
7015 status.kind === "installing" ||
7016 status.kind === "relaunching";
7017 const updateStatus =
7018 status.kind === "checking" ? t("updater.checking") :
7019 status.kind === "upToDate" ? t("updater.upToDate") :
7020 status.kind === "available" ? t("updater.available", { v: status.info.latest }) :
7021 status.kind === "downloading" ? t("updater.downloading", {
7022 done: mb(status.received),
7023 total: mb(status.total),
7024 pct: status.total > 0 ? Math.round((status.received / status.total) * 100) : 0,
7025 }) :
7026 status.kind === "verifying" ? t("updater.verifying") :
7027 status.kind === "authorizing" ? t("updater.authorizing") :
7028 status.kind === "installing" ? (
7029 status.info?.requiresElevation || status.info?.installMode === "deb"
7030 ? t("updater.installingPackage")
7031 : t("updater.installing")
7032 ) :
7033 status.kind === "relaunching" || status.kind === "done" ? t("updater.done") :
7034 status.kind === "error" ? "" :
7035 "";
7036 const updateStatusTone =
7037 status.kind === "error" ? "error" :
7038 status.kind === "available" ? "available" :
7039 status.kind === "upToDate" || status.kind === "done" || status.kind === "relaunching" ? "success" :
7040 status.kind === "checking" || updaterBusy ? "busy" :
7041 "neutral";
7042 const updateErrorTitle = status.kind === "error"
7043 ? status.disposition === "recovery"
7044 ? t("updater.recoveryBlocked")
7045 : status.disposition === "manual"
7046 ? t("updater.manualUpdateRequired")
7047 : t("updater.failed", { msg: status.message })
7048 : "";
7049 const updateErrorHint = status.kind === "error"
7050 ? status.disposition === "recovery"
7051 ? t("updater.recoveryHint")
7052 : status.disposition === "manual"
7053 ? t("updater.manualFallbackHint")
7054 : ""
7055 : "";
7056 const downloadIsPrimary = status.kind === "error" && status.disposition !== "retryable";
7057
7058 return (
7059 <SettingsSection>
7060 <SettingsField
7061 className="settings-field--wide-copy updates-control"
7062 label={
7063 <div className="updates-control__summary">
7064 <div className="updates-control__version">
7065 {t("updater.currentVersion", { v: version || "…" })}
7066 </div>
7067 {updaterEnabled && (
7068 <div className={`updates-control__status updates-control__status--${updateStatusTone}`} role="status" aria-live="polite">
7069 {updateStatus && (
7070 <>
7071 {updateStatusTone === "success" && <CheckCircle2 size={14} aria-hidden="true" />}
7072 {updateStatusTone === "busy" && <Loader2 className="updates-control__spinner" size={14} aria-hidden="true" />}
7073 <span>{updateStatus}</span>
7074 </>
7075 )}
7076 </div>
7077 )}
7078 </div>
7079 }
7080 >
7081 {updaterEnabled && <div className="updates-control__controls">
7082 <Tooltip label={t("updater.checkButton")}>
7083 <button
7084 className="chip chip--icon"
7085 type="button"
7086 disabled={settingsBusy || updaterBusy}
7087 aria-label={t("updater.checkButton")}
7088 onClick={() => void check()}
7089 >
7090 <RefreshCw className={status.kind === "checking" ? "updates-control__spinner" : undefined} size={14} aria-hidden="true" />
7091 </button>
7092 </Tooltip>
7093 </div>}
7094 </SettingsField>
7095 <SettingsField
7096 className="settings-field--wide-copy"
7097 label={t("updater.buildIdentity")}
7098 hint={t("updater.buildIdentityHint")}
7099 >
7100 <span className="mem-hint">{typeof __BUILD_CHANNEL__ === "string" ? __BUILD_CHANNEL__ : "development"}</span>
7101 </SettingsField>
7102 {updaterEnabled && <div
7103 className="updates-control__hint"
7104 style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: "4px 8px" }}
7105 >
7106 <span>{t("updater.officialReleaseHint")}</span>
7107 <button
7108 className="btn btn--small"
7109 type="button"
7110 onClick={openDownload}
7111 style={{
7112 height: "auto",
7113 minHeight: 0,
7114 padding: 0,
7115 borderColor: "transparent",
7116 background: "transparent",
7117 color: "var(--fg-dim)",
7118 textDecoration: "underline",
7119 textUnderlineOffset: 2,
7120 }}
7121 >
7122 {t("updater.officialDownload")}
7123 <ExternalLink size={13} aria-hidden="true" />
7124 </button>
7125 </div>}
7126 {updaterEnabled && status.kind === "available" && (
7127 <div className="updates-control__action">
7128 <div className="updates-control__action-copy">
7129 {!status.info.canSelfUpdate && <div>{status.info.manualReason || t("updater.macHint")}</div>}
7130 </div>
7131 <button
7132 className="btn btn--primary btn--small"
7133 disabled={settingsBusy || updaterBusy}
7134 onClick={() => applyUpdate(status.info)}
7135 >
7136 {status.info.canSelfUpdate ? t("updater.updateAndRestart") : t("updater.goToDownload")}
7137 </button>
7138 </div>
7139 )}
7140 {updaterEnabled && status.kind === "error" && (
7141 <div
7142 className="banner banner--update banner--error"
7143 role="alert"
7144 style={{ alignItems: "flex-start", flexWrap: "wrap", marginBottom: 12 }}
7145 >
7146 <div style={{ flex: "1 1 360px", minWidth: 0 }}>
7147 <div>{updateErrorTitle}</div>
7148 {updateErrorHint && <div className="banner__hint">{updateErrorHint}</div>}
7149 {status.disposition !== "retryable" && (
7150 <div className="banner__hint" style={{ overflowWrap: "anywhere" }}>
7151 {t("updater.errorDetails", { msg: status.message })}
7152 </div>
7153 )}
7154 </div>
7155 <span className="banner__spacer" />
7156 {status.disposition === "recovery" && (
7157 <button
7158 className="btn btn--small"
7159 type="button"
7160 disabled={settingsBusy || updaterBusy}
7161 onClick={() => void abandonPending()}
7162 >
7163 {t("updater.discardPrevious")}
7164 </button>
7165 )}
7166 {downloadIsPrimary && (
7167 <button className="btn btn--primary btn--small" type="button" onClick={openDownload}>
7168 {t("updater.officialDownload")}
7169 <ExternalLink size={14} aria-hidden="true" />
7170 </button>
7171 )}
7172 <button
7173 className={`btn btn--small${downloadIsPrimary ? "" : " btn--primary"}`}
7174 type="button"
7175 disabled={settingsBusy || updaterBusy}
7176 onClick={() => status.info ? applyUpdate(status.info) : void check()}
7177 >
7178 {t("updater.retry")}
7179 </button>
7180 </div>
7181 )}
7182 <SettingsField
7183 className="settings-field--wide-copy"
7184 label={t("changelog.title")}
7185 hint={t("changelog.subtitle")}
7186 >
7187 <button className="btn btn--small" onClick={() => void openExternal("https://reasonix.io/changelog/")}>
7188 {t("changelog.openWeb")}
7189 <ExternalLink size={14} aria-hidden="true" />
7190 </button>
7191 </SettingsField>
7192 <SettingsField
7193 className="settings-field--wide-copy"
7194 label={t("feedback.title")}
7195 hint={t("feedback.subtitle")}
7196 >
7197 <div className="settings-inline-controls">
7198 <button
7199 className="btn btn--small"
7200 onClick={() => void openExternal("https://github.com/esengine/DeepSeek-Reasonix/issues/new/choose")}
7201 >
7202 {t("feedback.submitIssue")}
7203 <ExternalLink size={14} aria-hidden="true" />
7204 </button>
7205 <button
7206 className="btn btn--small"
7207 onClick={() => void openExternal("https://github.com/esengine/DeepSeek-Reasonix/issues")}
7208 >
7209 {t("feedback.viewIssues")}
7210 <ExternalLink size={14} aria-hidden="true" />
7211 </button>
7212 </div>
7213 </SettingsField>
7214 {updaterEnabled && <details
7215 className="provider-editor-advanced"
7216 style={{
7217 marginTop: 0,
7218 borderRight: 0,
7219 borderBottom: 0,
7220 borderLeft: 0,
7221 borderRadius: 0,
7222 background: "transparent",
7223 }}
7224 >
7225 <summary style={{ padding: "0 2px" }}>
7226 <span className="provider-editor-advanced__title">
7227 <ChevronDown className="provider-editor-advanced__icon" size={16} aria-hidden="true" />
7228 {t("updater.updatePreferences")}
7229 </span>
7230 </summary>
7231 <div className="provider-editor-advanced__body">
7232 <SettingsField
7233 className="settings-field--wide-copy"
7234 label={t("updater.autoCheckLabel")}
7235 hint={t("updater.autoCheckHint")}
7236 >
7237 <ToggleSegment
7238 value={checkUpdates}
7239 disabled={settingsBusy}
7240 onChange={(enabled) => void applySettings(() => app.SetDesktopCheckUpdates(enabled))}
7241 />
7242 </SettingsField>
7243 </div>
7244 </details>}
7245 <details
7246 className="provider-editor-advanced"
7247 style={{
7248 marginTop: 0,
7249 borderRight: 0,
7250 borderBottom: 0,
7251 borderLeft: 0,
7252 borderRadius: 0,
7253 background: "transparent",
7254 }}
7255 >
7256 <summary style={{ padding: "0 2px" }}>
7257 <span className="provider-editor-advanced__title">
7258 <ChevronDown className="provider-editor-advanced__icon" size={16} aria-hidden="true" />
7259 {t("updater.privacyAndConfig")}
7260 </span>
7261 </summary>
7262 <div className="provider-editor-advanced__body">
7263 <SettingsField className="settings-field--wide-copy" label={t("settings.telemetryLabel")} hint={t("settings.telemetryHint")}>
7264 <ToggleSegment value={telemetry} disabled={settingsBusy} onChange={(enabled) => void applySettings(() => app.SetDesktopTelemetry(enabled))} />
7265 </SettingsField>
7266 <SettingsField className="settings-field--wide-copy" label={t("settings.metricsLabel")} hint={t("settings.metricsHint")}>
7267 <ToggleSegment value={metrics} disabled={settingsBusy} onChange={(enabled) => void applySettings(() => app.SetDesktopMetrics(enabled))} />
7268 </SettingsField>
7269 {configPath && (
7270 <Tooltip label={configPath} fill block className="mem-hint settings-config-path">
7271 {t("settings.config", { path: configPath })}
7272 </Tooltip>
7273 )}
7274 {shadowedByPath && (
7275 <Tooltip label={shadowedByPath} fill block className="mem-hint settings-config-path settings-config-path--shadowed">
7276 {t("settings.configShadowed", { path: shadowedByPath })}
7277 </Tooltip>
7278 )}
7279 </div>
7280 </details>
7281 </SettingsSection>
7282 );
7283 }
7284
7284 lines Plain Text