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