import { useCallback, useEffect, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction, } from "react"; import { Activity, Bot, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, Cloud, Cpu, Database, Eye, EyeOff, Gem, Globe2, Grid3X3, HardDrive, Hexagon, ImageIcon, Info, KeyRound, Layers, Loader2, LogOut, Moon, Orbit, Palette, Pencil, RotateCcw, Search, Server, ShieldCheck, SlidersHorizontal, 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, updateImageGenerationSettings, updateProviderSettings, updateSettings, updateWebSearchSettings, } from "@/lib/api"; import { cn } from "@/lib/utils"; import { useClient } from "@/providers/ClientProvider"; import type { ImageGenerationSettingsUpdate, SettingsPayload, WebSearchSettingsUpdate, } from "@/lib/types"; type SettingsSectionKey = | "overview" | "appearance" | "models" | "providers" | "image" | "web" | "runtime" | "advanced"; type LocalDensity = "comfortable" | "compact"; type LocalActivityMode = "auto" | "expanded"; interface LocalPreferences { density: LocalDensity; activityMode: LocalActivityMode; codeWrap: boolean; } interface AgentSettingsDraft { model: string; provider: string; modelPreset: string; timezone: string; botName: string; botIcon: string; toolHintMaxLength: number; } type PendingRestartSection = "runtime" | "web" | "image"; type PendingRestartSections = Record; const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences"; const DEFAULT_LOCAL_PREFS: LocalPreferences = { density: "comfortable", activityMode: "auto", codeWrap: true, }; const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map( ["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [ name, index, ]), ); const IMAGE_ASPECT_RATIO_OPTIONS = ["1:1", "3:4", "9:16", "4:3", "16:9", "3:2", "2:3", "21:9"]; const IMAGE_SIZE_OPTIONS = ["1K", "2K", "4K", "1024x1024", "1536x1024", "1024x1536"]; const EMPTY_PENDING_RESTART_SECTIONS: PendingRestartSections = { runtime: false, web: false, image: false, }; interface SettingsViewProps { theme: "light" | "dark"; onToggleTheme: () => void; onBackToChat: () => void; onModelNameChange: (modelName: string | null) => void; onLogout?: () => void; onRestart?: () => void; isRestarting?: boolean; } function readLocalPreferences(): LocalPreferences { try { const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY); if (!raw) return DEFAULT_LOCAL_PREFS; const parsed = JSON.parse(raw) as Partial; return { density: parsed.density === "compact" ? "compact" : "comfortable", activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto", codeWrap: parsed.codeWrap !== false, }; } catch { return DEFAULT_LOCAL_PREFS; } } function modelPresetValue(payload: SettingsPayload): string { return payload.agent.model_preset || "default"; } function defaultPreset(payload: SettingsPayload): SettingsPayload["model_presets"][number] | null { return payload.model_presets.find((preset) => preset.is_default) ?? null; } function editableDefaultProvider(payload: SettingsPayload): string { const base = defaultPreset(payload); return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? ""; } 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 [imageGenerationSaving, setImageGenerationSaving] = useState(false); const [error, setError] = useState(null); const [activeSection, setActiveSection] = useState("overview"); const [expandedProvider, setExpandedProvider] = useState(null); const [providerQuery, setProviderQuery] = useState(""); const [providerForms, setProviderForms] = useState>({}); const [visibleProviderKeys, setVisibleProviderKeys] = useState>({}); const [editingProviderKeys, setEditingProviderKeys] = useState>({}); const [pendingRestartSections, setPendingRestartSections] = useState( EMPTY_PENDING_RESTART_SECTIONS, ); const [localPrefs, setLocalPrefs] = useState(() => readLocalPreferences()); const [webSearchForm, setWebSearchForm] = useState({ provider: "duckduckgo", apiKey: "", baseUrl: "", maxResults: 5, timeout: 30, useJinaReader: true, }); const [imageGenerationForm, setImageGenerationForm] = useState({ enabled: false, provider: "openrouter", model: "openai/gpt-5.4-image-2", defaultAspectRatio: "1:1", defaultImageSize: "1K", maxImagesPerTurn: 4, }); const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false); const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false); const [form, setForm] = useState({ model: "", provider: "", modelPreset: "default", timezone: "UTC", botName: "nanobot", botIcon: "", toolHintMaxLength: 40, }); const text = useCallback( (key: string, fallback: string, options?: Record) => t(key, { defaultValue: fallback, ...(options ?? {}) }), [t], ); const applyPayload = useCallback((payload: SettingsPayload) => { const fallbackDefault = defaultPreset(payload); setSettings(payload); setForm({ model: fallbackDefault?.model ?? payload.agent.model, provider: editableDefaultProvider(payload), modelPreset: modelPresetValue(payload), timezone: payload.agent.timezone, botName: payload.agent.bot_name, botIcon: payload.agent.bot_icon, toolHintMaxLength: payload.agent.tool_hint_max_length, }); setWebSearchForm((prev) => ({ provider: payload.web_search.provider, apiKey: prev.provider === payload.web_search.provider ? prev.apiKey ?? "" : "", baseUrl: payload.web_search.base_url ?? "", maxResults: payload.web_search.max_results, timeout: payload.web_search.timeout, useJinaReader: payload.web.fetch.use_jina_reader, })); setImageGenerationForm({ enabled: payload.image_generation.enabled, provider: payload.image_generation.provider, model: payload.image_generation.model, defaultAspectRatio: payload.image_generation.default_aspect_ratio, defaultImageSize: payload.image_generation.default_image_size, maxImagesPerTurn: payload.image_generation.max_images_per_turn, }); if (payload.restart_required_sections) { setPendingRestartSections({ runtime: payload.restart_required_sections.includes("runtime"), web: payload.restart_required_sections.includes("web"), image: payload.restart_required_sections.includes("image"), }); } }, []); 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(() => { try { window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs)); } catch { // Browser-only preferences should never block settings. } }, [localPrefs]); 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 modelDirty = useMemo(() => { if (!settings) return false; const preset = modelPresetValue(settings); const base = defaultPreset(settings); return ( form.modelPreset !== preset || (form.modelPreset === "default" && (form.model !== (base?.model ?? settings.agent.model) || form.provider !== editableDefaultProvider(settings))) ); }, [form, settings]); const runtimeDirty = useMemo(() => { if (!settings) return false; return ( form.timezone !== settings.agent.timezone || form.botName !== settings.agent.bot_name || form.botIcon !== settings.agent.bot_icon || form.toolHintMaxLength !== settings.agent.tool_hint_max_length ); }, [form, settings]); const imageGenerationDirty = useMemo(() => { if (!settings) return false; return ( imageGenerationForm.enabled !== settings.image_generation.enabled || imageGenerationForm.provider !== settings.image_generation.provider || imageGenerationForm.model !== settings.image_generation.model || imageGenerationForm.defaultAspectRatio !== settings.image_generation.default_aspect_ratio || imageGenerationForm.defaultImageSize !== settings.image_generation.default_image_size || imageGenerationForm.maxImagesPerTurn !== settings.image_generation.max_images_per_turn ); }, [imageGenerationForm, settings]); const hasPendingRestart = useMemo( () => !!settings?.requires_restart || pendingRestartSections.runtime || pendingRestartSections.web || pendingRestartSections.image, [pendingRestartSections, settings?.requires_restart], ); const saveModelSettings = async () => { if (!settings || !modelDirty || saving) return; setSaving(true); try { const defaultModel = defaultPreset(settings)?.model ?? settings.agent.model; const defaultProvider = editableDefaultProvider(settings); const payload = await updateSettings(token, { modelPreset: form.modelPreset, ...(form.modelPreset === "default" && form.model !== defaultModel ? { model: form.model } : {}), ...(form.modelPreset === "default" && form.provider !== defaultProvider ? { provider: form.provider } : {}), }); applyPayload(payload); onModelNameChange(payload.agent.model || null); setError(null); } catch (err) { setError((err as Error).message); } finally { setSaving(false); } }; const saveRuntimeSettings = async () => { if (!settings || !runtimeDirty || saving) return; setSaving(true); try { const payload = await updateSettings(token, { timezone: form.timezone, botName: form.botName, botIcon: form.botIcon, toolHintMaxLength: form.toolHintMaxLength, }); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); } setError(null); } catch (err) { setError((err as Error).message); } finally { setSaving(false); } }; const saveImageGenerationSettings = async () => { if (!settings || !imageGenerationDirty || imageGenerationSaving) return; setImageGenerationSaving(true); try { const payload = await updateImageGenerationSettings(token, imageGenerationForm); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, image: true })); } setError(null); } catch (err) { setError((err as Error).message); } finally { setImageGenerationSaving(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); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, image: true })); } 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 webFetchRestartRequired = (webSearchForm.useJinaReader ?? settings.web.fetch.use_jina_reader) !== settings.web.fetch.use_jina_reader; const update: WebSearchSettingsUpdate = { provider: webSearchForm.provider, maxResults: webSearchForm.maxResults, timeout: webSearchForm.timeout, useJinaReader: webSearchForm.useJinaReader, }; 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); if (payload.requires_restart || webFetchRestartRequired) { setPendingRestartSections((prev) => ({ ...prev, web: true })); } setWebSearchForm((prev) => ({ provider: payload.web_search.provider, apiKey: "", baseUrl: payload.web_search.base_url ?? prev.baseUrl ?? "", maxResults: payload.web_search.max_results, timeout: payload.web_search.timeout, useJinaReader: payload.web.fetch.use_jina_reader, })); 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 ?? "", maxResults: settings.web_search.max_results, timeout: settings.web_search.timeout, useJinaReader: settings.web.fetch.use_jina_reader, }); setWebSearchKeyVisible(false); setWebSearchKeyEditing(false); }, [settings]); const handleWebSearchProviderChange = useCallback((provider: string) => { if (!settings) return; setWebSearchForm((prev) => ({ provider, apiKey: "", baseUrl: provider === settings.web_search.provider ? settings.web_search.base_url ?? "" : "", maxResults: prev.maxResults ?? settings.web_search.max_results, timeout: prev.timeout ?? settings.web_search.timeout, useJinaReader: prev.useJinaReader ?? settings.web.fetch.use_jina_reader, })); 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 }; }); }; const renderSection = () => { if (!settings) return null; switch (activeSection) { case "overview": return ( ); case "appearance": return ( ); case "models": return ( setActiveSection("providers")} /> ); case "providers": return ( setProviderForms((prev) => ({ ...prev, [provider]: { apiKey: prev[provider]?.apiKey ?? "", apiBase: prev[provider]?.apiBase ?? "", ...value, }, })) } onSaveProvider={saveProvider} onResetProviderDraft={resetProviderDraft} imageProviderRestartPending={pendingRestartSections.image} onRestart={onRestart} isRestarting={isRestarting} /> ); case "image": return ( setActiveSection("providers")} onRestart={onRestart} isRestarting={isRestarting} requiresRestartPending={pendingRestartSections.image} /> ); case "web": return ( setWebSearchKeyVisible((visible) => !visible)} onToggleKeyEditing={() => { setWebSearchKeyEditing((editing) => !editing); setWebSearchKeyVisible(false); setWebSearchForm((prev) => ({ ...prev, apiKey: "" })); }} onReset={resetWebSearchDraft} onSave={saveWebSearch} onRestart={onRestart} isRestarting={isRestarting} requiresRestartPending={pendingRestartSections.web} /> ); case "runtime": return ( ); case "advanced": return ; default: return null; } }; return (

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

{text(`settings.nav.${activeSection}`, titleForSection(activeSection))}

{loading ? (
{t("settings.status.loading")}
) : error && !settings ? ( {error} ) : settings ? (
{error ? (
{error}
) : null} {renderSection()}
) : null}
); } const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fallback: string }> = [ { key: "overview", icon: Activity, fallback: "Overview" }, { key: "appearance", icon: Palette, fallback: "Appearance" }, { key: "models", icon: SlidersHorizontal, fallback: "Models" }, { key: "providers", icon: KeyRound, fallback: "Providers" }, { key: "image", icon: ImageIcon, fallback: "Image" }, { key: "web", icon: Globe2, fallback: "Web" }, { key: "runtime", icon: Server, fallback: "Runtime" }, { key: "advanced", icon: ShieldCheck, fallback: "Advanced" }, ]; function titleForSection(section: SettingsSectionKey): string { return SETTINGS_NAV_ITEMS.find((item) => item.key === section)?.fallback ?? "Settings"; } function SettingsSidebar({ activeSection, onSelectSection, onBackToChat, onLogout, }: { activeSection: SettingsSectionKey; onSelectSection: (section: SettingsSectionKey) => void; onBackToChat: () => void; onLogout?: () => void; }) { const { t } = useTranslation(); return ( ); } function OverviewSettings({ settings, requiresRestart, onRestart, isRestarting, onSelectSection, }: { settings: SettingsPayload; requiresRestart: boolean; onRestart?: () => void; isRestarting?: boolean; onSelectSection: (section: SettingsSectionKey) => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const configuredCount = settings.providers.filter((provider) => provider.configured).length; const activePreset = settings.agent.model_preset || "default"; const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider; const webStatus = settings.web.enable ? tx("settings.values.enabled", "Enabled") : tx("settings.values.disabled", "Disabled"); const imageStatus = settings.image_generation.enabled ? tx("settings.values.enabled", "Enabled") : tx("settings.values.disabled", "Disabled"); const imageCaption = `${providerLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${ settings.image_generation.provider_configured ? tx("settings.values.configured", "Configured") : tx("settings.values.notConfigured", "Not configured") }`; return (
nanobot
{settings.agent.model}
{activeProvider} · {activePreset}
{requiresRestart ? tx("settings.values.restartPending", "Restart pending") : tx("settings.values.ready", "Ready")} {requiresRestart && onRestart ? ( ) : null}
{tx("settings.sections.ai", "AI")} onSelectSection("models")} /> onSelectSection("providers")} />
{tx("settings.sections.capabilities", "Capabilities")} onSelectSection("web")} /> onSelectSection("image")} />
{tx("settings.sections.system", "System")} onSelectSection("runtime")} /> onSelectSection("runtime")} />
); } function AppearanceSettings({ theme, onToggleTheme, localPrefs, onChangeLocalPrefs, }: { theme: "light" | "dark"; onToggleTheme: () => void; localPrefs: LocalPreferences; onChangeLocalPrefs: Dispatch>; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); return (
{t("settings.sections.interface")}
{tx("settings.sections.localPreferences", "Local preferences")} onChangeLocalPrefs((prev) => ({ ...prev, density: density as LocalDensity })) } /> onChangeLocalPrefs((prev) => ({ ...prev, activityMode: activityMode as LocalActivityMode })) } /> onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))} label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} />
); } function ModelsSettings({ form, setForm, settings, dirty, saving, onSave, onOpenProviders, }: { form: AgentSettingsDraft; setForm: Dispatch>; settings: SettingsPayload; dirty: boolean; saving: boolean; onSave: () => void; onOpenProviders: () => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const configuredProviders = settings.providers.filter((provider) => provider.configured); const showAutoProvider = defaultPreset(settings)?.provider === "auto" || form.provider === "auto"; const providerOptions = showAutoProvider ? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...configuredProviders] : configuredProviders; const providerValue = providerOptions.some((provider) => provider.name === form.provider) ? form.provider : ""; const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset); return (
{tx("settings.sections.presets", "Presets")}
{settings.model_presets.map((preset) => ( ))}
{t("settings.sections.ai")} {selectedPreset?.label ?? form.modelPreset} {form.modelPreset === "default" ? ( <> setForm((prev) => ({ ...prev, provider }))} /> setForm((prev) => ({ ...prev, model: event.target.value }))} className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" /> ) : ( {selectedPreset?.model ?? settings.agent.model} )} {configuredProviders.length === 0 ? ( ) : null}
); } function ProvidersSettings({ settings, expandedProvider, providerForms, visibleProviderKeys, editingProviderKeys, providerSaving, query, onQueryChange, onToggleProvider, onToggleProviderKey, onToggleProviderKeyEditing, onChangeProviderForm, onSaveProvider, onResetProviderDraft, imageProviderRestartPending, onRestart, isRestarting, }: { settings: SettingsPayload; expandedProvider: string | null; providerForms: Record; visibleProviderKeys: Record; editingProviderKeys: Record; providerSaving: string | null; query: string; onQueryChange: (query: string) => void; 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; onResetProviderDraft: (provider: string) => void; imageProviderRestartPending: boolean; onRestart?: () => void; isRestarting?: boolean; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const configuredProviders = settings.providers.filter((provider) => provider.configured); const unconfiguredProviders = useMemo( () => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)), [settings.providers], ); const filteredConfigured = filterProviders(configuredProviders, query); const filteredUnconfigured = filterProviders(unconfiguredProviders, query); 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}
); }; return (

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

{imageProviderRestartPending && onRestart ? (

{tx("settings.status.imageProviderRestart", "Image provider changes saved. Restart when ready.")}

) : null}
onQueryChange(event.target.value)} placeholder={tx("settings.providers.searchPlaceholder", "Search providers")} className="h-10 rounded-full pl-9 text-[13px]" />
{filteredConfigured.map(renderProviderRow)} {filteredUnconfigured.map(renderProviderRow)}
); } function ImageGenerationSettings({ settings, form, dirty, saving, onChangeForm, onSave, onOpenProviders, onRestart, isRestarting, requiresRestartPending, }: { settings: SettingsPayload; form: ImageGenerationSettingsUpdate; dirty: boolean; saving: boolean; onChangeForm: Dispatch>; onSave: () => void; onOpenProviders: () => void; onRestart?: () => void; isRestarting?: boolean; requiresRestartPending: boolean; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const selectedProvider = settings.image_generation.providers.find((provider) => provider.name === form.provider) ?? settings.image_generation.providers[0]; const providerConfigured = !!selectedProvider?.configured; const missingCredential = form.enabled && !providerConfigured; const aspectOptions = optionRowsWithCurrent( IMAGE_ASPECT_RATIO_OPTIONS.map((value) => ({ name: value, label: value })), form.defaultAspectRatio, ); const sizeOptions = optionRowsWithCurrent( IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })), form.defaultImageSize, ); return (
{tx("settings.sections.imageGeneration", "Image generation")} onChangeForm((prev) => ({ ...prev, enabled }))} label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} /> onChangeForm((prev) => ({ ...prev, provider }))} />
{providerConfigured ? tx("settings.values.configured", "Configured") : tx("settings.values.notConfigured", "Not configured")} {!providerConfigured ? ( ) : null}
{selectedProvider?.api_base || selectedProvider?.default_api_base || selectedProvider?.name || tx("settings.values.notAvailable", "Not available")}
{tx("settings.sections.imageDefaults", "Defaults")} onChangeForm((prev) => ({ ...prev, model: event.target.value }))} className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]" /> onChangeForm((prev) => ({ ...prev, defaultAspectRatio })) } /> onChangeForm((prev) => ({ ...prev, defaultImageSize })) } /> onChangeForm((prev) => ({ ...prev, maxImagesPerTurn })) } />
); } function WebSettings({ settings, form, keyVisible, keyEditing, saving, onChangeForm, onChangeProvider, onToggleKey, onToggleKeyEditing, onReset, onSave, onRestart, isRestarting, requiresRestartPending, }: { settings: SettingsPayload; form: WebSearchSettingsUpdate; keyVisible: boolean; keyEditing: boolean; saving: boolean; onChangeForm: Dispatch>; onChangeProvider: (provider: string) => void; onToggleKey: () => void; onToggleKeyEditing: () => void; onReset: () => void; onSave: () => void; onRestart?: () => void; isRestarting?: boolean; requiresRestartPending: boolean; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); 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 effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader; const dirty = form.provider !== settings.web_search.provider || apiKey.length > 0 || baseUrl !== (settings.web_search.base_url ?? "") || form.maxResults !== settings.web_search.max_results || form.timeout !== settings.web_search.timeout || effectiveJinaReader !== settings.web.fetch.use_jina_reader; const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader; const missingCredential = selectedProvider?.credential === "api_key" ? !apiKey && !hasExistingSecret : selectedProvider?.credential === "base_url" ? !baseUrl : false; return (
{tx("settings.sections.webSearch", "Web search")} {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}
{tx("settings.sections.webBehavior", "Behavior")} onChangeForm((prev) => ({ ...prev, maxResults }))} /> onChangeForm((prev) => ({ ...prev, timeout }))} suffix="s" /> onChangeForm((prev) => ({ ...prev, useJinaReader }))} label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} />
); } function RuntimeSettings({ form, setForm, settings, dirty, saving, onSave, onRestart, isRestarting, requiresRestartPending, }: { form: AgentSettingsDraft; setForm: Dispatch>; settings: SettingsPayload; dirty: boolean; saving: boolean; onSave: () => void; onRestart?: () => void; isRestarting?: boolean; requiresRestartPending: boolean; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); return (
{tx("settings.sections.identity", "Identity")} setForm((prev) => ({ ...prev, botName: event.target.value }))} className="h-8 w-[220px] rounded-full text-[13px]" /> setForm((prev) => ({ ...prev, botIcon: event.target.value }))} className="h-8 w-[120px] rounded-full text-center text-[13px]" /> setForm((prev) => ({ ...prev, timezone: event.target.value }))} className="h-8 w-[220px] rounded-full text-[13px]" /> setForm((prev) => ({ ...prev, toolHintMaxLength }))} />
{t("settings.sections.system")} {onRestart && !requiresRestartPending ? ( ) : null}
); } function AdvancedSettings({ settings }: { settings: SettingsPayload }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); return (
{tx("settings.sections.safety", "Safety")}
{tx("settings.sections.integrations", "Integrations")} {tx("settings.actions.openDocs", "Open docs")}
); } 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 ProviderSection({ title, count, empty, children, }: { title: string; count: number; empty: string; children: ReactNode; }) { return (
{count > 0 ? (
{children}
) : ( {empty} )}
); } 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; } function filterProviders( providers: SettingsPayload["providers"], query: string, ): SettingsPayload["providers"] { const normalized = query.trim().toLowerCase(); if (!normalized) return providers; return providers.filter((provider) => `${provider.name} ${provider.label} ${provider.api_base ?? ""} ${provider.default_api_base ?? ""}` .toLowerCase() .includes(normalized), ); } function optionRowsWithCurrent( options: Array<{ name: string; label: string }>, value: string, ): Array<{ name: string; label: string }> { if (!value || options.some((option) => option.name === value)) return options; return [{ name: value, label: value }, ...options]; } function providerLabel( providers: Array<{ name: string; label: string }>, value: string, ): string { return providers.find((provider) => provider.name === value)?.label ?? value; } const PROVIDER_ICONS: Record = { custom: Hexagon, openrouter: Sparkles, skywork: 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, ant_ling: Sparkles, 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 OverviewListRow({ icon: Icon, title, value, caption, onClick, }: { icon: LucideIcon; title: string; value: string; caption: string; onClick: () => void; }) { 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 ReadOnlyRow({ title, value }: { title: string; value: string }) { return ( {value} ); } function RestartSettingsFooter({ dirty, saving, pendingRestart, disabled = false, message, dirtyMessage, pendingMessage, onSave, onRestart, onReset, isRestarting, }: { dirty: boolean; saving: boolean; pendingRestart: boolean; disabled?: boolean; message?: string; dirtyMessage?: string; pendingMessage?: string; onSave: () => void; onRestart?: () => void; onReset?: () => void; isRestarting?: boolean; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const statusMessage = message ?? (pendingRestart && !dirty ? pendingMessage ?? tx("settings.status.savedRestartApply", "Saved. Restart when ready.") : dirty ? dirtyMessage ?? t("settings.status.unsaved") : undefined); const statusTone = disabled ? "danger" : dirty || pendingRestart ? "accent" : undefined; return (
{statusMessage}
{pendingRestart && !dirty && onRestart ? ( ) : null} {onReset ? ( ) : null}
); } function SettingsFooter({ dirty, saving, saved, onSave, }: { dirty: boolean; saving: boolean; saved: boolean; onSave: () => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const statusMessage = dirty ? t("settings.status.unsaved") : saved ? t("settings.status.savedRestart") : tx("settings.status.upToDate", "Up to date."); return (
{statusMessage}
); } function SettingsStatusMessage({ children, tone, }: { children?: ReactNode; tone?: "accent" | "danger"; }) { if (!children) return null; return ( {tone ? ( ) : null} {children} ); } function StatusPill({ children, tone = "neutral", }: { children: ReactNode; tone?: "neutral" | "success" | "warning"; }) { return ( {children} ); } function SegmentedControl({ value, options, onChange, }: { value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void; }) { return (
{options.map((option) => ( ))}
); } function ToggleButton({ checked, onChange, label, }: { checked: boolean; onChange: (checked: boolean) => void; label: string; }) { return ( ); } function NumberInput({ value, min, max, onChange, suffix, }: { value: number; min: number; max: number; onChange: (value: number) => void; suffix?: string; }) { return (
{ const parsed = Number(event.target.value); if (Number.isFinite(parsed)) onChange(parsed); }} className="h-8 w-24 rounded-full text-[13px]" /> {suffix ? {suffix} : null}
); }