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