From e0e8330ecc3e570778a0dc30e84c5dea64863a4a Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 10 Aug 2026 19:33:52 +0800 Subject: [PATCH] refactor(webui): split settings frontend by domain --- .../src/components/settings/SettingsPage.tsx | 654 + .../components/settings/SettingsSidebar.tsx | 188 + .../src/components/settings/SettingsView.tsx | 10504 +--------------- .../capabilities/ImageGenerationSettings.tsx | 213 + .../capabilities/SecuritySettings.tsx | 131 + .../capabilities/TranscriptionSettings.tsx | 176 + .../settings/capabilities/WebSettings.tsx | 293 + .../useCapabilitySettingsActions.ts | 224 + .../useCapabilitySettingsState.ts | 73 + webui/src/components/settings/contracts.ts | 32 + .../settings/models/ModelsSettings.tsx | 923 ++ .../settings/models/ProviderSettings.tsx | 1368 ++ .../models/useModelSettingsActions.ts | 562 + .../models/useModelSettingsEffects.ts | 97 + .../settings/models/useModelSettingsState.ts | 78 + .../settings/overview/OverviewSettings.tsx | 526 + .../settings/shared/ModelControls.tsx | 619 + .../settings/shared/SettingsControls.tsx | 410 + .../settings/system/AppsSettings.tsx | 1531 +++ .../settings/system/AutomationsSettings.tsx | 1527 +++ .../settings/system/ChannelsSettings.tsx | 257 + .../settings/system/RuntimeSettings.tsx | 406 + .../system/createSystemSettingsActions.ts | 656 + .../system/useSystemSettingsEffects.ts | 221 + .../settings/system/useSystemSettingsState.ts | 157 + .../settings/useSettingsController.ts | 613 + webui/src/tests/settings-apps-oauth.test.tsx | 566 + .../src/tests/settings-capabilities.test.tsx | 255 + webui/src/tests/settings-channels.test.tsx | 1281 ++ webui/src/tests/settings-models.test.tsx | 1205 ++ webui/src/tests/settings-overview.test.tsx | 241 + webui/src/tests/settings-providers.test.tsx | 849 ++ webui/src/tests/settings-system.test.tsx | 381 + webui/src/tests/settings-test-utils.tsx | 195 + webui/src/tests/settings-view.test.tsx | 4905 -------- 35 files changed, 16931 insertions(+), 15386 deletions(-) create mode 100644 webui/src/components/settings/SettingsPage.tsx create mode 100644 webui/src/components/settings/SettingsSidebar.tsx create mode 100644 webui/src/components/settings/capabilities/ImageGenerationSettings.tsx create mode 100644 webui/src/components/settings/capabilities/SecuritySettings.tsx create mode 100644 webui/src/components/settings/capabilities/TranscriptionSettings.tsx create mode 100644 webui/src/components/settings/capabilities/WebSettings.tsx create mode 100644 webui/src/components/settings/capabilities/useCapabilitySettingsActions.ts create mode 100644 webui/src/components/settings/capabilities/useCapabilitySettingsState.ts create mode 100644 webui/src/components/settings/contracts.ts create mode 100644 webui/src/components/settings/models/ModelsSettings.tsx create mode 100644 webui/src/components/settings/models/ProviderSettings.tsx create mode 100644 webui/src/components/settings/models/useModelSettingsActions.ts create mode 100644 webui/src/components/settings/models/useModelSettingsEffects.ts create mode 100644 webui/src/components/settings/models/useModelSettingsState.ts create mode 100644 webui/src/components/settings/overview/OverviewSettings.tsx create mode 100644 webui/src/components/settings/shared/ModelControls.tsx create mode 100644 webui/src/components/settings/shared/SettingsControls.tsx create mode 100644 webui/src/components/settings/system/AppsSettings.tsx create mode 100644 webui/src/components/settings/system/AutomationsSettings.tsx create mode 100644 webui/src/components/settings/system/ChannelsSettings.tsx create mode 100644 webui/src/components/settings/system/RuntimeSettings.tsx create mode 100644 webui/src/components/settings/system/createSystemSettingsActions.ts create mode 100644 webui/src/components/settings/system/useSystemSettingsEffects.ts create mode 100644 webui/src/components/settings/system/useSystemSettingsState.ts create mode 100644 webui/src/components/settings/useSettingsController.ts create mode 100644 webui/src/tests/settings-apps-oauth.test.tsx create mode 100644 webui/src/tests/settings-capabilities.test.tsx create mode 100644 webui/src/tests/settings-channels.test.tsx create mode 100644 webui/src/tests/settings-models.test.tsx create mode 100644 webui/src/tests/settings-overview.test.tsx create mode 100644 webui/src/tests/settings-providers.test.tsx create mode 100644 webui/src/tests/settings-system.test.tsx create mode 100644 webui/src/tests/settings-test-utils.tsx delete mode 100644 webui/src/tests/settings-view.test.tsx diff --git a/webui/src/components/settings/SettingsPage.tsx b/webui/src/components/settings/SettingsPage.tsx new file mode 100644 index 000000000..28d2d17a5 --- /dev/null +++ b/webui/src/components/settings/SettingsPage.tsx @@ -0,0 +1,654 @@ +import { ChevronLeft, Loader2 } from "lucide-react"; + +import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings"; +import { ImageGenerationSettings } from "@/components/settings/capabilities/ImageGenerationSettings"; +import { AdvancedSettings } from "@/components/settings/capabilities/SecuritySettings"; +import { TranscriptionSettings } from "@/components/settings/capabilities/TranscriptionSettings"; +import { WebSettings } from "@/components/settings/capabilities/WebSettings"; +import { + ModelPresetDeleteDialog, + ModelsSettings, +} from "@/components/settings/models/ModelsSettings"; +import { + ProviderOAuthLoginDialog, + ProvidersSettings, + providerFormFromRow, +} from "@/components/settings/models/ProviderSettings"; +import { AppearanceSettings, OverviewSettings } from "@/components/settings/overview/OverviewSettings"; +import { SettingsSidebar, standaloneSectionTitle } from "@/components/settings/SettingsSidebar"; +import { + NanobotFeatureInstallDialog, + SettingsGroup, + SettingsRow, +} from "@/components/settings/shared/SettingsControls"; +import { AppsCatalogSettings } from "@/components/settings/system/AppsSettings"; +import { + AutomationDeleteDialog, + AutomationEditDialog, + AutomationsSettings, +} from "@/components/settings/system/AutomationsSettings"; +import { ChannelsSettings } from "@/components/settings/system/ChannelsSettings"; +import { RuntimeSettings } from "@/components/settings/system/RuntimeSettings"; +import type { SettingsController } from "@/components/settings/useSettingsController"; +import type { SkillSummary } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +interface SettingsPageProps { + controller: SettingsController; + theme: "light" | "dark"; + showSidebar: boolean; + onToggleTheme: () => void; + onBackToChat: () => void; + skills: SkillSummary[]; + onLogout?: () => void; + isRestarting: boolean; + hostChromeInset: boolean; +} + +export function SettingsPage({ + controller, + theme, + showSidebar, + onToggleTheme, + onBackToChat, + skills, + onLogout, + isRestarting, + hostChromeInset, +}: SettingsPageProps) { + const { + activeSection, + apiService, + apiServiceAction, + apiServiceError, + apiServiceLoading, + appsKindFilter, + appsQuery, + automationAction, + automationPendingDelete, + automationPendingEdit, + automations, + automationsError, + automationsFilter, + automationsLoading, + automationsQuery, + automationsSort, + beginModelPresetCreation, + cancelModelPresetCreation, + changeModelCallOrder, + channelsQuery, + cliApps, + cliAppsAction, + cliAppsError, + cliAppsFocusName, + cliAppsLoading, + cliAppsMessage, + closeProviderOAuthFlow, + completeProviderOAuthResponse, + createCustomProvider, + customMcpForm, + editingProviderKeys, + error, + expandedProvider, + featureCatalog, + form, + handleApiServiceAction, + handleAutomationAction, + handleAutomationEdit, + handleCliAppAction, + handleDeleteModelConfiguration, + handleImportMcpConfig, + handleMcpOAuthCancel, + handleMcpOAuthComplete, + handleMcpOAuthConnect, + handleMcpOAuthOpen, + handleMcpPresetAction, + handleMcpToolsChange, + handleMigrateModelConfigurations, + handleNanobotFeatureAction, + handleSaveCustomMcp, + handleToggleProvider, + handleWebSearchProviderChange, + hasPendingRestart, + hostEngineApplying, + imageGenerationDirty, + imageGenerationForm, + imageGenerationSaving, + installCapabilities, + loading, + localPrefs, + mcpConfigImport, + mcpError, + mcpFieldValues, + mcpMessage, + mcpOAuthCallbackError, + mcpOAuthCallbackUrl, + mcpOAuthCompleting, + mcpOAuthFlow, + mcpOAuthPopupBlocked, + mcpPresetAction, + mcpPresets, + mcpPresetsLoading, + modelCallOrder, + modelCallOrderSaving, + modelConfigurationSaving, + modelDirty, + modelMigrationSaving, + modelPresetBeforeCreateRef, + modelPresetCreating, + modelPresetPendingDelete, + nanobotFeatureAction, + nanobotFeatureConfirm, + nanobotFeatures, + nanobotFeaturesError, + nanobotFeaturesLoading, + networkSafetyDirty, + networkSafetyForm, + networkSafetySaving, + pendingRestartSections, + providerForms, + providerOAuthCompleting, + providerOAuthDialogError, + providerOAuthFlow, + providerOAuthResponse, + providerSaving, + remoteBrowserAccess, + resetWebSearchDraft, + restartViaSettingsSurface, + runProviderOAuth, + saveImageGenerationSettings, + saveModelSettings, + saveNetworkSafetySettings, + saveProvider, + saveTranscriptionSettings, + saveWebSearch, + saving, + selectSection, + setAppsKindFilter, + setAppsQuery, + setAutomationPendingDelete, + setAutomationPendingEdit, + setAutomationsFilter, + setAutomationsQuery, + setAutomationsSort, + setChannelsQuery, + setCliAppsError, + setCliAppsMessage, + setCustomMcpForm, + setForm, + setImageGenerationForm, + setLocalPrefs, + setMcpConfigImport, + setMcpError, + setMcpFieldValues, + setMcpMessage, + setMcpOAuthCallbackError, + setMcpOAuthCallbackUrl, + setModelPresetCreating, + setModelPresetPendingDelete, + setNanobotFeatureConfirm, + setNanobotFeatures, + setNanobotFeaturesError, + setNetworkSafetyForm, + setProviderForms, + setProviderOAuthDialogError, + setProviderOAuthResponse, + setTranscriptionForm, + setWebSearchForm, + setWebSearchKeyEditing, + setWebSearchKeyVisible, + settings, + t, + toggleProviderKeyEditing, + toggleProviderKeyVisibility, + token, + transcriptionDirty, + transcriptionForm, + transcriptionSaving, + visibleProviderKeys, + webSearchForm, + webSearchKeyEditing, + webSearchKeyVisible, + webSearchSaving, + } = controller; + + 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 ( + void handleMcpOAuthCancel()} + onMcpOAuthOpen={handleMcpOAuthOpen} + onMcpOAuthCallbackUrlChange={(value) => { + setMcpOAuthCallbackUrl(value); + setMcpOAuthCallbackError(null); + }} + onMcpOAuthComplete={() => void handleMcpOAuthComplete()} + onDismissStatus={() => { + 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} +
+
+
+ ); +} diff --git a/webui/src/components/settings/SettingsSidebar.tsx b/webui/src/components/settings/SettingsSidebar.tsx new file mode 100644 index 000000000..c271f762c --- /dev/null +++ b/webui/src/components/settings/SettingsSidebar.tsx @@ -0,0 +1,188 @@ +import { useRef } from "react"; +import { + Activity, + Check, + ChevronDown, + ChevronLeft, + Globe2, + ImageIcon, + LogOut, + MessageCircle, + Mic, + Palette, + Server, + ShieldCheck, + SlidersHorizontal, + type LucideIcon, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { + SIDEBAR_SELECTION_ITEM_CLASS, + SidebarSelectionHighlight, +} from "@/components/SidebarSelectionHighlight"; +import type { SettingsSectionKey } from "@/components/settings/contracts"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +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" }, +]; + +export 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"; +} + +export 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 ( + + ); +} diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 5cf707478..07f162b0f 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -1,540 +1,9 @@ -import { - useCallback, - useEffect, - forwardRef, - useId, - 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 { SettingsPage } from "@/components/settings/SettingsPage"; +import type { SettingsSectionKey } from "@/components/settings/contracts"; +import { useSettingsController } from "@/components/settings/useSettingsController"; +import type { SettingsPayload, SkillSummary } from "@/lib/types"; -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 { - ComboboxOption, - useComboboxNavigation, -} from "@/components/ui/combobox"; -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 { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { SegmentedControl } from "@/components/ui/segmented-control"; -import { Textarea } from "@/components/ui/textarea"; -import { isLoopbackHost } from "@/lib/network"; -import { - cancelMcpOAuth, - checkVersion, - completeMcpOAuth, - completeProviderOAuth, - createModelConfiguration, - createProviderSettings, - deleteModelConfiguration, - disableNanobotFeature, - enableNanobotFeature, - fetchApiService, - fetchAutomations, - fetchSettings, - fetchSettingsUsage, - fetchCliApps, - fetchMcpPresets, - fetchMcpOAuthStatus, - fetchNanobotFeatures, - fetchProviderModels, - importMcpConfig, - loginProviderOAuth, - logoutProviderOAuth, - migrateModelConfigurations, - runAutomationAction, - runCliAppAction, - runMcpPresetAction, - saveCustomMcpServer, - startMcpOAuth, - startApiService, - stopApiService, - updateAutomation, - updateImageGenerationSettings, - updateMcpServerTools, - updateModelCallOrder, - updateModelConfiguration, - updateNetworkSafetySettings, - updateProviderSettings, - 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, - McpOAuthFlowPayload, - 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"; -} - -function isExpectedMcpOAuthPendingReloadFailure( - payload: McpPresetsPayload, - expectedName?: string, -): boolean { - if ( - !expectedName - || payload.last_action?.ok === false - || payload.hot_reload?.ok !== false - ) return false; - - const normalizedName = expectedName.trim().toLowerCase(); - const failed = payload.hot_reload.failed ?? []; - if ( - !normalizedName - || failed.length !== 1 - || failed[0].trim().toLowerCase() !== normalizedName - ) return false; - - return payload.presets.some((preset) => ( - preset.name.trim().toLowerCase() === normalizedName - && preset.auth === "oauth" - && preset.status === "authorization_required" - )); -} - -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"; -type CustomMcpAuth = "none" | "oauth" | "headers"; - -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"]); -type ProviderRequestOption = { - kind: "priority" | "hosted_tool"; - titleKey: string; - title: string; - helpKey: string; - help: string; - toolType?: "web_search" | "x_search"; - defaultEnabled?: boolean; - forceResponses?: boolean; -}; -const PROVIDER_REQUEST_OPTIONS: Partial> = { - openai_codex: [{ - kind: "priority", - titleKey: "settings.providers.capabilityFastMode", - title: "Fast mode", - helpKey: "settings.providers.capabilityFastModeHelp", - help: "Use OpenAI's priority service tier for faster responses. This consumes credits faster.", - }], - openai: [{ - kind: "hosted_tool", - titleKey: "settings.providers.capabilityOpenAISearch", - title: "OpenAI web search", - helpKey: "settings.providers.capabilityOpenAISearchHelp", - help: "Allow compatible Responses API models to search the web. Search activity appears in chat.", - toolType: "web_search", - forceResponses: true, - }], - deepseek: [{ - kind: "hosted_tool", - titleKey: "settings.providers.capabilityDeepSeekSearch", - title: "DeepSeek web search", - helpKey: "settings.providers.capabilityDeepSeekSearchHelp", - help: "Let DeepSeek V4 Flash search the web through its Responses API. Search activity appears in chat.", - toolType: "web_search", - defaultEnabled: true, - }], - xai_grok: [{ - kind: "hosted_tool", - titleKey: "settings.providers.capabilityXSearch", - title: "X Search", - helpKey: "settings.providers.capabilityXSearchHelp", - help: "Allow supported Grok models to use xAI-hosted X Search. Search activity appears in chat.", - toolType: "x_search", - defaultEnabled: true, - }], -}; -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 parseProviderExtraBody(value: string): Record | null { - if (!value.trim()) return {}; - try { - const parsed: unknown = JSON.parse(value); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? parsed as Record - : null; - } catch { - return null; - } -} - -function isHostedSearchTool(tool: unknown, toolType: "web_search" | "x_search"): boolean { - if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; - const configuredType = (tool as Record).type; - if (typeof configuredType !== "string") return false; - return configuredType === toolType - || (toolType === "web_search" && configuredType.startsWith("web_search_")); -} - -function hasHostedSearchTool(value: unknown, toolType: "web_search" | "x_search"): boolean { - return Array.isArray(value) && value.some((tool) => isHostedSearchTool(tool, toolType)); -} - -function providerRequestOptionEnabled( - option: ProviderRequestOption, - extraBody: Record, -): boolean { - if (option.kind === "priority") return extraBody.service_tier === "priority"; - if (Object.prototype.hasOwnProperty.call(extraBody, "tools")) { - return hasHostedSearchTool(extraBody.tools, option.toolType!); - } - return option.defaultEnabled === true; -} - -function updateProviderRequestOption( - option: ProviderRequestOption, - enabled: boolean, - form: ProviderForm, -): Partial { - const extraBody = { ...(parseProviderExtraBody(form.extraBody) ?? {}) }; - if (option.kind === "priority") { - if (enabled) extraBody.service_tier = "priority"; - else if (extraBody.service_tier === "priority") delete extraBody.service_tier; - } else { - const toolType = option.toolType!; - const tools = Array.isArray(extraBody.tools) - ? extraBody.tools.filter((tool) => !isHostedSearchTool(tool, toolType)) - : []; - if (enabled) tools.push({ type: toolType }); - if (tools.length || option.defaultEnabled) { - extraBody.tools = tools; - } else { - delete extraBody.tools; - } - } - return { - extraBody: providerJsonValue(extraBody), - ...(option.forceResponses && enabled - ? { apiType: "responses" as const } - : {}), - }; -} - -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", -); - -interface CustomMcpForm { - name: string; - transport: CustomMcpTransport; - auth: CustomMcpAuth; - 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", - auth: "none", - command: "", - args: "", - url: "", - env: "", - headers: "", - toolTimeout: "30", -}; +export type { SettingsSectionKey } from "@/components/settings/contracts"; interface SettingsViewProps { theme: "light" | "dark"; @@ -554,192 +23,6 @@ interface SettingsViewProps { 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", @@ -757,9768 +40,27 @@ export function SettingsView({ isRestarting = false, hostChromeInset = false, }: SettingsViewProps) { - const { t } = useTranslation(); - const { client, 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 [mcpOAuthFlow, setMcpOAuthFlow] = useState(null); - const mcpOAuthFlowRef = useRef(null); - const mcpOAuthPopupRef = useRef(null); - const mcpOAuthNavigatedUrlRef = useRef(null); - const [mcpOAuthPopupBlocked, setMcpOAuthPopupBlocked] = useState(false); - const [mcpOAuthCallbackUrl, setMcpOAuthCallbackUrl] = useState(""); - const [mcpOAuthCompleting, setMcpOAuthCompleting] = useState(false); - const [mcpOAuthCallbackError, setMcpOAuthCallbackError] = 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( - client, - 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, client, closeProviderOAuthFlow, 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 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(client, { - 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(client, 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(client, { - 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(client, 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(client); - 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(client, modelPresetPendingDelete.name); - applyPayload(payload); - setModelPresetPendingDelete(null); - 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(client, 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(client, 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(client, 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(client, 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(client, values!) - : await stopApiService(client); - 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(client, 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(client, { - 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( - client, - providerName, - providerName === "openai_codex" && remoteBrowserAccess, - ) - : await logoutProviderOAuth(client, 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( - client, - 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(client, 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(client, 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(client, name) - : await disableNanobotFeature(client, 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(client, 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(client, job.id, values); - setAutomations(payload); - setAutomationPendingEdit(null); - } catch (err) { - setAutomationsError((err as Error).message); - } finally { - setAutomationAction(null); - } - }; - - const closeMcpOAuthPopup = () => { - const popup = mcpOAuthPopupRef.current; - mcpOAuthPopupRef.current = null; - mcpOAuthNavigatedUrlRef.current = null; - if (!popup) return; - try { - if (!popup.closed) popup.close(); - } catch { - // The authorization page may have navigated cross-origin before it closed itself. - } - }; - - const openMcpOAuthPopup = (authorizationUrl?: string): Window | null => { - let popup: Window | null = null; - try { - popup = window.open( - authorizationUrl ?? "about:blank", - "nanobot-mcp-oauth", - "popup,width=560,height=720,resizable=yes,scrollbars=yes", - ); - if (popup) { - mcpOAuthPopupRef.current = popup; - mcpOAuthNavigatedUrlRef.current = authorizationUrl ?? null; - if (!authorizationUrl) { - try { - popup.document.title = t("settings.oauth.signingIn", { defaultValue: "Preparing sign-in…" }); - popup.document.body.textContent = t("settings.mcp.preparingSignIn", { - defaultValue: "Preparing secure sign-in…", - }); - } catch { - // about:blank can become unavailable if the window is reused mid-navigation. - } - } - try { - popup.opener = null; - popup.focus(); - } catch { - // A cross-origin authorization page can restrict window access. - } - } - } catch { - // Browsers can reject popup creation before returning a window handle. - } - setMcpOAuthPopupBlocked(!popup); - return popup; - }; - - const navigateMcpOAuthPopup = (flow: McpOAuthFlowPayload) => { - const authorizationUrl = flow.authorization_url; - if (!authorizationUrl) return; - const popup = mcpOAuthPopupRef.current; - // OAuth pages can use Cross-Origin-Opener-Policy, which severs the - // WindowProxy and makes an open tab appear closed. Once navigation was - // requested, do not mistake that browser isolation for a blocked popup. - if (popup && mcpOAuthNavigatedUrlRef.current === authorizationUrl) return; - try { - if (popup && !popup.closed) { - popup.location.replace(authorizationUrl); - mcpOAuthNavigatedUrlRef.current = authorizationUrl; - popup.focus(); - setMcpOAuthPopupBlocked(false); - return; - } - if (popup) return; - } catch { - // Fall through to the explicit Continue in browser action. - } - setMcpOAuthPopupBlocked(true); - }; - - const finishMcpOAuthFlow = async (flow: McpOAuthFlowPayload) => { - if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return; - closeMcpOAuthPopup(); - mcpOAuthFlowRef.current = null; - setMcpOAuthFlow(null); - setMcpPresetAction(null); - setMcpOAuthCallbackUrl(""); - setMcpOAuthCompleting(false); - setMcpOAuthCallbackError(null); - - if (flow.status === "connected") { - try { - const payload = await fetchMcpPresets(getToken()); - setMcpPresets(payload); - notifyMcpPresetsChanged(payload); - setMcpMessage(null); - setMcpError(null); - } catch (err) { - setMcpError((err as Error).message); - } - return; - } - - if (flow.status === "authorized" && flow.hot_reload) { - if (flow.hot_reload.requires_restart) { - setPendingRestartSections((prev) => ({ ...prev, runtime: true })); - } - setMcpError( - flow.hot_reload.message - || t("settings.mcp.reloadFailed", { - defaultValue: "Signed in, but nanobot could not connect the tools. Try restarting nanobot.", - }), - ); - return; - } - - if (flow.status === "failed") { - setMcpError( - flow.error - || t("settings.mcp.oauthFailed", { - defaultValue: "Unable to connect. Try signing in again.", - }), - ); - } - }; - - const monitorMcpOAuthFlow = async (initial: McpOAuthFlowPayload) => { - let current = initial; - while (mcpOAuthFlowRef.current?.flow_id === current.flow_id) { - navigateMcpOAuthPopup(current); - const terminal = - current.status === "connected" - || current.status === "failed" - || current.status === "cancelled" - || (current.status === "authorized" && Boolean(current.hot_reload)); - if (terminal) { - await finishMcpOAuthFlow(current); - return; - } - - await new Promise((resolve) => window.setTimeout(resolve, 800)); - if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return; - try { - current = await fetchMcpOAuthStatus(getToken(), current.flow_id); - if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return; - mcpOAuthFlowRef.current = current; - setMcpOAuthFlow(current); - } catch (err) { - if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return; - closeMcpOAuthPopup(); - mcpOAuthFlowRef.current = null; - setMcpOAuthFlow(null); - setMcpPresetAction(null); - setMcpOAuthCallbackUrl(""); - setMcpOAuthCompleting(false); - setMcpOAuthCallbackError(null); - setMcpError((err as Error).message); - return; - } - } - }; - - const handleMcpOAuthConnect = async (name: string) => { - openMcpOAuthPopup(); - const key = `oauth:${name}`; - setMcpPresetAction(key); - setMcpMessage(null); - setMcpError(null); - setMcpOAuthCallbackUrl(""); - setMcpOAuthCompleting(false); - setMcpOAuthCallbackError(null); - try { - const flow = await startMcpOAuth(client, name); - mcpOAuthFlowRef.current = flow; - setMcpOAuthFlow(flow); - navigateMcpOAuthPopup(flow); - void monitorMcpOAuthFlow(flow); - } catch (err) { - closeMcpOAuthPopup(); - mcpOAuthFlowRef.current = null; - setMcpOAuthFlow(null); - setMcpPresetAction(null); - setMcpOAuthCallbackUrl(""); - setMcpOAuthCompleting(false); - setMcpOAuthCallbackError(null); - setMcpError((err as Error).message); - } - }; - - const handleMcpOAuthCancel = async () => { - const flow = mcpOAuthFlowRef.current; - if (!flow) return; - mcpOAuthFlowRef.current = null; - setMcpOAuthFlow(null); - setMcpPresetAction(null); - setMcpOAuthCallbackUrl(""); - setMcpOAuthCompleting(false); - setMcpOAuthCallbackError(null); - closeMcpOAuthPopup(); - try { - await cancelMcpOAuth(client, flow.flow_id); - } catch (err) { - setMcpError((err as Error).message); - } - }; - - const handleMcpOAuthOpen = () => { - const authorizationUrl = mcpOAuthFlowRef.current?.authorization_url; - if (!authorizationUrl) return; - openMcpOAuthPopup(authorizationUrl); - }; - - const handleMcpOAuthComplete = async () => { - const flow = mcpOAuthFlowRef.current; - const callbackUrl = mcpOAuthCallbackUrl.trim(); - if (!flow || flow.completion_input !== "callback_url") return; - if (!callbackUrl) { - setMcpOAuthCallbackError(t("settings.oauth.pasteCallbackToContinue")); - return; - } - setMcpOAuthCompleting(true); - setMcpOAuthCallbackError(null); - try { - const next = await completeMcpOAuth(client, flow.flow_id, callbackUrl); - if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return; - mcpOAuthFlowRef.current = next; - setMcpOAuthFlow(next); - } catch (err) { - if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return; - setMcpOAuthCallbackError((err as Error).message); - } finally { - if (mcpOAuthFlowRef.current?.flow_id === flow.flow_id) { - setMcpOAuthCompleting(false); - } - } - }; - - const applyMcpActionFeedback = ( - payload: McpPresetsPayload, - announceSuccess = false, - expectedOAuthPendingName?: string, - ) => { - const expectedOAuthPending = isExpectedMcpOAuthPendingReloadFailure( - payload, - expectedOAuthPendingName, - ); - const actionError = payload.last_action?.ok === false - ? payload.last_action.error || payload.last_action.message - : payload.hot_reload?.ok === false && !expectedOAuthPending - ? payload.hot_reload.message - : null; - setMcpError(actionError || null); - setMcpMessage( - actionError || !announceSuccess - ? null - : payload.last_action?.message ?? 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(client, action, name, values); - setMcpPresets(payload); - applyMcpActionFeedback(payload, action === "test"); - 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 expectsOAuthAuthorization = ( - customMcpForm.transport !== "stdio" && customMcpForm.auth === "oauth" - ); - const key = `custom:${name || "new"}`; - setMcpPresetAction(key); - setMcpMessage(null); - setMcpError(null); - try { - const payload = await saveCustomMcpServer(client, { - name, - transport: customMcpForm.transport, - auth: - customMcpForm.transport !== "stdio" && customMcpForm.auth === "oauth" - ? "oauth" - : "", - command: customMcpForm.command, - args: customMcpForm.args, - url: customMcpForm.url, - env: customMcpForm.env, - headers: - customMcpForm.transport !== "stdio" && customMcpForm.auth === "headers" - ? customMcpForm.headers - : "", - tool_timeout: customMcpForm.toolTimeout, - }); - setMcpPresets(payload); - applyMcpActionFeedback( - payload, - false, - expectsOAuthAuthorization ? name : undefined, - ); - 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(client, mcpConfigImport); - setMcpPresets(payload); - applyMcpActionFeedback(payload); - 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(client, name, enabledTools); - setMcpPresets(payload); - applyMcpActionFeedback(payload); - 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 ( - void handleMcpOAuthCancel()} - onMcpOAuthOpen={handleMcpOAuthOpen} - onMcpOAuthCallbackUrlChange={(value) => { - setMcpOAuthCallbackUrl(value); - setMcpOAuthCallbackError(null); - }} - onMcpOAuthComplete={() => void handleMcpOAuthComplete()} - onDismissStatus={() => { - 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, + const controller = useSettingsController({ + initialSection, + initialSettings, + onModelNameChange, + onSettingsChange, + onSectionChange, + onRestart, + onNativeEngineRestart, }); 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 ? ( -