import { useCallback, useEffect, forwardRef, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction, } from "react"; import { Activity, Bot, Brain, Check, CircleAlert, ChevronDown, ChevronLeft, ChevronRight, Cloud, Cpu, Database, Eye, EyeOff, Gem, Globe2, Grid3X3, HardDrive, Hexagon, ImageIcon, Layers, Loader2, LogOut, Moon, PlayCircle, Plus, Orbit, Palette, Pencil, RotateCcw, Search, Server, ShieldCheck, SlidersHorizontal, Sparkles, Trash2, Triangle, Waves, X, Zap, type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings"; import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { createModelConfiguration, fetchSettings, fetchSettingsUsage, fetchCliApps, fetchMcpPresets, fetchProviderModels, importMcpConfig, loginProviderOAuth, logoutProviderOAuth, runCliAppAction, runMcpPresetAction, saveCustomMcpServer, updateImageGenerationSettings, updateMcpServerTools, updateModelConfiguration, updateNetworkSafetySettings, updateProviderSettings, updateSettings, updateWebSearchSettings, } from "@/lib/api"; import { notifyCliAppsChanged } from "@/lib/cli-app-events"; import { getHostApi } from "@/lib/runtime"; import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events"; import { logoFallbackUrls, providerBrand, providerDisplayLabel, } from "@/lib/provider-brand"; import { cn } from "@/lib/utils"; import { shortWorkspacePath } from "@/lib/workspace"; import { useClient } from "@/providers/ClientProvider"; import type { CliAppInfo, CliAppsPayload, ImageGenerationSettingsUpdate, McpPresetInfo, McpPresetsPayload, NetworkSafetySettingsUpdate, ProviderModelsPayload, SettingsPayload, SkillSummary, WebSearchSettingsUpdate, WebuiDefaultAccessMode, } from "@/lib/types"; export type SettingsSectionKey = | "overview" | "appearance" | "models" | "image" | "browser" | "apps" | "skills" | "runtime" | "advanced"; type LocalDensity = "comfortable" | "compact"; type LocalActivityMode = "auto" | "expanded"; type AppsKindFilter = "all" | "cli" | "mcp"; type AppsCatalogItem = | { id: string; kind: "cli"; app: CliAppInfo } | { id: string; kind: "mcp"; preset: McpPresetInfo }; interface LocalPreferences { density: LocalDensity; activityMode: LocalActivityMode; codeWrap: boolean; brandLogos: boolean; } interface AgentSettingsDraft { model: string; provider: string; modelPreset: string; presetLabel: string; contextWindowTokens: number; timezone: string; botName: string; botIcon: string; toolHintMaxLength: number; } interface ModelConfigurationDraft { label: string; provider: string; model: string; } type PendingRestartSection = "runtime" | "browser" | "image"; type PendingRestartSections = Record; type RestartAwarePayload = { requires_restart?: boolean; surface?: SettingsPayload["surface"]; runtime_surface?: SettingsPayload["runtime_surface"]; runtime_capabilities?: SettingsPayload["runtime_capabilities"]; }; type ProviderApiType = "auto" | "chat_completions" | "responses"; type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType }; type CustomMcpTransport = "stdio" | "streamableHttp" | "sse"; const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const; const DEFERRED_MODEL_LIST_PROVIDERS = new Set([ "aihubmix", "atomic_chat", "byteplus", "byteplus_coding_plan", "huggingface", "lm_studio", "novita", "ollama", "openrouter", "ovms", "siliconflow", "vllm", "volcengine", "volcengine_coding_plan", ]); const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2; const FALLBACK_TIMEZONES = [ "UTC", "Asia/Shanghai", "Asia/Hong_Kong", "Asia/Tokyo", "Asia/Seoul", "Asia/Singapore", "Asia/Taipei", "Asia/Dubai", "Asia/Kolkata", "Europe/London", "Europe/Paris", "Europe/Berlin", "Europe/Amsterdam", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "America/Toronto", "America/Sao_Paulo", "Australia/Sydney", "Pacific/Auckland", ]; interface CustomMcpForm { name: string; transport: CustomMcpTransport; command: string; args: string; url: string; env: string; headers: string; toolTimeout: string; } const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences"; const DEFAULT_LOCAL_PREFS: LocalPreferences = { density: "comfortable", activityMode: "auto", codeWrap: true, brandLogos: true, }; const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [ { value: "auto", label: "Auto" }, { value: "chat_completions", label: "Chat Completions" }, { value: "responses", label: "Responses" }, ]; 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, browser: false, image: false, }; const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = { name: "", transport: "stdio", command: "", args: "", url: "", env: "", headers: "", toolTimeout: "30", }; interface SettingsViewProps { theme: "light" | "dark"; initialSection?: SettingsSectionKey; initialSettings?: SettingsPayload | null; showSidebar?: boolean; onToggleTheme: () => void; onBackToChat: () => void; onModelNameChange: (modelName: string | null) => void; onSettingsChange?: (payload: SettingsPayload) => void; skills?: SkillSummary[]; onWorkspaceSettingsChange?: () => void | Promise; onSectionChange?: (section: SettingsSectionKey) => void; onLogout?: () => void; onRestart?: () => void; onNativeEngineRestart?: () => Promise; isRestarting?: boolean; hostChromeInset?: 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, brandLogos: parsed.brandLogos !== 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 normalizeContextWindowTokens(value: number | null | undefined): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 65_536; } function editableDefaultProvider(payload: SettingsPayload): string { const base = defaultPreset(payload); return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? ""; } function settingsProviderRow( payload: SettingsPayload, provider: string | null | undefined, ): SettingsPayload["providers"][number] | null { if (!provider) return null; return payload.providers.find((row) => row.name === provider) ?? null; } function settingsProviderConfigured( payload: SettingsPayload, provider: string | null | undefined, ): boolean { const row = settingsProviderRow(payload, provider); if (row) return row.configured; return payload.agent.has_api_key; } const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = { model: "", provider: "", modelPreset: "default", presetLabel: "Default", contextWindowTokens: 65_536, timezone: "UTC", botName: "nanobot", botIcon: "", toolHintMaxLength: 40, }; const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = { provider: "duckduckgo", apiKey: "", baseUrl: "", maxResults: 5, timeout: 30, useJinaReader: true, }; const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = { enabled: false, provider: "openrouter", model: "openai/gpt-5.4-image-2", defaultAspectRatio: "1:1", defaultImageSize: "1K", maxImagesPerTurn: 4, }; const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = { webuiAllowLocalServiceAccess: true, webuiDefaultAccessMode: "default", }; function agentDraftFromPayload(payload: SettingsPayload): AgentSettingsDraft { const fallbackDefault = defaultPreset(payload); const activePresetName = modelPresetValue(payload); const activePreset = payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault; return { model: activePreset?.model ?? payload.agent.model, provider: activePreset?.is_default ? editableDefaultProvider(payload) : activePreset?.provider ?? editableDefaultProvider(payload), modelPreset: activePresetName, presetLabel: activePreset?.label ?? activePresetName, contextWindowTokens: normalizeContextWindowTokens( activePreset?.context_window_tokens ?? payload.agent.context_window_tokens, ), timezone: payload.agent.timezone, botName: payload.agent.bot_name, botIcon: payload.agent.bot_icon, toolHintMaxLength: payload.agent.tool_hint_max_length, }; } function webSearchFormFromPayload( payload: SettingsPayload, previous?: WebSearchSettingsUpdate, ): WebSearchSettingsUpdate { return { provider: payload.web_search.provider, apiKey: previous?.provider === payload.web_search.provider ? previous.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, }; } function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate { return { 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, }; } function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate { return { webuiAllowLocalServiceAccess: payload.advanced.webui_allow_local_service_access ?? payload.advanced.allow_local_preview_access ?? true, webuiDefaultAccessMode: visibleWebuiDefaultAccessMode( payload.advanced.webui_default_access_mode, ), }; } function pendingRestartSectionsFromPayload(payload: SettingsPayload): PendingRestartSections { const sections = payload.restart_required_sections ?? []; return { runtime: sections.includes("runtime"), browser: sections.includes("browser"), image: sections.includes("image"), }; } export function SettingsView({ theme, initialSection = "overview", initialSettings = null, showSidebar = true, onToggleTheme, onBackToChat, onModelNameChange, onSettingsChange, skills = [], onWorkspaceSettingsChange, onSectionChange, onLogout, onRestart, onNativeEngineRestart, isRestarting = false, hostChromeInset = false, }: SettingsViewProps) { const { t } = useTranslation(); const { token } = useClient(); const [settings, setSettings] = useState(() => initialSettings); const [cliApps, setCliApps] = useState(null); const [mcpPresets, setMcpPresets] = useState(null); const [loading, setLoading] = useState(() => initialSettings === null); const [cliAppsLoading, setCliAppsLoading] = useState(true); const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true); const [saving, setSaving] = useState(false); const [modelConfigurationOpen, setModelConfigurationOpen] = useState(false); const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false); const [modelConfigurationForm, setModelConfigurationForm] = useState({ label: "", provider: "", model: "", }); const [cliAppsAction, setCliAppsAction] = useState(null); const [mcpPresetAction, setMcpPresetAction] = useState(null); const [providerSaving, setProviderSaving] = useState(null); const [webSearchSaving, setWebSearchSaving] = useState(false); const [imageGenerationSaving, setImageGenerationSaving] = useState(false); const [networkSafetySaving, setNetworkSafetySaving] = useState(false); const [hostEngineApplying, setHostEngineApplying] = useState(false); const [error, setError] = useState(null); const [activeSection, setActiveSection] = useState(initialSection); const [expandedProvider, setExpandedProvider] = useState(null); const [providerQuery, setProviderQuery] = useState(""); const [appsQuery, setAppsQuery] = useState(""); const [cliAppsMessage, setCliAppsMessage] = useState(null); const [cliAppsError, setCliAppsError] = useState(null); const [cliAppsFocusName, setCliAppsFocusName] = useState(null); const [appsKindFilter, setAppsKindFilter] = useState("all"); const [mcpMessage, setMcpMessage] = useState(null); const [mcpError, setMcpError] = useState(null); const [mcpFieldValues, setMcpFieldValues] = useState>>({}); const [customMcpForm, setCustomMcpForm] = useState(DEFAULT_CUSTOM_MCP_FORM); const [mcpConfigImport, setMcpConfigImport] = 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(() => initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM, ); const [imageGenerationForm, setImageGenerationForm] = useState( () => initialSettings ? imageGenerationFormFromPayload(initialSettings) : DEFAULT_IMAGE_GENERATION_FORM, ); const [networkSafetyForm, setNetworkSafetyForm] = useState(() => initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM, ); useEffect(() => { setActiveSection(initialSection); }, [initialSection]); const selectSection = useCallback( (section: SettingsSectionKey) => { setActiveSection(section); onSectionChange?.(section); }, [onSectionChange], ); const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false); const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false); const [form, setForm] = useState(() => initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT, ); const text = useCallback( (key: string, fallback: string, options?: Record) => t(key, { defaultValue: fallback, ...(options ?? {}) }), [t], ); const applyPayload = useCallback((payload: SettingsPayload) => { setSettings(payload); setForm(agentDraftFromPayload(payload)); setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev)); setImageGenerationForm(imageGenerationFormFromPayload(payload)); setNetworkSafetyForm(networkSafetyFormFromPayload(payload)); if (payload.restart_required_sections) { setPendingRestartSections(pendingRestartSectionsFromPayload(payload)); } onSettingsChange?.(payload); }, [onSettingsChange]); useEffect(() => { if (!initialSettings || settings !== null) return; applyPayload(initialSettings); setLoading(false); }, [applyPayload, initialSettings, settings]); useEffect(() => { let cancelled = false; const showLoading = settings === null; if (showLoading) setLoading(true); fetchSettings(token) .then((payload) => { if (!cancelled) { applyPayload(payload); setError(null); } }) .catch((err) => { if (!cancelled && showLoading) setError((err as Error).message); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [applyPayload, token]); const hasSettings = settings !== null; useEffect(() => { if (activeSection !== "overview" || !hasSettings) return; let cancelled = false; const refresh = () => { fetchSettingsUsage(token) .then((usage) => { if (cancelled) return; setSettings((current) => (current ? { ...current, usage } : current)); }) .catch(() => {}); }; void refresh(); const interval = window.setInterval(refresh, 5000); const onFocus = () => refresh(); const onVisibilityChange = () => { if (document.visibilityState === "visible") refresh(); }; window.addEventListener("focus", onFocus); document.addEventListener("visibilitychange", onVisibilityChange); return () => { cancelled = true; window.clearInterval(interval); window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisibilityChange); }; }, [activeSection, hasSettings, token]); useEffect(() => { if (activeSection !== "apps") return; let cancelled = false; setCliAppsLoading(true); fetchCliApps(token) .then((payload) => { if (!cancelled) { setCliApps(payload); setCliAppsError(null); } }) .catch((err) => { if (!cancelled) setCliAppsError((err as Error).message); }) .finally(() => { if (!cancelled) setCliAppsLoading(false); }); return () => { cancelled = true; }; }, [activeSection, token]); useEffect(() => { if (activeSection !== "apps") return; let cancelled = false; setMcpPresetsLoading(true); fetchMcpPresets(token) .then((payload) => { if (!cancelled) { setMcpPresets(payload); setMcpError(null); } }) .catch((err) => { if (!cancelled) setMcpError((err as Error).message); }) .finally(() => { if (!cancelled) setMcpPresetsLoading(false); }); return () => { cancelled = true; }; }, [activeSection, 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 ?? "", apiType: next[provider.name]?.apiType ?? provider.api_type ?? "auto", }; } return next; }); }, [settings]); const modelDirty = useMemo(() => { if (!settings) return false; const activePresetName = modelPresetValue(settings); const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset); if (!selectedPreset) return form.modelPreset !== activePresetName; const selectedProvider = selectedPreset.is_default ? editableDefaultProvider(settings) : selectedPreset.provider; return ( form.modelPreset !== activePresetName || form.model !== selectedPreset.model || form.provider !== selectedProvider || form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) || (!selectedPreset.is_default && form.presetLabel.trim() !== selectedPreset.label) ); }, [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, 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 networkSafetyDirty = useMemo(() => { if (!settings) return false; const currentLocalServiceAccess = settings.advanced.webui_allow_local_service_access ?? settings.advanced.allow_local_preview_access ?? true; const currentDefaultAccess = visibleWebuiDefaultAccessMode(settings.advanced.webui_default_access_mode); return ( networkSafetyForm.webuiAllowLocalServiceAccess !== currentLocalServiceAccess || networkSafetyForm.webuiDefaultAccessMode !== currentDefaultAccess ); }, [networkSafetyForm, settings]); const configuredModelProviderOptions = useMemo( () => settings?.providers .filter((provider) => provider.configured) .map((provider) => ({ name: provider.name, label: provider.label })) ?? [], [settings], ); const hasPendingRestart = useMemo( () => !!settings?.requires_restart || pendingRestartSections.runtime || pendingRestartSections.browser || pendingRestartSections.image, [pendingRestartSections, settings?.requires_restart], ); const restartViaSettingsSurface = useCallback(async () => { const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native"; if ( isNativeHost && settings?.runtime_capabilities?.can_restart_engine && onNativeEngineRestart ) { setHostEngineApplying(true); try { const nextToken = await onNativeEngineRestart(); const payload = await fetchSettings(nextToken); applyPayload(payload); setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS); setError(null); } catch (err) { setError((err as Error).message); } finally { setHostEngineApplying(false); } return; } onRestart?.(); }, [applyPayload, onNativeEngineRestart, onRestart, settings]); const maybeRestartHostEngine = useCallback( async (payload: RestartAwarePayload) => { const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface; const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities; const isNativeHost = surface === "native"; if ( !payload.requires_restart || !isNativeHost || !capabilities?.can_restart_engine || !onNativeEngineRestart ) { return; } setHostEngineApplying(true); try { const nextToken = await onNativeEngineRestart(); const refreshed = await fetchSettings(nextToken); applyPayload(refreshed); setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS); setError(null); } catch (err) { setError((err as Error).message); } finally { setHostEngineApplying(false); } }, [applyPayload, onNativeEngineRestart, settings], ); const saveModelSettings = async () => { if (!settings || !modelDirty || saving) return; setSaving(true); try { const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset); let payload: SettingsPayload; if (selectedPreset && !selectedPreset.is_default) { payload = await updateModelConfiguration(token, { name: selectedPreset.name, label: form.presetLabel.trim(), model: form.model, provider: form.provider, ...(form.contextWindowTokens !== selectedPreset.context_window_tokens ? { contextWindowTokens: form.contextWindowTokens } : {}), }); } else { const defaultModel = defaultPreset(settings)?.model ?? settings.agent.model; const defaultProvider = editableDefaultProvider(settings); const defaultContextWindowTokens = normalizeContextWindowTokens( defaultPreset(settings)?.context_window_tokens ?? settings.agent.context_window_tokens, ); payload = await updateSettings(token, { modelPreset: form.modelPreset, ...(form.model !== defaultModel ? { model: form.model } : {}), ...(form.provider !== defaultProvider ? { provider: form.provider } : {}), ...(form.contextWindowTokens !== defaultContextWindowTokens ? { contextWindowTokens: form.contextWindowTokens } : {}), }); } applyPayload(payload); onModelNameChange(payload.agent.model || null); setError(null); } catch (err) { setError((err as Error).message); } finally { setSaving(false); } }; const openModelConfigurationDialog = () => { if (!settings) return; const currentProvider = settings.agent.provider; const provider = configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ?? configuredModelProviderOptions[0]?.name ?? ""; setModelConfigurationForm({ label: "", provider, model: "", }); setModelConfigurationOpen(true); }; const handleCreateModelConfiguration = async () => { if (modelConfigurationSaving) return; const label = modelConfigurationForm.label.trim(); const provider = modelConfigurationForm.provider.trim(); const model = modelConfigurationForm.model.trim(); if (!label || !provider || !model) return; setModelConfigurationSaving(true); try { const payload = await createModelConfiguration(token, { label, provider, model, }); applyPayload(payload); onModelNameChange(payload.agent.model || null); setModelConfigurationOpen(false); setError(null); } catch (err) { setError((err as Error).message); } finally { setModelConfigurationSaving(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, }); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); } await onWorkspaceSettingsChange?.(); await maybeRestartHostEngine(payload); 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 })); } await maybeRestartHostEngine(payload); setError(null); } catch (err) { setError((err as Error).message); } finally { setImageGenerationSaving(false); } }; const saveNetworkSafetySettings = async () => { if (!settings || !networkSafetyDirty || networkSafetySaving) return; setNetworkSafetySaving(true); try { const payload = await updateNetworkSafetySettings(token, networkSafetyForm); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); } await maybeRestartHostEngine(payload); setError(null); } catch (err) { setError((err as Error).message); } finally { setNetworkSafetySaving(false); } }; const saveProvider = async (providerName: string) => { if (providerSaving) return; const provider = settings?.providers.find((item) => item.name === providerName); if (!provider) return; if (provider.auth_type === "oauth") return; const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "", apiType: "auto" }; 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(), apiType: providerForm.apiType, }); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, image: true })); } await maybeRestartHostEngine(payload); setProviderForms((prev) => ({ ...prev, [providerName]: { apiKey: "", apiBase: providerForm.apiBase.trim(), apiType: providerForm.apiType, }, })); setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false })); setError(null); } catch (err) { setError((err as Error).message); } finally { setProviderSaving(null); } }; const runProviderOAuth = async (providerName: string, action: "login" | "logout") => { if (providerSaving) return; setProviderSaving(providerName); try { const payload = action === "login" ? await loginProviderOAuth(token, providerName) : await logoutProviderOAuth(token, providerName); applyPayload(payload); setExpandedProvider(providerName); 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, browser: true })); } await maybeRestartHostEngine(payload); 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 ?? "", apiType: provider.api_type ?? "auto", }, })); 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 ?? "", apiType: forms[providerName]?.apiType ?? "auto", }, })); setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false })); } return { ...prev, [providerName]: nextEditing }; }); }; const handleCliAppAction = async ( action: "install" | "update" | "uninstall" | "test", name: string, ) => { const key = `${action}:${name}`; setCliAppsAction(key); setCliAppsMessage(null); setCliAppsError(null); try { const payload = await runCliAppAction(token, action, name); setCliApps(payload); if (action !== "test") { notifyCliAppsChanged(payload); } setCliAppsMessage(payload.last_action?.message ?? null); setCliAppsFocusName(action === "uninstall" ? null : name); } catch (err) { setCliAppsError((err as Error).message); } finally { setCliAppsAction(null); } }; const handleMcpPresetAction = async ( action: "enable" | "remove" | "test", name: string, values: Record = {}, ) => { const key = `${action}:${name}`; setMcpPresetAction(key); setMcpMessage(null); setMcpError(null); try { const payload = await runMcpPresetAction(token, action, name, values); setMcpPresets(payload); setMcpMessage(payload.last_action?.message ?? null); if (action !== "test") { notifyMcpPresetsChanged(payload); } if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); } await maybeRestartHostEngine(payload); if (action === "enable") { setMcpFieldValues((prev) => ({ ...prev, [name]: {} })); } } catch (err) { setMcpError((err as Error).message); } finally { setMcpPresetAction(null); } }; const handleSaveCustomMcp = async () => { const name = customMcpForm.name.trim(); const key = `custom:${name || "new"}`; setMcpPresetAction(key); setMcpMessage(null); setMcpError(null); try { const payload = await saveCustomMcpServer(token, { name, transport: customMcpForm.transport, command: customMcpForm.command, args: customMcpForm.args, url: customMcpForm.url, env: customMcpForm.env, headers: customMcpForm.headers, tool_timeout: customMcpForm.toolTimeout, }); setMcpPresets(payload); setMcpMessage(payload.last_action?.message ?? null); notifyMcpPresetsChanged(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); } await maybeRestartHostEngine(payload); setCustomMcpForm((prev) => ({ ...DEFAULT_CUSTOM_MCP_FORM, transport: prev.transport })); } catch (err) { setMcpError((err as Error).message); } finally { setMcpPresetAction(null); } }; const handleImportMcpConfig = async () => { setMcpPresetAction("import"); setMcpMessage(null); setMcpError(null); try { const payload = await importMcpConfig(token, mcpConfigImport); setMcpPresets(payload); setMcpMessage(payload.last_action?.message ?? null); notifyMcpPresetsChanged(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); } await maybeRestartHostEngine(payload); setMcpConfigImport(""); } catch (err) { setMcpError((err as Error).message); } finally { setMcpPresetAction(null); } }; const handleMcpToolsChange = async (name: string, enabledTools: string[]) => { setMcpPresetAction(`tools:${name}`); setMcpMessage(null); setMcpError(null); try { const payload = await updateMcpServerTools(token, name, enabledTools); setMcpPresets(payload); setMcpMessage(payload.last_action?.message ?? null); notifyMcpPresetsChanged(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); } await maybeRestartHostEngine(payload); } catch (err) { setMcpError((err as Error).message); } finally { setMcpPresetAction(null); } }; const renderSection = () => { if (!settings) return null; switch (activeSection) { case "overview": return ( ); case "appearance": return ( ); case "models": return (
runProviderOAuth(provider, "login")} onSave={saveModelSettings} onCreateConfiguration={openModelConfigurationDialog} /> setProviderForms((prev) => ({ ...prev, [provider]: { apiKey: prev[provider]?.apiKey ?? "", apiBase: prev[provider]?.apiBase ?? "", apiType: prev[provider]?.apiType ?? "auto", ...value, }, })) } onSaveProvider={saveProvider} onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")} onProviderOAuthLogout={(provider) => runProviderOAuth(provider, "logout")} onResetProviderDraft={resetProviderDraft} imageProviderRestartPending={pendingRestartSections.image} onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} />
); case "image": return ( selectSection("models")} showBrandLogos={localPrefs.brandLogos} onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} requiresRestartPending={pendingRestartSections.image} /> ); case "browser": return ( setWebSearchKeyVisible((visible) => !visible)} onToggleKeyEditing={() => { setWebSearchKeyEditing((editing) => !editing); setWebSearchKeyVisible(false); setWebSearchForm((prev) => ({ ...prev, apiKey: "" })); }} onReset={resetWebSearchDraft} onSave={saveWebSearch} showBrandLogos={localPrefs.brandLogos} onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} requiresRestartPending={pendingRestartSections.browser} /> ); case "apps": return ( { setCliAppsMessage(null); setCliAppsError(null); setMcpMessage(null); setMcpError(null); }} onBackToChat={onBackToChat} onMcpFieldChange={(presetName, fieldName, value) => { setMcpFieldValues((prev) => ({ ...prev, [presetName]: { ...(prev[presetName] ?? {}), [fieldName]: value, }, })); }} onCustomMcpFormChange={setCustomMcpForm} onMcpConfigImportChange={setMcpConfigImport} onSaveCustomMcp={handleSaveCustomMcp} onImportMcpConfig={handleImportMcpConfig} onMcpToolsChange={handleMcpToolsChange} onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} /> ); case "skills": return ; case "runtime": return ( ); case "advanced": return ( ); default: return null; } }; return (
{showSidebar ? ( ) : null}
{!showSidebar ? ( ) : null}

{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: "image", icon: ImageIcon, fallback: "Image" }, { key: "browser", icon: Globe2, fallback: "Web" }, { key: "runtime", icon: Server, fallback: "System" }, { key: "advanced", icon: ShieldCheck, fallback: "Security" }, ]; function visibleWebuiDefaultAccessMode(mode: string | null | undefined): WebuiDefaultAccessMode { return mode === "full" ? "full" : "default"; } function titleForSection(section: SettingsSectionKey): string { return SETTINGS_NAV_ITEMS.find((item) => item.key === section)?.fallback ?? "Settings"; } function SettingsSidebar({ activeSection, onSelectSection, onBackToChat, onLogout, hostChromeInset, }: { activeSection: SettingsSectionKey; onSelectSection: (section: SettingsSectionKey) => void; onBackToChat: () => void; onLogout?: () => void; hostChromeInset?: boolean; }) { const { t } = useTranslation(); return ( ); } function OverviewSettings({ settings, requiresRestart, onSelectSection, showBrandLogos, }: { settings: SettingsPayload; requiresRestart: boolean; onSelectSection: (section: SettingsSectionKey) => void; showBrandLogos: boolean; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const activePreset = settings.agent.model_preset || "default"; const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider; const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider); const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider); const activeModelValue = activeProviderConfigured ? settings.agent.model : tx("settings.values.notConfigured", "Not configured"); const activeModelCaption = activeProviderConfigured ? `${activeProvider} · ${activePreset}` : activeProviderLabel || settings.agent.model ? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ") : tx("settings.byok.noConfiguredProviders", "No configured providers"); 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 = `${providerDisplayLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${ settings.image_generation.provider_configured ? tx("settings.values.configured", "Configured") : tx("settings.values.notConfigured", "Not configured") }`; const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native"; const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path); const runtimeTitle = isNativeHost ? tx("settings.rows.engine", "Engine") : tx("settings.rows.gateway", "Gateway"); const runtimeValue = isNativeHost ? tx("settings.values.privateEngine", "Private engine") : `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`; const runtimeCaption = isNativeHost ? tx("settings.values.unixSocket", "Unix socket") : requiresRestart ? tx("settings.values.restartPending", "Restart pending") : tx("settings.values.ready", "Ready"); return (
{tx("settings.sections.ai", "AI")} onSelectSection("models")} />
{tx("settings.sections.capabilities", "Capabilities")} onSelectSection("browser")} /> 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 }))} ariaLabel={tx("settings.rows.codeWrap", "Code wrapping")} label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} /> onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))} ariaLabel={tx("settings.rows.brandLogos", "Brand logos")} label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} />
); } function NewModelConfigurationDialog({ open, draft, providers, saving, showProviderLogos, onOpenChange, onChangeDraft, onSave, }: { open: boolean; draft: ModelConfigurationDraft; providers: Array<{ name: string; label: string }>; saving: boolean; showProviderLogos: boolean; onOpenChange: (open: boolean) => void; onChangeDraft: Dispatch>; onSave: () => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const canSave = Boolean(draft.label.trim() && draft.provider.trim() && draft.model.trim()); return (
{ event.preventDefault(); onSave(); }} > {tx("settings.models.newConfiguration", "New model configuration")} {tx("settings.models.newConfigurationHelp", "Save a provider and model as a one-click option.")}
{tx("settings.rows.provider", "Provider")} onChangeDraft((prev) => ({ ...prev, provider })) } />
); } function ModelsSettings({ token, form, setForm, settings, dirty, saving, showBrandLogos, providerSaving, onProviderOAuthLogin, onSave, onCreateConfiguration, }: { token: string; form: AgentSettingsDraft; setForm: Dispatch>; settings: SettingsPayload; dirty: boolean; saving: boolean; showBrandLogos: boolean; providerSaving: string | null; onProviderOAuthLogin: (provider: string) => void; onSave: () => void; onCreateConfiguration: () => 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 selectableProviders = uniqueProviders(configuredProviders); const providerOptions = showAutoProvider ? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders] : selectableProviders; const providerValue = providerOptions.some((provider) => provider.name === form.provider) ? form.provider : ""; const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset) ?? null; const selectedProvider = settings.providers.find((provider) => provider.name === form.provider); const selectedProviderNeedsSignIn = selectedProvider?.auth_type === "oauth" && !selectedProvider.configured; const selectedProviderSigningIn = providerSaving === selectedProvider?.name; const selectedProviderConfigured = settingsProviderConfigured(settings, form.provider); const modelFieldsMissing = !form.model.trim() || !form.provider.trim() || Boolean(selectedPreset && !selectedPreset.is_default && !form.presetLabel.trim()); return (
{ const nextPreset = settings.model_presets.find((preset) => preset.name === modelPreset); setForm((prev) => ({ ...prev, modelPreset, model: nextPreset?.model ?? prev.model, provider: nextPreset?.is_default ? editableDefaultProvider(settings) : nextPreset?.provider ?? prev.provider, presetLabel: nextPreset?.label ?? modelPreset, contextWindowTokens: normalizeContextWindowTokens( nextPreset?.context_window_tokens ?? prev.contextWindowTokens, ), })); }} onCreateConfiguration={onCreateConfiguration} /> {selectedPreset && !selectedPreset.is_default ? ( setForm((prev) => ({ ...prev, presetLabel: event.target.value })) } className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" /> ) : null} setForm((prev) => ({ ...prev, provider, model: provider === prev.provider ? prev.model : "", })) } /> {selectedProviderNeedsSignIn ? ( ) : null} setForm((prev) => ({ ...prev, model }))} /> ({ value: String(tokens), label: tokens === 262_144 ? "256K" : "64K", }))} onChange={(value) => setForm((prev) => ({ ...prev, contextWindowTokens: normalizeContextWindowTokens(Number(value)), })) } />
); } function ProvidersSettings({ settings, expandedProvider, providerForms, visibleProviderKeys, editingProviderKeys, providerSaving, query, showBrandLogos, onQueryChange, onToggleProvider, onToggleProviderKey, onToggleProviderKeyEditing, onChangeProviderForm, onSaveProvider, onProviderOAuthLogin, onProviderOAuthLogout, onResetProviderDraft, imageProviderRestartPending, onRestart, isRestarting, }: { settings: SettingsPayload; expandedProvider: string | null; providerForms: Record; visibleProviderKeys: Record; editingProviderKeys: Record; providerSaving: string | null; query: string; showBrandLogos: boolean; onQueryChange: (query: string) => void; onToggleProvider: (provider: string) => void; onToggleProviderKey: (provider: string) => void; onToggleProviderKeyEditing: (provider: string) => void; onChangeProviderForm: (provider: string, value: Partial) => void; onSaveProvider: (provider: string) => void; onProviderOAuthLogin: (provider: string) => void; onProviderOAuthLogout: (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 ?? "", apiType: provider.api_type ?? "auto", }; const saving = providerSaving === provider.name; const isOauthProvider = provider.auth_type === "oauth"; 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 = !isOauthProvider && apiKeyRequired && !provider.configured && !apiKey; const missingOptionalCredential = !isOauthProvider && !apiKeyRequired && !provider.configured && !apiKey && !apiBase; return (
{expanded ? (
{isOauthProvider ? (

{tx("settings.oauth.authentication", "OAuth authentication")}

{provider.configured ? t("settings.oauth.signedInAs", { account: provider.oauth_account || provider.label, defaultValue: "Signed in as {{account}}", }) : tx("settings.oauth.signInHelp", "Sign in from this device; no API key is stored in config.")}

{provider.configured ? ( ) : null}
) : ( <> {provider.name === "openai" ? ( ) : null}
)}
) : 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, showBrandLogos, onRestart, isRestarting, requiresRestartPending, }: { settings: SettingsPayload; form: ImageGenerationSettingsUpdate; dirty: boolean; saving: boolean; onChangeForm: Dispatch>; onSave: () => void; onOpenProviders: () => void; showBrandLogos: boolean; 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 }))} ariaLabel={tx("settings.rows.imageGeneration", "Image generation")} 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, showBrandLogos, 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; showBrandLogos: boolean; 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 }))} ariaLabel={tx("settings.rows.jinaReader", "Jina reader")} label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} />
); } function AppsCatalogSettings({ cliApps, mcpPresets, cliAppsLoading, mcpPresetsLoading, query, filter, cliActionKey, mcpActionKey, cliMessage, cliError, cliFocusName, mcpMessage, mcpError, mcpFieldValues, customMcpForm, mcpConfigImport, showBrandLogos, requiresRestartPending, onQueryChange, onFilterChange, onCliAction, onMcpAction, onDismissStatus, onBackToChat, onMcpFieldChange, onCustomMcpFormChange, onMcpConfigImportChange, onSaveCustomMcp, onImportMcpConfig, onMcpToolsChange, onRestart, isRestarting, }: { cliApps: CliAppsPayload | null; mcpPresets: McpPresetsPayload | null; cliAppsLoading: boolean; mcpPresetsLoading: boolean; query: string; filter: AppsKindFilter; cliActionKey: string | null; mcpActionKey: string | null; cliMessage: string | null; cliError: string | null; cliFocusName: string | null; mcpMessage: string | null; mcpError: string | null; mcpFieldValues: Record>; customMcpForm: CustomMcpForm; mcpConfigImport: string; showBrandLogos: boolean; requiresRestartPending: boolean; onQueryChange: (value: string) => void; onFilterChange: (value: AppsKindFilter) => void; onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; onDismissStatus: () => void; onBackToChat: () => void; onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void; onCustomMcpFormChange: Dispatch>; onMcpConfigImportChange: (value: string) => void; onSaveCustomMcp: () => void; onImportMcpConfig: () => void; onMcpToolsChange: (name: string, enabledTools: string[]) => void; onRestart?: () => void; isRestarting?: boolean; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const filterOptions = [ { value: "all", label: tx("settings.apps.filterAll", "All") }, { value: "cli", label: tx("settings.apps.filterCli", "App CLIs") }, { value: "mcp", label: tx("settings.apps.filterMcp", "MCP services") }, ]; const normalizedQuery = query.trim().toLowerCase(); const items: AppsCatalogItem[] = [ ...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })), ...(mcpPresets?.presets ?? []).map((preset) => ({ id: `mcp:${preset.name}`, kind: "mcp" as const, preset, })), ] .filter((item) => filter === "all" || item.kind === filter) .filter((item) => !normalizedQuery || appsSearchText(item).includes(normalizedQuery)) .sort((left, right) => { const rank = Number(!appsReady(left)) - Number(!appsReady(right)); return rank || appsTitle(left).localeCompare(appsTitle(right)); }); const focusedApp = cliFocusName ? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed) : null; const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets; const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null); const statusIsError = Boolean(cliError || mcpError); const caption = t("settings.apps.caption", { cli: cliApps?.installed_count ?? 0, mcp: mcpPresets?.installed_count ?? 0, defaultValue: "{{cli}} CLI · {{mcp}} MCP", }); return (

{tx( "settings.apps.description", "Add local app adapters and connected tool servers that nanobot can use from chat.", )}

{caption}
onQueryChange(event.target.value)} placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")} className="h-12 rounded-[14px] border-border/70 bg-card/90 pl-11 text-[15px] shadow-sm" />
onFilterChange(value as AppsKindFilter)} />
{statusMessage ? (
{statusMessage}
) : null} {focusedApp ? ( ) : null} {requiresRestartPending ? (
{tx("settings.mcp.restartRequired", "Restart nanobot to connect updated MCP tools.")} {onRestart ? ( ) : null}
) : null}
{tx("settings.apps.featured", "Featured")} {items.length}
{loading ? (
{tx("settings.apps.loading", "Loading Apps...")}
) : items.length ? (
{items.map((item) => item.kind === "cli" ? ( ) : ( ), )}
) : (
{tx("settings.apps.empty", "No apps match this filter.")}
)}
{filter !== "cli" ? ( ) : null}
); } function CliAppsCatalogRow({ app, actionKey, showBrandLogos, onAction, }: { app: CliAppInfo; actionKey: string | null; showBrandLogos: boolean; onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const installBusy = actionKey === `install:${app.name}`; const updateBusy = actionKey === `update:${app.name}`; const uninstallBusy = actionKey === `uninstall:${app.name}`; const testBusy = actionKey === `test:${app.name}`; const busy = installBusy || updateBusy || uninstallBusy || testBusy; const description = app.description || app.requires || app.entry_point || app.name; return (

{app.display_name}

{tx("settings.apps.cliLabel", "CLI")}

{description}

{app.installed ? ( <> onAction("test", app.name)}> {tx("settings.cliApps.test", "Test CLI")} onAction("update", app.name)}> {tx("settings.cliApps.update", "Update CLI")} onAction("uninstall", app.name)}> {tx("settings.cliApps.uninstall", "Uninstall CLI")} onAction("uninstall", app.name)} > ) : app.install_supported ? ( onAction("install", app.name)} > ) : ( )}
); } function McpAppsCatalogRow({ preset, values, actionKey, showBrandLogos, onFieldChange, onAction, onToolsChange, }: { preset: McpPresetInfo; values: Record; actionKey: string | null; showBrandLogos: boolean; onFieldChange: (presetName: string, fieldName: string, value: string) => void; onAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; onToolsChange: (name: string, enabledTools: string[]) => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const [setupOpen, setSetupOpen] = useState(false); const [toolsOpen, setToolsOpen] = useState(false); const enableBusy = actionKey === `enable:${preset.name}`; const removeBusy = actionKey === `remove:${preset.name}`; const testBusy = actionKey === `test:${preset.name}`; const toolsBusy = actionKey === `tools:${preset.name}`; const busy = enableBusy || removeBusy || testBusy || toolsBusy; const missingFields = preset.required_fields.filter((field) => field.required && !field.configured); const hasFields = preset.required_fields.length > 0; const needsSetupInput = missingFields.length > 0; const readyInstalled = preset.installed && preset.configured; const canEnable = preset.install_supported && (missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim()))); const toolNames = preset.tool_names ?? []; const enabledTools = preset.enabled_tools ?? ["*"]; const allowAllTools = enabledTools.includes("*"); const enabledSet = new Set(allowAllTools ? toolNames : enabledTools); const description = preset.description || preset.note || preset.requires || preset.name; const statusLabel = mcpPresetStatusLabel(preset.status, tx); useEffect(() => { if (preset.configured || !preset.install_supported) setSetupOpen(false); }, [preset.configured, preset.install_supported]); const enableOrOpenSetup = () => { if (needsSetupInput || (preset.installed && !preset.configured && hasFields)) { setSetupOpen(true); return; } onAction("enable", preset.name, values); }; const submitSetup = () => { if (!canEnable) return; onAction("enable", preset.name, values); }; const setTools = (next: string[]) => onToolsChange(preset.name, next); const toggleTool = (toolName: string) => { const next = new Set(allowAllTools ? toolNames : enabledTools); if (next.has(toolName)) next.delete(toolName); else next.add(toolName); const nextValues = Array.from(next); setTools(nextValues.length === toolNames.length ? ["*"] : nextValues); }; return (

{preset.display_name}

{tx("settings.apps.mcpLabel", "MCP")}

{description}

{readyInstalled ? ( <> onAction("test", preset.name)}> {tx("settings.mcp.test", "Test")} {toolNames.length ? ( setToolsOpen((open) => !open)}> {tx("settings.mcp.toolScope", "Tools")} ) : null} onAction("remove", preset.name)}> {tx("settings.mcp.remove", "Remove")} onAction("remove", preset.name)} > ) : preset.installed && !preset.configured ? ( { if (hasFields) setSetupOpen(true); else onAction("enable", preset.name, values); }} > ) : preset.install_supported ? ( ) : ( )}
{setupOpen && preset.install_supported && hasFields ? (
{t("settings.mcp.connectTitle", { name: preset.display_name, defaultValue: "Connect {{name}}", })}

{tx("settings.mcp.connectHint", "Add the key from your account settings.")}

{preset.required_fields.map((field) => ( ))}
) : null} {toolsOpen && readyInstalled && toolNames.length ? (
{tx("settings.mcp.toolScope", "Tools")}
{toolNames.map((toolName) => { const selected = enabledSet.has(toolName); return ( ); })}
) : null}
); } function AppsTypeBadge({ children }: { children: ReactNode }) { return ( {children} ); } const AppsActionButton = forwardRef void; children: ReactNode; }>(function AppsActionButton({ ariaLabel, busy, disabled, tone = "default", onClick, children, }, ref) { return ( ); }); function appsTitle(item: AppsCatalogItem): string { return item.kind === "cli" ? item.app.display_name : item.preset.display_name; } function appsReady(item: AppsCatalogItem): boolean { return item.kind === "cli" ? item.app.installed : item.preset.installed && item.preset.configured; } function appsSearchText(item: AppsCatalogItem): string { if (item.kind === "cli") { const app = item.app; return [ app.display_name, app.name, app.category, app.description, app.requires, app.entry_point, app.source, ] .join(" ") .toLowerCase(); } const preset = item.preset; return [ preset.display_name, preset.name, preset.category, preset.description, preset.requires, preset.note, preset.transport, preset.source ?? "", ] .join(" ") .toLowerCase(); } function McpCustomServerPanel({ form, configImport, actionKey, onFormChange, onConfigImportChange, onSave, onImportConfig, }: { form: CustomMcpForm; configImport: string; actionKey: string | null; onFormChange: Dispatch>; onConfigImportChange: (value: string) => void; onSave: () => void; onImportConfig: () => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const [activeMode, setActiveMode] = useState<"custom" | "import" | null>(null); const [advancedOpen, setAdvancedOpen] = useState(false); const customBusy = actionKey?.startsWith("custom:") ?? false; const importBusy = actionKey === "import" || actionKey === "import-cursor"; const remote = form.transport !== "stdio"; const canSave = Boolean(form.name.trim()) && (remote ? Boolean(form.url.trim()) : Boolean(form.command.trim())); const update = (key: K, value: CustomMcpForm[K]) => { onFormChange((prev) => ({ ...prev, [key]: value })); }; const transports: Array<{ value: CustomMcpTransport; label: string }> = [ { value: "stdio", label: "stdio" }, { value: "streamableHttp", label: "HTTP" }, { value: "sse", label: "SSE" }, ]; return (

{tx("settings.mcp.moreOptions", "More MCP options")}

{tx("settings.mcp.moreOptionsSubtitle", "Add a custom server or import mcp.json.")}

{activeMode === "custom" ? (
{tx("settings.mcp.transport", "Transport")} update("transport", value as CustomMcpTransport)} />
{remote ? ( ) : ( )}
{advancedOpen ? (
{!remote ? (