import { useCallback, useEffect, forwardRef, useMemo, useRef, useState, type Dispatch, type FormEvent, type ReactNode, type SetStateAction, } from "react"; import { Activity, ArrowUpCircle, ArrowUpDown, Bot, Brain, Check, CircleAlert, ChevronDown, ChevronLeft, ChevronRight, Cloud, Clipboard, Cpu, Database, Eye, EyeOff, ExternalLink, Gem, Globe2, GripVertical, Grid3X3, HardDrive, Hexagon, ImageIcon, Layers, ListOrdered, Loader2, LogOut, MessageCircle, Mic, Moon, PauseCircle, 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 { channelUiPresentation } from "@/channel-plugins/registry"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { SIDEBAR_SELECTION_ITEM_CLASS, SidebarSelectionHighlight, } from "@/components/SidebarSelectionHighlight"; import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings"; import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap"; import { ToggleButton } from "@/components/settings/ToggleButton"; import { channelIsRunning, channelMatchesFilter, channelSearchText, localizedChannelDisplayName, type ChannelFilter, } from "@/components/settings/channels/ChannelIdentity"; import { ChannelCatalogRow, ChannelSetupPanel, } from "@/components/settings/channels/ChannelSetupPanel"; 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 { isLoopbackHost } from "@/lib/network"; import { checkVersion, completeProviderOAuth, createModelConfiguration, createProviderSettings, deleteModelConfiguration, disableNanobotFeature, enableNanobotFeature, fetchApiService, fetchAutomations, fetchSettings, fetchSettingsUsage, fetchCliApps, fetchMcpPresets, fetchNanobotFeatures, fetchProviderModels, importMcpConfig, loginProviderOAuth, logoutProviderOAuth, migrateModelConfigurations, runAutomationAction, runCliAppAction, runMcpPresetAction, saveCustomMcpServer, startApiService, stopApiService, updateAutomation, updateImageGenerationSettings, updateMcpServerTools, updateModelCallOrder, updateModelConfiguration, updateNetworkSafetySettings, updateProviderSettings, updateSettings, updateTranscriptionSettings, updateWebSearchSettings, } from "@/lib/api"; import { notifyCliAppsChanged } from "@/lib/cli-app-events"; import { copyTextToClipboard } from "@/lib/clipboard"; import { readLocalPreferences, writeLocalPreferences, type FileEditDisplayMode, type LocalActivityMode, type LocalDensity, type LocalPreferences, } from "@/lib/local-preferences"; import { getRuntimeHost, isNativeRuntime } from "@/lib/runtime"; import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events"; import { fmtDateTime, relativeTime } from "@/lib/format"; import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useMediaQuery } from "@/hooks/useMediaQuery"; import { usePageVisibility } from "@/hooks/usePageVisibility"; import { isGenericRepositoryLogoUrl, logoFallbackUrls, providerBrand, providerDisplayLabel, } from "@/lib/provider-brand"; import { cn } from "@/lib/utils"; import { shortWorkspacePath } from "@/lib/workspace"; import { useClient } from "@/providers/ClientProvider"; import type { ApiServicePayload, AutomationsPayload, AutomationUpdatePayload, CliAppInfo, CliAppsPayload, ImageGenerationSettingsUpdate, McpPresetInfo, McpPresetsPayload, NanobotFeatureInfo, NanobotFeaturesPayload, NetworkSafetySettingsUpdate, ProviderModelsPayload, ProviderOAuthAuthorizationRequired, ProviderOAuthCompletionResult, ProviderOAuthLoginResult, ProviderOAuthPending, ProviderSettingsUpdate, SessionAutomationJob, SettingsPayload, SkillSummary, TranscriptionSettingsUpdate, WebSearchSettingsUpdate, WebuiDefaultAccessMode, } from "@/lib/types"; export type SettingsSectionKey = | "overview" | "appearance" | "models" | "image" | "voice" | "browser" | "channels" | "apps" | "automations" | "skills" | "runtime" | "advanced"; function isProviderOAuthAuthorizationRequired( payload: ProviderOAuthLoginResult, ): payload is ProviderOAuthAuthorizationRequired { return (payload as ProviderOAuthAuthorizationRequired).status === "authorization_required"; } function isProviderOAuthPending( payload: ProviderOAuthCompletionResult, ): payload is ProviderOAuthPending { return (payload as ProviderOAuthPending).status === "pending"; } type AppsKindFilter = "ready" | "cli" | "mcp"; type AutomationFilter = "all" | "active" | "paused" | "failed" | "system"; type AutomationSort = "next" | "last" | "updated" | "name"; type AutomationAction = "enable" | "disable" | "delete" | "run"; type AppsCatalogItem = | { id: string; kind: "cli"; app: CliAppInfo } | { id: string; kind: "mcp"; preset: McpPresetInfo }; interface AgentSettingsDraft { model: string; provider: string; modelPreset: string; presetLabel: string; maxTokens: number; contextWindowTokens: number; temperature: number; reasoningEffort: string; timezone: string; toolHintMaxLength: number; } 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 ProviderAdvancedField = NonNullable< SettingsPayload["providers"][number]["advanced_fields"] >[number]; type ProviderForm = { displayName: string; apiKey: string; apiBase: string; apiType: ProviderApiType; proxy: string; extraHeaders: string; extraBody: string; extraQuery: string; thinkingStyle: string; region: string; profile: string; }; type CustomProviderDraft = ProviderForm & { name: string }; type CustomMcpTransport = "stdio" | "streamableHttp" | "sse"; const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const; const OAUTH_PROXY_PROVIDERS = new Set(["openai_codex", "xai_grok"]); const CUSTOM_PROVIDER_CREATION_KEY = "__custom_provider__"; const CUSTOM_PROVIDER_ADVANCED_FIELDS: ProviderAdvancedField[] = [ "extra_headers", "extra_body", "extra_query", "proxy", "thinking_style", ]; const DEFERRED_MODEL_LIST_PROVIDERS = new Set([ "aihubmix", "atomic_chat", "byteplus", "byteplus_coding_plan", "huggingface", "lm_studio", "modelscope", "novita", "ollama", "openrouter", "ovms", "siliconflow", "vllm", "volcengine", "volcengine_coding_plan", ]); function providerJsonValue(value: Record | null | undefined): string { return value && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : ""; } function providerFormFromRow( provider: SettingsPayload["providers"][number], ): ProviderForm { return { displayName: provider.is_custom ? provider.label : "", apiKey: "", apiBase: provider.api_base ?? provider.default_api_base ?? "", apiType: provider.api_type ?? "auto", proxy: provider.proxy ?? "", extraHeaders: providerJsonValue(provider.extra_headers), extraBody: providerJsonValue(provider.extra_body), extraQuery: providerJsonValue(provider.extra_query), thinkingStyle: provider.thinking_style ?? "", region: provider.region ?? "", profile: provider.profile ?? "", }; } function emptyCustomProviderDraft(): CustomProviderDraft { return { name: "", displayName: "", apiKey: "", apiBase: "", apiType: "auto", proxy: "", extraHeaders: "", extraBody: "", extraQuery: "", thinkingStyle: "", region: "", profile: "", }; } const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2; const CLI_APPS_REFRESH_RETRY_MS = 2_000; const CLI_APPS_REFRESH_MAX_RETRIES = 30; const SETTINGS_SEARCH_INPUT_CLASS = cn( "border-border/45 bg-settings-surface transition-colors hover:border-border/70", "focus-visible:border-border/70 focus-visible:bg-background", "focus-visible:ring-0 focus-visible:ring-offset-0", ); 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 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 modelPresetValue(payload: SettingsPayload): string { return ( payload.model_call_order?.[0] ?? payload.model_presets.find((preset) => !preset.is_default)?.name ?? "" ); } function normalizeContextWindowTokens(value: number | null | undefined): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000; } 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, resolvedProvider?: string | null, ): boolean { const row = settingsProviderRow(payload, provider); if (row) return row.configured; if (provider === "auto") { const resolvedRow = settingsProviderRow( payload, resolvedProvider ?? payload.agent.resolved_provider ?? payload.agent.provider, ); if (resolvedRow) return resolvedRow.configured; } return payload.agent.has_api_key; } const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = { model: "", provider: "", modelPreset: "", presetLabel: "", maxTokens: 8192, contextWindowTokens: 200_000, temperature: 0.1, reasoningEffort: "", timezone: "UTC", 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_TRANSCRIPTION_FORM: TranscriptionSettingsUpdate = { enabled: true, provider: "groq", model: "", language: "", maxDurationSec: 120, maxUploadMb: 25, }; const DEFAULT_TRANSCRIPTION_SETTINGS: NonNullable = { enabled: true, provider: "groq", provider_configured: false, model: "whisper-large-v3", language: null, max_duration_sec: 120, max_upload_mb: 25, providers: [], }; const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = { webuiAllowLocalServiceAccess: true, webuiDefaultAccessMode: "default", }; function agentDraftFromPayload( payload: SettingsPayload, preferredPresetName?: string, ): AgentSettingsDraft { const activePresetName = preferredPresetName ?? modelPresetValue(payload); const activePreset = payload.model_presets.find( (preset) => !preset.is_default && preset.name === activePresetName, ) ?? null; return { model: activePreset?.model ?? payload.agent.model, provider: activePreset?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "", modelPreset: activePresetName, presetLabel: activePreset?.label ?? activePresetName, maxTokens: activePreset?.max_tokens ?? payload.agent.max_tokens, contextWindowTokens: normalizeContextWindowTokens( activePreset?.context_window_tokens ?? payload.agent.context_window_tokens, ), temperature: activePreset?.temperature ?? payload.agent.temperature, reasoningEffort: activePreset?.reasoning_effort ?? "", timezone: payload.agent.timezone, 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, }; } type WebSearchProviderOption = SettingsPayload["web_search"]["providers"][number]; function webSearchProviderAcceptsApiKey(provider?: WebSearchProviderOption): boolean { return provider?.credential === "api_key" || provider?.credential === "optional_api_key"; } function webSearchProviderRequiresApiKey(provider?: WebSearchProviderOption): boolean { return provider?.credential === "api_key"; } 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 transcriptionFormFromPayload(payload: SettingsPayload): TranscriptionSettingsUpdate { const transcription = payload.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS; return { enabled: transcription.enabled, provider: transcription.provider, model: transcription.model, language: transcription.language ?? "", maxDurationSec: transcription.max_duration_sec, maxUploadMb: transcription.max_upload_mb, }; } 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 { getToken, token } = useClient(); const pageVisible = usePageVisibility(); const remoteBrowserAccess = typeof window !== "undefined" && !isLoopbackHost(window.location.hostname); const [settings, setSettings] = useState(() => initialSettings); const [cliApps, setCliApps] = useState(null); const [nanobotFeatures, setNanobotFeatures] = useState(null); const featureCatalog = nanobotFeatures?.features ?? []; const [mcpPresets, setMcpPresets] = useState(null); const [automations, setAutomations] = useState(null); const [loading, setLoading] = useState(() => initialSettings === null); const [cliAppsLoading, setCliAppsLoading] = useState(true); const [nanobotFeaturesLoading, setNanobotFeaturesLoading] = useState(true); const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true); const [automationsLoading, setAutomationsLoading] = useState(false); const [saving, setSaving] = useState(false); const [modelPresetCreating, setModelPresetCreating] = useState(false); const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false); const [modelCallOrderSaving, setModelCallOrderSaving] = useState(false); const [modelMigrationSaving, setModelMigrationSaving] = useState(false); const [modelPresetPendingDelete, setModelPresetPendingDelete] = useState(null); const modelPresetBeforeCreateRef = useRef(null); const [cliAppsAction, setCliAppsAction] = useState(null); const [nanobotFeatureAction, setNanobotFeatureAction] = useState(null); const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState(null); const [mcpPresetAction, setMcpPresetAction] = useState(null); const [providerSaving, setProviderSaving] = useState(null); const [providerOAuthFlow, setProviderOAuthFlow] = useState(null); const providerOAuthFlowRef = useRef(null); const [providerOAuthResponse, setProviderOAuthResponse] = useState(""); const [providerOAuthCompleting, setProviderOAuthCompleting] = useState(false); const [providerOAuthDialogError, setProviderOAuthDialogError] = useState(null); const [webSearchSaving, setWebSearchSaving] = useState(false); const [imageGenerationSaving, setImageGenerationSaving] = useState(false); const [transcriptionSaving, setTranscriptionSaving] = useState(false); const [networkSafetySaving, setNetworkSafetySaving] = useState(false); const [apiService, setApiService] = useState(null); const [apiServiceLoading, setApiServiceLoading] = useState(false); const [apiServiceAction, setApiServiceAction] = useState<"start" | "stop" | null>(null); const [apiServiceError, setApiServiceError] = useState(null); const [hostEngineApplying, setHostEngineApplying] = useState(false); const [error, setError] = useState(null); const [activeSection, setActiveSection] = useState(initialSection); const [expandedProvider, setExpandedProvider] = useState(null); const [appsQuery, setAppsQuery] = useState(""); const [channelsQuery, setChannelsQuery] = useState(""); const [automationsQuery, setAutomationsQuery] = useState(""); const [automationsFilter, setAutomationsFilter] = useState("all"); const [automationsSort, setAutomationsSort] = useState("next"); const [cliAppsMessage, setCliAppsMessage] = useState(null); const [cliAppsError, setCliAppsError] = useState(null); const [nanobotFeaturesError, setNanobotFeaturesError] = useState(null); const [cliAppsFocusName, setCliAppsFocusName] = useState(null); const [appsKindFilter, setAppsKindFilter] = useState("cli"); const [mcpMessage, setMcpMessage] = useState(null); const [mcpError, setMcpError] = useState(null); const [automationsError, setAutomationsError] = useState(null); const [automationAction, setAutomationAction] = useState(null); const [automationPendingDelete, setAutomationPendingDelete] = useState(null); const [automationPendingEdit, setAutomationPendingEdit] = 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 [transcriptionForm, setTranscriptionForm] = useState( () => initialSettings ? transcriptionFormFromPayload(initialSettings) : DEFAULT_TRANSCRIPTION_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 [modelCallOrder, setModelCallOrder] = useState( () => initialSettings?.model_call_order ?? [], ); const applyPayload = useCallback( ( payload: SettingsPayload, options: { preserveAgentForm?: boolean } = {}, ) => { setSettings(payload); if (!options.preserveAgentForm) { setForm(agentDraftFromPayload(payload)); setModelPresetCreating(false); } setModelCallOrder(payload.model_call_order ?? []); setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev)); setImageGenerationForm(imageGenerationFormFromPayload(payload)); setTranscriptionForm(transcriptionFormFromPayload(payload)); setNetworkSafetyForm(networkSafetyFormFromPayload(payload)); if (payload.restart_required_sections) { setPendingRestartSections(pendingRestartSectionsFromPayload(payload)); } onSettingsChange?.(payload); }, [onSettingsChange], ); const closeProviderOAuthFlow = useCallback(() => { providerOAuthFlowRef.current = null; setProviderOAuthFlow(null); setProviderOAuthResponse(""); setProviderOAuthCompleting(false); setProviderOAuthDialogError(null); }, []); useEffect(() => { if (!providerOAuthFlow) return; let cancelled = false; let timer: number | null = null; const poll = async () => { try { const payload = await completeProviderOAuth( getToken(), providerOAuthFlow.provider, providerOAuthFlow.flow_id, ); if ( cancelled || providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id ) return; if (isProviderOAuthPending(payload)) { timer = window.setTimeout(() => void poll(), 1000); return; } applyPayload(payload); setExpandedProvider(providerOAuthFlow.provider); setError(null); closeProviderOAuthFlow(); } catch (err) { if ( cancelled || providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id ) return; setError((err as Error).message); closeProviderOAuthFlow(); } }; timer = window.setTimeout(() => void poll(), 1000); return () => { cancelled = true; if (timer !== null) window.clearTimeout(timer); }; }, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]); 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(getToken()) .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, getToken]); const hasSettings = settings !== null; useEffect(() => { if (activeSection !== "overview" || !hasSettings || !pageVisible) return; let cancelled = false; let refreshing = false; const refresh = async () => { if (refreshing) return; refreshing = true; try { const usage = await fetchSettingsUsage(getToken()); if (!cancelled) { setSettings((current) => (current ? { ...current, usage } : current)); } } catch { // Usage is best-effort telemetry; the settings snapshot remains usable. } finally { refreshing = false; } }; void refresh(); const interval = window.setInterval(() => void refresh(), 5000); const onFocus = () => void refresh(); window.addEventListener("focus", onFocus); return () => { cancelled = true; window.clearInterval(interval); window.removeEventListener("focus", onFocus); }; }, [activeSection, getToken, hasSettings, pageVisible]); useEffect(() => { if (activeSection !== "apps") return; let cancelled = false; let retry: number | null = null; let retryCount = 0; const loadCliApps = (showLoading: boolean) => { if (showLoading) setCliAppsLoading(true); fetchCliApps(getToken()) .then((payload) => { if (cancelled) return; if (payload.catalog_refresh_pending && retryCount < CLI_APPS_REFRESH_MAX_RETRIES) { retryCount += 1; retry = window.setTimeout(() => { retry = null; loadCliApps(false); }, CLI_APPS_REFRESH_RETRY_MS); } setCliApps(payload); setCliAppsError(null); setCliAppsLoading(false); }) .catch((err) => { if (!cancelled) { setCliAppsError((err as Error).message); setCliAppsLoading(false); } }); }; loadCliApps(true); return () => { cancelled = true; if (retry !== null) window.clearTimeout(retry); }; }, [activeSection, getToken]); useEffect(() => { if ( !pageVisible || !["channels", "models", "browser", "runtime"].includes(activeSection) ) { return; } let cancelled = false; let refreshing = false; const refresh = async (showLoading = false): Promise => { if (refreshing) return; refreshing = true; if (showLoading) setNanobotFeaturesLoading(true); try { const payload = await fetchNanobotFeatures(getToken()); if (!cancelled) { setNanobotFeatures(payload); setNanobotFeaturesError(null); } } catch (err) { const message = (err as Error).message; if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message); } finally { refreshing = false; if (!cancelled && showLoading) setNanobotFeaturesLoading(false); } }; void refresh(true); const interval = activeSection === "channels" ? window.setInterval(() => void refresh(false), 5000) : null; const refreshOnFocus = () => { if (activeSection === "channels" && document.visibilityState !== "hidden") { void refresh(false); } }; window.addEventListener("focus", refreshOnFocus); document.addEventListener("visibilitychange", refreshOnFocus); return () => { cancelled = true; if (interval !== null) window.clearInterval(interval); window.removeEventListener("focus", refreshOnFocus); document.removeEventListener("visibilitychange", refreshOnFocus); }; }, [activeSection, getToken, pageVisible]); useEffect(() => { if (activeSection !== "runtime") return; let cancelled = false; setApiServiceLoading(true); fetchApiService(getToken()) .then((payload) => { if (!cancelled) { setApiService(payload); setApiServiceError(null); } }) .catch((err) => { if (!cancelled) setApiServiceError((err as Error).message); }) .finally(() => { if (!cancelled) setApiServiceLoading(false); }); return () => { cancelled = true; }; }, [activeSection, getToken]); useEffect(() => { if (activeSection !== "apps") return; let cancelled = false; setMcpPresetsLoading(true); fetchMcpPresets(getToken()) .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, getToken]); const refreshAutomations = useCallback( async (showLoading = false) => { if (showLoading) setAutomationsLoading(true); try { const payload = await fetchAutomations(getToken()); setAutomations(payload); setAutomationsError(null); } catch (err) { setAutomationsError((err as Error).message); } finally { if (showLoading) setAutomationsLoading(false); } }, [getToken], ); useEffect(() => { if (activeSection !== "automations" || !pageVisible) return; let cancelled = false; let refreshing = false; const refresh = async (showLoading = false) => { if (cancelled || refreshing) return; refreshing = true; if (showLoading) setAutomationsLoading(true); try { const payload = await fetchAutomations(getToken()); if (cancelled) return; setAutomations(payload); setAutomationsError(null); } catch (err) { if (!cancelled) setAutomationsError((err as Error).message); } finally { refreshing = false; if (!cancelled && showLoading) setAutomationsLoading(false); } }; void refresh(true); const interval = window.setInterval(() => void refresh(false), 5000); const refreshOnFocus = () => void refresh(false); window.addEventListener("focus", refreshOnFocus); return () => { cancelled = true; window.clearInterval(interval); window.removeEventListener("focus", refreshOnFocus); }; }, [activeSection, getToken, pageVisible]); useEffect(() => { writeLocalPreferences(localPrefs); }, [localPrefs]); useEffect(() => { if (!settings) return; setProviderForms((prev) => { const next = { ...prev }; for (const provider of settings.providers) { next[provider.name] = next[provider.name] ?? providerFormFromRow(provider); } return next; }); }, [settings]); const modelDirty = useMemo(() => { if (!settings) return false; const selectedPreset = settings.model_presets.find( (preset) => !preset.is_default && preset.name === form.modelPreset, ); if (!selectedPreset) return false; return ( form.model !== selectedPreset.model || form.provider !== selectedPreset.provider || form.maxTokens !== selectedPreset.max_tokens || form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) || form.temperature !== selectedPreset.temperature || form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") || form.presetLabel.trim() !== selectedPreset.label ); }, [form, settings]); const runtimeDirty = useMemo(() => { if (!settings) return false; return form.timezone !== settings.agent.timezone; }, [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 transcriptionDirty = useMemo(() => { if (!settings) return false; const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS; return ( transcriptionForm.enabled !== transcription.enabled || transcriptionForm.provider !== transcription.provider || transcriptionForm.model !== transcription.model || transcriptionForm.language !== (transcription.language ?? "") || transcriptionForm.maxDurationSec !== transcription.max_duration_sec || transcriptionForm.maxUploadMb !== transcription.max_upload_mb ); }, [settings, transcriptionForm]); 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 && provider.model_selectable !== false) .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 || saving || modelCallOrderSaving || modelConfigurationSaving ) { return; } if (modelPresetCreating) { const label = form.presetLabel.trim(); const provider = form.provider.trim(); const model = form.model.trim(); if ( !label || !provider || !model || form.maxTokens <= 0 || form.contextWindowTokens <= 0 || form.temperature < 0 || form.temperature > 2 ) { return; } setModelConfigurationSaving(true); try { const payload = await createModelConfiguration(token, { label, provider, model, maxTokens: form.maxTokens, contextWindowTokens: form.contextWindowTokens, temperature: form.temperature, reasoningEffort: form.reasoningEffort || null, }); const createdPreset = payload.created_model_preset; const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null; applyPayload(payload); if (createdPreset) { setForm(agentDraftFromPayload(payload, createdPreset)); } let finalPayload = payload; if (nextOrder) { const orderedPayload = await updateModelCallOrder(token, nextOrder); applyPayload(orderedPayload); finalPayload = orderedPayload; } if (createdPreset) { setForm(agentDraftFromPayload(finalPayload, createdPreset)); } modelPresetBeforeCreateRef.current = null; onModelNameChange(finalPayload.agent.model || null); setError(null); } catch (err) { setError((err as Error).message); } finally { setModelConfigurationSaving(false); } return; } if (!modelDirty) return; const selectedPreset = settings.model_presets.find( (preset) => !preset.is_default && preset.name === form.modelPreset, ); if (!selectedPreset) return; const reasoningEffort = form.reasoningEffort || null; setSaving(true); try { const payload = await updateModelConfiguration(token, { name: selectedPreset.name, label: form.presetLabel.trim() !== selectedPreset.label ? form.presetLabel.trim() : undefined, model: form.model !== selectedPreset.model ? form.model : undefined, provider: form.provider !== selectedPreset.provider ? form.provider : undefined, maxTokens: form.maxTokens !== selectedPreset.max_tokens ? form.maxTokens : undefined, contextWindowTokens: form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ? form.contextWindowTokens : undefined, temperature: form.temperature !== selectedPreset.temperature ? form.temperature : undefined, reasoningEffort: reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined, }); applyPayload(payload); setForm(agentDraftFromPayload(payload, selectedPreset.name)); onModelNameChange(payload.agent.model || null); setError(null); } catch (err) { setError((err as Error).message); } finally { setSaving(false); } }; const beginModelPresetCreation = () => { if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return; const primaryPreset = settings.model_presets.find( (preset) => !preset.is_default && preset.name === settings.model_call_order?.[0], ); const currentProvider = primaryPreset?.provider === "auto" ? primaryPreset.resolved_provider ?? settings.agent.resolved_provider : primaryPreset?.provider ?? settings.agent.provider; const provider = configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ?? configuredModelProviderOptions[0]?.name ?? ""; modelPresetBeforeCreateRef.current = form.modelPreset; setForm((prev) => ({ ...prev, modelPreset: "", presetLabel: "", provider, model: "", maxTokens: primaryPreset?.max_tokens ?? settings.agent.max_tokens, contextWindowTokens: normalizeContextWindowTokens( primaryPreset?.context_window_tokens ?? settings.agent.context_window_tokens, ), temperature: primaryPreset?.temperature ?? settings.agent.temperature, reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "", })); setModelPresetCreating(true); }; const cancelModelPresetCreation = () => { if (!settings || modelConfigurationSaving) return; const previousPreset = modelPresetBeforeCreateRef.current; setModelPresetCreating(false); setForm(agentDraftFromPayload(settings, previousPreset ?? undefined)); modelPresetBeforeCreateRef.current = null; }; const changeModelCallOrder = async (nextOrder: string[]) => { const unchanged = nextOrder.length === modelCallOrder.length && nextOrder.every((name, index) => name === modelCallOrder[index]); if ( !settings || saving || modelCallOrderSaving || modelConfigurationSaving || nextOrder.length === 0 || unchanged ) { return; } const previousOrder = [...modelCallOrder]; setModelCallOrder(nextOrder); setModelCallOrderSaving(true); try { const payload = await updateModelCallOrder(token, nextOrder); applyPayload(payload, { preserveAgentForm: true }); onModelNameChange(payload.agent.model || null); setError(null); } catch (err) { setModelCallOrder(previousOrder); setError((err as Error).message); } finally { setModelCallOrderSaving(false); } }; const handleMigrateModelConfigurations = async () => { if (modelMigrationSaving) return; setModelMigrationSaving(true); try { const payload = await migrateModelConfigurations(token); applyPayload(payload); onModelNameChange(payload.agent.model || null); setError(null); } catch (err) { setError((err as Error).message); } finally { setModelMigrationSaving(false); } }; const handleDeleteModelConfiguration = async () => { if ( !modelPresetPendingDelete || saving || modelCallOrderSaving || modelConfigurationSaving ) { return; } setSaving(true); try { const payload = await deleteModelConfiguration(token, modelPresetPendingDelete.name); applyPayload(payload); setModelPresetPendingDelete(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, }); 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 saveTranscriptionSettings = async () => { if (!settings || !transcriptionDirty || transcriptionSaving) return; setTranscriptionSaving(true); try { const payload = await updateTranscriptionSettings(token, transcriptionForm); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, browser: true })); } await maybeRestartHostEngine(payload); setError(null); } catch (err) { setError((err as Error).message); } finally { setTranscriptionSaving(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 installCapabilities = async (names: string[]): Promise => { const missing = names.filter( (name) => !featureCatalog.find((feature) => feature.name === name)?.installed, ); if (!missing.length) return true; setNanobotFeatureAction(`enable:${names.join("+")}`); setNanobotFeaturesError(null); try { let latest = nanobotFeatures; for (const name of missing) { latest = await enableNanobotFeature(token, name); if (latest.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); } } if (latest) setNanobotFeatures(latest); return true; } catch (err) { setNanobotFeaturesError((err as Error).message); return false; } finally { setNanobotFeatureAction(null); } }; const handleApiServiceAction = async ( action: "start" | "stop", values?: { host: string; port: number; timeout: number; apiKey?: string }, ) => { if (apiServiceAction) return; setApiServiceAction(action); setApiServiceError(null); try { const payload = action === "start" ? await startApiService(token, values!) : await stopApiService(token); setApiService(payload); const refreshed = await fetchNanobotFeatures(token); setNanobotFeatures(refreshed); const nextSettings = await fetchSettings(token); applyPayload(nextSettings); } catch (err) { setApiServiceError((err as Error).message); } finally { setApiServiceAction(null); } }; const saveProvider = async (providerName: string) => { if (providerSaving) return; const provider = settings?.providers.find((item) => item.name === providerName); if (!provider) return; const isOauthProvider = provider.auth_type === "oauth"; const providerForm = providerForms[providerName] ?? providerFormFromRow(provider); const apiKey = providerForm.apiKey.trim(); const apiKeyRequired = provider.api_key_required ?? true; if (!isOauthProvider && !provider.configured && apiKeyRequired && !apiKey) { setError(t("settings.byok.apiKeyRequired")); return; } setProviderSaving(providerName); try { const supportName = providerName === "bedrock" ? "bedrock" : providerName === "azure_openai" ? "azure" : null; if (supportName && !(await installCapabilities([supportName]))) return; const update: ProviderSettingsUpdate = { provider: providerName }; if (!isOauthProvider) { update.apiKey = apiKey || undefined; update.apiBase = providerForm.apiBase.trim(); if (provider.is_custom) update.displayName = providerForm.displayName.trim(); } for (const field of provider.advanced_fields ?? []) { if (field === "api_type") update.apiType = providerForm.apiType; if (field === "proxy") update.proxy = providerForm.proxy.trim(); if (field === "extra_headers") { update.extraHeaders = providerForm.extraHeaders.trim(); } if (field === "extra_body") update.extraBody = providerForm.extraBody.trim(); if (field === "extra_query") update.extraQuery = providerForm.extraQuery.trim(); if (field === "thinking_style") { update.thinkingStyle = providerForm.thinkingStyle.trim(); } if (field === "region") update.region = providerForm.region.trim(); if (field === "profile") update.profile = providerForm.profile.trim(); } const payload = await updateProviderSettings(token, update); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, image: true })); } await maybeRestartHostEngine(payload); setProviderForms((prev) => ({ ...prev, [providerName]: { ...providerForm, displayName: providerForm.displayName.trim(), apiKey: "", apiBase: providerForm.apiBase.trim(), proxy: providerForm.proxy.trim(), thinkingStyle: providerForm.thinkingStyle.trim(), region: providerForm.region.trim(), profile: providerForm.profile.trim(), }, })); setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false })); if (!isOauthProvider) setExpandedProvider(null); setError(null); } catch (err) { setError((err as Error).message); } finally { setProviderSaving(null); } }; const createCustomProvider = async (draft: CustomProviderDraft): Promise => { if (providerSaving) return false; setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY); try { const payload = await createProviderSettings(token, { name: draft.name.trim(), apiKey: draft.apiKey.trim() || undefined, apiBase: draft.apiBase.trim(), proxy: draft.proxy.trim(), extraHeaders: draft.extraHeaders.trim(), extraBody: draft.extraBody.trim(), extraQuery: draft.extraQuery.trim(), thinkingStyle: draft.thinkingStyle.trim(), }); applyPayload(payload); setExpandedProvider(null); setError(null); return true; } catch (err) { setError((err as Error).message); return false; } finally { setProviderSaving(null); } }; const runProviderOAuth = async (providerName: string, action: "login" | "logout") => { if (providerSaving) return; let popup: Window | null = null; if ( action === "login" && providerName === "xai_grok" && !remoteBrowserAccess ) { try { popup = window.open("about:blank", "_blank"); if (popup) popup.opener = null; } catch { popup = null; } } setProviderSaving(providerName); try { const payload = action === "login" ? await loginProviderOAuth( token, providerName, "", providerName === "openai_codex" && remoteBrowserAccess, ) : await logoutProviderOAuth(token, providerName); if (isProviderOAuthAuthorizationRequired(payload)) { try { if (popup && !popup.closed) popup.location.href = payload.authorization_url; } catch { // The dialog keeps the authorization link available when the popup was closed. } providerOAuthFlowRef.current = payload; setProviderOAuthFlow(payload); setProviderOAuthResponse(""); setProviderOAuthDialogError(null); setExpandedProvider(providerName); setError(null); return; } popup?.close(); closeProviderOAuthFlow(); applyPayload(payload); setExpandedProvider(providerName); setError(null); } catch (err) { popup?.close(); setError((err as Error).message); } finally { setProviderSaving(null); } }; const completeProviderOAuthResponse = async () => { const flow = providerOAuthFlowRef.current; const authorizationResponse = providerOAuthResponse.trim(); if (!flow || !authorizationResponse || providerOAuthCompleting) return; setProviderOAuthCompleting(true); setProviderOAuthDialogError(null); try { const payload = await completeProviderOAuth( token, flow.provider, flow.flow_id, authorizationResponse, ); if (providerOAuthFlowRef.current?.flow_id !== flow.flow_id) return; if (isProviderOAuthPending(payload)) return; applyPayload(payload); setExpandedProvider(flow.provider); setError(null); closeProviderOAuthFlow(); } catch (err) { if (providerOAuthFlowRef.current?.flow_id === flow.flow_id) { setProviderOAuthDialogError((err as Error).message); } } finally { setProviderOAuthCompleting(false); } }; 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 = webSearchProviderAcceptsApiKey(provider) && webSearchForm.provider === settings.web_search.provider && !!settings.web_search.api_key_hint; if (webSearchProviderRequiresApiKey(provider) && !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 { if (provider.name === "olostep" && !(await installCapabilities(["olostep"]))) return; 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 ( webSearchProviderAcceptsApiKey(provider) && (apiKey || (provider.credential === "optional_api_key" && webSearchKeyEditing)) ) { 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]: providerFormFromRow(provider), })); 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]: { ...(forms[providerName] ?? providerFormFromRow( settings?.providers.find((provider) => provider.name === providerName) ?? { name: providerName, label: providerName, configured: false, }, )), apiKey: "", }, })); 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 handleNanobotFeatureAction = async ( action: "enable" | "disable", name: string, confirmed = false, ) => { const feature = featureCatalog.find((item) => item.name === name); if (action === "enable" && !confirmed && feature && !feature.installed && feature.install_supported) { setNanobotFeaturesError(null); setNanobotFeatureConfirm(feature); return; } const key = `${action}:${name}`; setNanobotFeatureAction(key); setNanobotFeatureConfirm(null); setNanobotFeaturesError(null); try { const payload = action === "enable" ? await enableNanobotFeature(token, name) : await disableNanobotFeature(token, name); setNanobotFeatures(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); } } catch (err) { setNanobotFeaturesError((err as Error).message); } finally { setNanobotFeatureAction(null); } }; const handleAutomationAction = async ( action: AutomationAction, job: SessionAutomationJob, ) => { const key = `${action}:${job.id}`; setAutomationAction(key); setAutomationsError(null); try { const payload = await runAutomationAction(token, action, job.id); setAutomations(payload); if (action === "delete") setAutomationPendingDelete(null); if (action === "run") { window.setTimeout(() => void refreshAutomations(false), 1200); window.setTimeout(() => void refreshAutomations(false), 4000); } } catch (err) { setAutomationsError((err as Error).message); } finally { setAutomationAction(null); } }; const handleAutomationEdit = async ( job: SessionAutomationJob, values: AutomationUpdatePayload, ) => { const key = `update:${job.id}`; setAutomationAction(key); setAutomationsError(null); try { const payload = await updateAutomation(token, job.id, values); setAutomations(payload); setAutomationPendingEdit(null); } catch (err) { setAutomationsError((err as Error).message); } finally { setAutomationAction(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} onMigrate={handleMigrateModelConfigurations} onBeginCreate={beginModelPresetCreation} onCancelCreate={cancelModelPresetCreation} onSelectConfiguration={() => { setModelPresetCreating(false); modelPresetBeforeCreateRef.current = null; }} onDeleteConfiguration={setModelPresetPendingDelete} /> setProviderForms((prev) => ({ ...prev, [provider]: { ...(prev[provider] ?? providerFormFromRow( settings.providers.find((row) => row.name === provider) ?? { name: provider, label: provider, configured: false, }, )), ...value, }, })) } onSaveProvider={saveProvider} onCreateCustomProvider={createCustomProvider} onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")} onProviderOAuthLogout={(provider) => runProviderOAuth(provider, "logout")} imageProviderRestartPending={pendingRestartSections.image || pendingRestartSections.runtime} onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} />
); case "image": return ( selectSection("models")} showBrandLogos={localPrefs.brandLogos} onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} requiresRestartPending={pendingRestartSections.image} /> ); case "voice": return ( selectSection("models")} showBrandLogos={localPrefs.brandLogos} onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} requiresRestartPending={pendingRestartSections.browser} /> ); 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} olostepFeature={featureCatalog.find((feature) => feature.name === "olostep")} olostepInstalling={nanobotFeatureAction === "enable:olostep"} capabilityError={nanobotFeaturesError} /> ); case "channels": return ( { setNanobotFeaturesError(null); }} onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} /> ); 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 "automations": return ( ); case "skills": return ; case "runtime": return ( feature.name === "langfuse")} capabilitiesLoading={nanobotFeaturesLoading} capabilityAction={nanobotFeatureAction} capabilityError={nanobotFeaturesError} onApiServiceAction={handleApiServiceAction} onInstallCapability={(name) => void installCapabilities([name])} /> ); case "advanced": return ( ); default: return null; } }; return (
{showSidebar ? ( ) : null} { if (!open) setModelPresetPendingDelete(null); }} onConfirm={handleDeleteModelConfiguration} /> provider.name === providerOAuthFlow.provider) ?.label ?? providerOAuthFlow.provider : "" } authorizationResponse={providerOAuthResponse} completing={providerOAuthCompleting} error={providerOAuthDialogError} remoteBrowserAccess={remoteBrowserAccess} onAuthorizationResponseChange={(value) => { setProviderOAuthResponse(value); setProviderOAuthDialogError(null); }} onOpenAuthorization={() => { if (!providerOAuthFlow) return; const opened = window.open( providerOAuthFlow.authorization_url, "_blank", "noopener,noreferrer", ); if (opened) opened.opener = null; }} onComplete={() => void completeProviderOAuthResponse()} onClose={closeProviderOAuthFlow} /> { if (!open) setNanobotFeatureConfirm(null); }} onConfirm={(feature) => handleNanobotFeatureAction("enable", feature.name, true)} /> { if (!open) setAutomationPendingDelete(null); }} onConfirm={(job) => handleAutomationAction("delete", job)} /> { if (!open) setAutomationPendingEdit(null); }} onSave={handleAutomationEdit} />
{!showSidebar ? (

{t(`settings.nav.${activeSection}`, { defaultValue: standaloneSectionTitle(activeSection), })}

) : null} {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: "voice", icon: Mic, fallback: "Voice" }, { key: "browser", icon: Globe2, fallback: "Web" }, { key: "channels", icon: MessageCircle, fallback: "Channels" }, { 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 standaloneSectionTitle(section: SettingsSectionKey): string { if (section === "apps") return "Apps"; if (section === "automations") return "Automations"; if (section === "skills") return "Skills"; 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(); const activeNavItemRef = useRef(null); const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection) ?? SETTINGS_NAV_ITEMS[0]; const ActiveIcon = activeItem.icon; const activeLabel = t(`settings.nav.${activeItem.key}`, { defaultValue: activeItem.fallback, }); 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 activePresetName = settings.agent.model_preset; const activePreset = activePresetName && activePresetName !== "default" ? settings.model_presets.find((preset) => preset.name === activePresetName)?.label ?? activePresetName : null; 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].filter(Boolean).join(" · ") : 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 webSearchProvider = settings.web_search.providers.find((provider) => provider.name === settings.web_search.provider) ?? settings.web_search.providers[0]; const webSearchProviderLabel = providerDisplayLabel( settings.web_search.providers, settings.web_search.provider, ); const webSearchCredentialStatus = webSearchProvider?.credential === "none" ? tx("settings.byok.webSearch.noCredentialRequired", "No key required") : webSearchProvider?.credential === "optional_api_key" ? settings.web_search.api_key_hint ? tx("settings.values.configured", "Configured") : tx("settings.byok.webSearch.noCredentialRequired", "No key required") : webSearchProvider?.credential === "base_url" ? settings.web_search.base_url ? tx("settings.values.configured", "Configured") : tx("settings.values.notConfigured", "Not configured") : settings.web_search.api_key_hint ? tx("settings.values.configured", "Configured") : tx("settings.values.notConfigured", "Not configured"); const webCaption = `${webSearchProviderLabel} · ${webSearchCredentialStatus}`; 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 transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS; const voiceStatus = transcription.enabled ? tx("settings.values.enabled", "Enabled") : tx("settings.values.disabled", "Disabled"); const voiceCaption = `${providerDisplayLabel(transcription.providers, transcription.provider)} · ${ transcription.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")} /> onSelectSection("voice")} />
{tx("settings.sections.system", "System")} onSelectSection("runtime")} /> onSelectSection("runtime")} />
{tx("settings.sections.about", "About")}
); } function VersionCheckRow({ currentVersion }: { currentVersion?: string }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const { token } = useClient(); const [checking, setChecking] = useState(false); const [result, setResult] = useState< | { type: "up-to-date" } | { type: "update"; latestVersion: string; pypiUrl?: string } | { type: "error"; message: string } | null >(null); const handleCheck = async () => { setChecking(true); setResult(null); try { const res = await checkVersion(token); if (res.updateAvailable) { setResult({ type: "update", latestVersion: res.updateAvailable.latestVersion, pypiUrl: res.updateAvailable.pypiUrl, }); } else { setResult({ type: "up-to-date" }); } } catch (err) { setResult({ type: "error", message: (err as Error).message }); } finally { setChecking(false); } }; return (
{tx("settings.about.version", "Version")}
{currentVersion ? `v${currentVersion}` : "nanobot"}
{result?.type === "up-to-date" ? ( {tx("settings.about.upToDate", "You're up to date")} ) : null} {result?.type === "update" ? ( {t("settings.about.updateAvailable", { defaultValue: "Update available v{{version}}", version: result.latestVersion, })} {result.pypiUrl ? ( PyPI ) : null} ) : null} {result?.type === "error" ? ( {result.message} ) : null}
); } 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, fileEditDisplayMode: fileEditDisplayMode as FileEditDisplayMode, })) } /> 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 ProviderOAuthLoginDialog({ flow, providerLabel, authorizationResponse, completing, error, remoteBrowserAccess, onAuthorizationResponseChange, onOpenAuthorization, onComplete, onClose, }: { flow: ProviderOAuthAuthorizationRequired | null; providerLabel: string; authorizationResponse: string; completing: boolean; error: string | null; remoteBrowserAccess: boolean; onAuthorizationResponseChange: (value: string) => void; onOpenAuthorization: () => void; onComplete: () => void; onClose: () => void; }) { const { t } = useTranslation(); const expectsCallbackUrl = flow?.completion_input === "callback_url"; const inputId = expectsCallbackUrl ? "provider-oauth-callback" : "provider-oauth-code"; const inputLabel = expectsCallbackUrl ? t("settings.oauth.callbackUrl") : t("settings.oauth.authorizationCode"); return ( { if (!open) onClose(); }} >
{ event.preventDefault(); onComplete(); }} > {providerLabel} {expectsCallbackUrl ? remoteBrowserAccess ? t("settings.oauth.remoteCallbackHelp") : t("settings.oauth.localCallbackHelp") : remoteBrowserAccess ? t("settings.oauth.remoteCodeHelp") : t("settings.oauth.localCodeHelp")}
{expectsCallbackUrl && remoteBrowserAccess ? ( ) : ( )} {expectsCallbackUrl && remoteBrowserAccess ? t("settings.oauth.pasteCallbackToContinue") : t("settings.oauth.waitingForCallback")}
{expectsCallbackUrl ? (