import { useCallback, useEffect, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; import { Bot, Brain, ChevronLeft, ChevronDown, Check, Cloud, Cpu, Database, Eye, EyeOff, Pencil, Gem, Grid3X3, Hexagon, Loader2, LogOut, KeyRound, Layers, Moon, Orbit, RotateCcw, Settings, Sparkles, Triangle, Waves, Zap, type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { fetchSettings, updateProviderSettings, updateSettings, updateWebSearchSettings, } from "@/lib/api"; import { cn } from "@/lib/utils"; import { useClient } from "@/providers/ClientProvider"; import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types"; type SettingsSectionKey = "general" | "byok"; type ByokPaneKey = "llm" | "web-search"; const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map( ["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [ name, index, ]), ); interface SettingsViewProps { theme: "light" | "dark"; onToggleTheme: () => void; onBackToChat: () => void; onModelNameChange: (modelName: string | null) => void; onLogout?: () => void; onRestart?: () => void; isRestarting?: boolean; } export function SettingsView({ theme, onToggleTheme, onBackToChat, onModelNameChange, onLogout, onRestart, isRestarting = false, }: SettingsViewProps) { const { t } = useTranslation(); const { token } = useClient(); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [providerSaving, setProviderSaving] = useState(null); const [webSearchSaving, setWebSearchSaving] = useState(false); const [error, setError] = useState(null); const [activeSection, setActiveSection] = useState("general"); const [expandedProvider, setExpandedProvider] = useState(null); const [providerForms, setProviderForms] = useState>({}); const [visibleProviderKeys, setVisibleProviderKeys] = useState>({}); const [editingProviderKeys, setEditingProviderKeys] = useState>({}); const [webSearchForm, setWebSearchForm] = useState({ provider: "duckduckgo", apiKey: "", baseUrl: "", }); const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false); const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false); const [form, setForm] = useState({ model: "", provider: "", }); const applyPayload = useCallback((payload: SettingsPayload) => { setSettings(payload); setForm({ model: payload.agent.model, provider: payload.agent.provider, }); setWebSearchForm((prev) => ({ provider: payload.web_search.provider, apiKey: prev.provider === payload.web_search.provider ? prev.apiKey ?? "" : "", baseUrl: payload.web_search.base_url ?? "", })); }, []); useEffect(() => { let cancelled = false; setLoading(true); fetchSettings(token) .then((payload) => { if (!cancelled) { applyPayload(payload); setError(null); } }) .catch((err) => { if (!cancelled) setError((err as Error).message); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [applyPayload, token]); useEffect(() => { if (!settings) return; setProviderForms((prev) => { const next = { ...prev }; for (const provider of settings.providers) { next[provider.name] = { apiKey: next[provider.name]?.apiKey ?? "", apiBase: next[provider.name]?.apiBase ?? provider.api_base ?? provider.default_api_base ?? "", }; } return next; }); }, [settings]); const dirty = useMemo(() => { if (!settings) return false; return ( form.model !== settings.agent.model || form.provider !== settings.agent.provider ); }, [form, settings]); const save = async () => { if (!dirty || saving) return; setSaving(true); try { const payload = await updateSettings(token, { model: form.model, ...(form.provider ? { provider: form.provider } : {}), }); applyPayload(payload); onModelNameChange(payload.agent.model || null); setError(null); } catch (err) { setError((err as Error).message); } finally { setSaving(false); } }; const saveProvider = async (providerName: string) => { if (providerSaving) return; const provider = settings?.providers.find((item) => item.name === providerName); if (!provider) return; const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" }; const apiKey = providerForm.apiKey.trim(); const apiKeyRequired = provider.api_key_required ?? true; if (!provider.configured && apiKeyRequired && !apiKey) { setError(t("settings.byok.apiKeyRequired")); return; } setProviderSaving(providerName); try { const payload = await updateProviderSettings(token, { provider: providerName, apiKey: apiKey || undefined, apiBase: providerForm.apiBase.trim(), }); applyPayload(payload); setProviderForms((prev) => ({ ...prev, [providerName]: { apiKey: "", apiBase: providerForm.apiBase.trim(), }, })); setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false })); setError(null); } catch (err) { setError((err as Error).message); } finally { setProviderSaving(null); } }; const saveWebSearch = async () => { if (!settings || webSearchSaving) return; const provider = settings.web_search.providers.find((item) => item.name === webSearchForm.provider); if (!provider) return; const apiKey = webSearchForm.apiKey?.trim() ?? ""; const baseUrl = webSearchForm.baseUrl?.trim() ?? ""; const hasExistingSecret = provider.credential === "api_key" && webSearchForm.provider === settings.web_search.provider && !!settings.web_search.api_key_hint; if (provider.credential === "api_key" && !apiKey && !hasExistingSecret) { setError(t("settings.byok.webSearch.apiKeyRequired")); return; } if (provider.credential === "base_url" && !baseUrl) { setError(t("settings.byok.webSearch.baseUrlRequired")); return; } setWebSearchSaving(true); try { const update: WebSearchSettingsUpdate = { provider: webSearchForm.provider }; if (provider.credential === "api_key" && apiKey) update.apiKey = apiKey; if (provider.credential === "base_url") update.baseUrl = baseUrl; const payload = await updateWebSearchSettings(token, update); applyPayload(payload); setWebSearchForm((prev) => ({ provider: payload.web_search.provider, apiKey: "", baseUrl: payload.web_search.base_url ?? prev.baseUrl ?? "", })); setWebSearchKeyVisible(false); setWebSearchKeyEditing(false); setError(null); } catch (err) { setError((err as Error).message); } finally { setWebSearchSaving(false); } }; const resetProviderDraft = useCallback((providerName: string) => { const provider = settings?.providers.find((item) => item.name === providerName); if (!provider) return; setProviderForms((prev) => ({ ...prev, [providerName]: { apiKey: "", apiBase: provider.api_base ?? provider.default_api_base ?? "", }, })); setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false })); }, [settings]); const handleToggleProvider = useCallback((providerName: string) => { if (expandedProvider) resetProviderDraft(expandedProvider); setExpandedProvider(expandedProvider === providerName ? null : providerName); }, [expandedProvider, resetProviderDraft]); const resetWebSearchDraft = useCallback(() => { if (!settings) return; setWebSearchForm({ provider: settings.web_search.provider, apiKey: "", baseUrl: settings.web_search.base_url ?? "", }); setWebSearchKeyVisible(false); setWebSearchKeyEditing(false); }, [settings]); const handleWebSearchProviderChange = useCallback((provider: string) => { if (!settings) return; setWebSearchForm({ provider, apiKey: "", baseUrl: provider === settings.web_search.provider ? settings.web_search.base_url ?? "" : "", }); setWebSearchKeyVisible(false); setWebSearchKeyEditing(false); }, [settings]); const toggleProviderKeyVisibility = (providerName: string) => { const isVisible = visibleProviderKeys[providerName]; setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible })); }; const toggleProviderKeyEditing = (providerName: string) => { setEditingProviderKeys((prev) => { const nextEditing = !prev[providerName]; if (!nextEditing) { setProviderForms((forms) => ({ ...forms, [providerName]: { apiKey: "", apiBase: forms[providerName]?.apiBase ?? "", }, })); setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false })); } return { ...prev, [providerName]: nextEditing }; }); }; return (

{t("settings.sidebar.title")}

{t(`settings.nav.${activeSection}`)}

{loading ? (
{t("settings.status.loading")}
) : error && !settings ? ( {error} ) : settings ? (
{error ? (
{error}
) : null} {activeSection === "general" ? ( setActiveSection("byok")} /> ) : ( setProviderForms((prev) => ({ ...prev, [provider]: { apiKey: prev[provider]?.apiKey ?? "", apiBase: prev[provider]?.apiBase ?? "", ...value, }, })) } onSaveProvider={saveProvider} onChangeWebSearchForm={setWebSearchForm} onChangeWebSearchProvider={handleWebSearchProviderChange} onToggleWebSearchKey={() => setWebSearchKeyVisible((visible) => !visible)} onToggleWebSearchKeyEditing={() => { setWebSearchKeyEditing((editing) => !editing); setWebSearchKeyVisible(false); setWebSearchForm((prev) => ({ ...prev, apiKey: "" })); }} onResetProviderDraft={resetProviderDraft} onResetWebSearchDraft={resetWebSearchDraft} onSaveWebSearch={saveWebSearch} /> )}
) : null}
); } const SETTINGS_NAV_ITEMS = [ { key: "general", icon: Settings }, { key: "byok", icon: KeyRound }, ] as const; function SettingsSidebar({ activeSection, onSelectSection, onBackToChat, onLogout, }: { activeSection: SettingsSectionKey; onSelectSection: (section: SettingsSectionKey) => void; onBackToChat: () => void; onLogout?: () => void; }) { const { t } = useTranslation(); return ( ); } function GeneralSettings({ theme, onToggleTheme, form, setForm, settings, dirty, saving, onSave, onRestart, isRestarting, onOpenByok, }: { theme: "light" | "dark"; onToggleTheme: () => void; form: { model: string; provider: string; }; setForm: Dispatch>; settings: SettingsPayload; dirty: boolean; saving: boolean; onSave: () => void; onRestart?: () => void; isRestarting?: boolean; onOpenByok: () => void; }) { const { t } = useTranslation(); const configuredProviders = settings.providers.filter((provider) => provider.configured); const providerValue = configuredProviders.some((provider) => provider.name === form.provider) ? form.provider : ""; return (
{t("settings.sections.interface")}
{t("settings.sections.ai")} setForm((prev) => ({ ...prev, provider }))} /> setForm((prev) => ({ ...prev, model: event.target.value }))} className="h-8 w-[280px] rounded-full text-[13px]" /> {(dirty || saving || settings.requires_restart) ? ( ) : null} {configuredProviders.length === 0 ? ( ) : null}
{onRestart && (
{t("settings.sections.system")} {settings.runtime.config_path || t("settings.values.notAvailable")}
)}
); } function ProviderPicker({ providers, value, emptyLabel, onChange, }: { providers: Array<{ name: string; label: string }>; value: string; emptyLabel: string; onChange: (provider: string) => void; }) { const selectedProvider = providers.find((provider) => provider.name === value) ?? null; const disabled = providers.length === 0; return ( {providers.map((provider) => { const selected = provider.name === value; return ( onChange(provider.name)} className={cn( "flex cursor-default items-center justify-between gap-2 rounded-[12px] px-3 py-2 text-[13px]", "focus:bg-muted focus:text-foreground", selected && "bg-primary/10 text-primary focus:bg-primary/12 focus:text-primary", )} > {provider.label} {selected ? : null} ); })} ); } function WebSearchByokSettings({ settings, form, keyVisible, keyEditing, saving, onChangeForm, onChangeProvider, onToggleKey, onToggleKeyEditing, onSave, }: { settings: SettingsPayload; form: WebSearchSettingsUpdate; keyVisible: boolean; keyEditing: boolean; saving: boolean; onChangeForm: Dispatch>; onChangeProvider: (provider: string) => void; onToggleKey: () => void; onToggleKeyEditing: () => void; onSave: () => void; }) { const { t } = useTranslation(); const selectedProvider = settings.web_search.providers.find((provider) => provider.name === form.provider) ?? settings.web_search.providers[0]; const hasExistingSecret = selectedProvider?.credential === "api_key" && form.provider === settings.web_search.provider && !!settings.web_search.api_key_hint; const showKeyInput = selectedProvider?.credential === "api_key" && (!hasExistingSecret || keyEditing); const apiKey = form.apiKey?.trim() ?? ""; const baseUrl = form.baseUrl?.trim() ?? ""; const dirty = form.provider !== settings.web_search.provider || apiKey.length > 0 || baseUrl !== (settings.web_search.base_url ?? ""); const missingCredential = selectedProvider?.credential === "api_key" ? !apiKey && !hasExistingSecret : selectedProvider?.credential === "base_url" ? !baseUrl : false; return (
{selectedProvider?.credential === "none" ? ( {t("settings.byok.webSearch.noCredentialRequired")} ) : null} {selectedProvider?.credential === "api_key" ? (
{showKeyInput ? ( <> onChangeForm((prev) => ({ ...prev, apiKey: event.target.value })) } placeholder={ hasExistingSecret ? t("settings.byok.apiKeyConfiguredPlaceholder") : t("settings.byok.apiKeyPlaceholder") } className="h-9 rounded-full pr-11 text-[13px]" /> ) : ( <>
{settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")}
)}
) : null} {selectedProvider?.credential === "base_url" ? ( onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value })) } placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")} className="h-9 w-[280px] rounded-full text-[13px]" /> ) : null}
{missingCredential ? t("settings.byok.webSearch.missingCredential") : t("settings.byok.webSearch.saveHint")}
); } function ByokSettings({ settings, expandedProvider, providerForms, visibleProviderKeys, editingProviderKeys, providerSaving, webSearchForm, webSearchKeyVisible, webSearchKeyEditing, webSearchSaving, onToggleProvider, onToggleProviderKey, onToggleProviderKeyEditing, onChangeProviderForm, onSaveProvider, onChangeWebSearchForm, onChangeWebSearchProvider, onToggleWebSearchKey, onToggleWebSearchKeyEditing, onResetProviderDraft, onResetWebSearchDraft, onSaveWebSearch, }: { settings: SettingsPayload; expandedProvider: string | null; providerForms: Record; visibleProviderKeys: Record; editingProviderKeys: Record; providerSaving: string | null; webSearchForm: WebSearchSettingsUpdate; webSearchKeyVisible: boolean; webSearchKeyEditing: boolean; webSearchSaving: boolean; onToggleProvider: (provider: string) => void; onToggleProviderKey: (provider: string) => void; onToggleProviderKeyEditing: (provider: string) => void; onChangeProviderForm: (provider: string, value: Partial<{ apiKey: string; apiBase: string }>) => void; onSaveProvider: (provider: string) => void; onChangeWebSearchForm: Dispatch>; onChangeWebSearchProvider: (provider: string) => void; onToggleWebSearchKey: () => void; onToggleWebSearchKeyEditing: () => void; onResetProviderDraft: (provider: string) => void; onResetWebSearchDraft: () => void; onSaveWebSearch: () => void; }) { const { t } = useTranslation(); const [activePane, setActivePane] = useState("llm"); const [showAllUnconfigured, setShowAllUnconfigured] = useState(false); const configuredProviders = settings.providers.filter((provider) => provider.configured); const unconfiguredProviders = useMemo( () => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)), [settings.providers], ); const initialUnconfiguredCount = 6; const visibleUnconfiguredProviders = showAllUnconfigured ? unconfiguredProviders : unconfiguredProviders.slice(0, initialUnconfiguredCount); const hiddenUnconfiguredCount = Math.max( 0, unconfiguredProviders.length - visibleUnconfiguredProviders.length, ); const renderProviderRow = (provider: SettingsPayload["providers"][number]) => { const expanded = expandedProvider === provider.name; const form = providerForms[provider.name] ?? { apiKey: "", apiBase: provider.api_base ?? provider.default_api_base ?? "", }; const saving = providerSaving === provider.name; const keyVisible = !!visibleProviderKeys[provider.name]; const editingKey = !provider.configured || !!editingProviderKeys[provider.name]; const apiKeyRequired = provider.api_key_required ?? true; const apiKey = form.apiKey.trim(); const apiBase = form.apiBase.trim(); const missingRequiredApiKey = apiKeyRequired && !provider.configured && !apiKey; const missingOptionalCredential = !apiKeyRequired && !provider.configured && !apiKey && !apiBase; return (
{expanded ? (
) : null}
); }; const panes: Array<{ key: ByokPaneKey; label: string }> = [ { key: "llm", label: t("settings.byok.tabs.llm") }, { key: "web-search", label: t("settings.byok.tabs.webSearch") }, ]; return (

{t("settings.byok.description")}

{panes.map((pane) => { const selected = activePane === pane.key; return ( ); })}
{activePane === "llm" ? (
{configuredProviders.length > 0 ? (
{configuredProviders.map(renderProviderRow)}
) : ( {t("settings.byok.noConfiguredProviders")} )}
{visibleUnconfiguredProviders.map(renderProviderRow)}
{hiddenUnconfiguredCount > 0 ? ( ) : showAllUnconfigured && unconfiguredProviders.length > initialUnconfiguredCount ? ( ) : null}
) : ( )}
); } function ByokSectionHeader({ title, count }: { title: string; count: number }) { return (

{title}

{count}
); } function ByokEmptyState({ children }: { children: ReactNode }) { return (
{children}
); } function orderUnconfiguredProviders( providers: SettingsPayload["providers"], ): SettingsPayload["providers"] { return providers .map((provider, index) => ({ provider, index })) .sort((left, right) => { const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider); return rank || left.index - right.index; }) .map(({ provider }) => provider); } function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number { const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name); if (localRank !== undefined) return localRank; if ((provider.api_key_required ?? true) === false) return 100; return 200; } const PROVIDER_ICONS: Record = { custom: Hexagon, openrouter: Sparkles, aihubmix: Triangle, anthropic: Brain, openai: Bot, deepseek: Waves, zhipu: Grid3X3, dashscope: Cloud, moonshot: Moon, minimax: Zap, minimax_anthropic: Brain, groq: Cpu, huggingface: Layers, gemini: Gem, mistral: Orbit, siliconflow: Layers, volcengine: Cloud, volcengine_coding_plan: Cloud, byteplus: Cloud, byteplus_coding_plan: Cloud, qianfan: Database, azure_openai: Cloud, bedrock: Database, vllm: Cpu, ollama: Cpu, lm_studio: Cpu, atomic_chat: Cpu, ovms: Cpu, nvidia: Zap, }; function ProviderIcon({ provider }: { provider: string }) { const Icon = PROVIDER_ICONS[provider] ?? Hexagon; return ( ); } function SettingsSectionTitle({ children }: { children: ReactNode }) { return (

{children}

); } function SettingsGroup({ children }: { children: ReactNode }) { return (
{children}
); } function SettingsRow({ title, description, children, }: { title: string; description?: string; children?: ReactNode; }) { return (
{title}
{description ? (
{description}
) : null}
{children ?
{children}
: null}
); } function SettingsFooter({ dirty, saving, saved, onSave, }: { dirty: boolean; saving: boolean; saved: boolean; onSave: () => void; }) { const { t } = useTranslation(); return (
{saved ? t("settings.status.savedRestart") : t("settings.status.unsaved")}
); }