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.backToChat")}
+
+
+ {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 (
+
+
+
+ {t("settings.backToChat")}
+
+
+
+ {t("settings.sidebar.title")}
+
+
+
+
+
+
+
+
+ {activeLabel}
+
+
+
+
+ {SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
+ const active = key === activeSection;
+ return (
+ onSelectSection(key)}
+ className={cn(
+ "flex h-10 cursor-default items-center gap-2.5 px-2.5 text-[13px] font-medium",
+ active && "bg-sidebar-accent text-foreground focus:bg-sidebar-accent",
+ )}
+ >
+
+
+ {t(`settings.nav.${key}`, { defaultValue: fallback })}
+
+ {active ? : null}
+
+ );
+ })}
+
+
+
+
+ {SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
+ const active = key === activeSection;
+ return (
+ onSelectSection(key)}
+ className={cn(
+ "touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
+ SIDEBAR_SELECTION_ITEM_CLASS,
+ active
+ ? "text-sidebar-accent-foreground"
+ : "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
+ )}
+ >
+
+
+ {t(`settings.nav.${key}`, { defaultValue: fallback })}
+
+
+ );
+ })}
+
+
+
+
+ {onLogout && !hostChromeInset ? (
+
+
+ {t("app.account.logout")}
+
+ ) : null}
+
+
+ );
+}
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.backToChat")}
-
-
- {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 (
-
-
-
- {t("settings.backToChat")}
-
-
-
- {t("settings.sidebar.title")}
-
-
-
-
-
-
-
-
- {activeLabel}
-
-
-
-
- {SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
- const active = key === activeSection;
- return (
- onSelectSection(key)}
- className={cn(
- "flex h-10 cursor-default items-center gap-2.5 px-2.5 text-[13px] font-medium",
- active && "bg-sidebar-accent text-foreground focus:bg-sidebar-accent",
- )}
- >
-
-
- {t(`settings.nav.${key}`, { defaultValue: fallback })}
-
- {active ? : null}
-
- );
- })}
-
-
-
-
- {SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
- const active = key === activeSection;
- return (
- onSelectSection(key)}
- className={cn(
- "touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
- SIDEBAR_SELECTION_ITEM_CLASS,
- active
- ? "text-sidebar-accent-foreground"
- : "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
- )}
- >
-
-
- {t(`settings.nav.${key}`, { defaultValue: fallback })}
-
-
- );
- })}
-
-
-
-
- {onLogout && !hostChromeInset ? (
-
-
- {t("app.account.logout")}
-
- ) : null}
-
-
- );
-}
-
-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"}
-
-
-
-
void handleCheck()}
- disabled={checking}
- className="rounded-full"
- >
- {checking ? (
-
- ) : (
-
- )}
- {checking
- ? tx("settings.about.checking", "Checking...")
- : tx("settings.about.checkForUpdates", "Check for updates")}
-
- {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")}
-
-
-
-
- {t("settings.values.light")}
-
-
- {t("settings.values.dark")}
-
-
-
-
-
-
-
-
-
-
-
- {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();
- }}
- >
-
-
-
-
- );
-}
-
-function ModelPresetDeleteDialog({
- preset,
- deleting,
- onOpenChange,
- onConfirm,
-}: {
- preset: SettingsPayload["model_presets"][number] | null;
- deleting: boolean;
- onOpenChange: (open: boolean) => void;
- onConfirm: () => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string, values?: Record) =>
- t(key, { defaultValue: fallback, ...(values ?? {}) });
- return (
-
-
-
-
- {tx("settings.models.deletePresetTitle", "Delete model preset?")}
-
-
- {tx(
- "settings.models.deletePresetHelp",
- "This removes the preset “{{name}}”. Provider credentials are not affected.",
- { name: preset?.label ?? "" },
- )}
-
-
-
- onOpenChange(false)}
- >
- {tx("settings.actions.cancel", "Cancel")}
-
-
- {deleting ? (
-
- ) : null}
- {deleting
- ? tx("settings.actions.deleting", "Deleting...")
- : tx("settings.actions.delete", "Delete")}
-
-
-
-
- );
-}
-
-function CapabilityInstallNotice({
- title,
- description,
- installing = false,
-}: {
- title: string;
- description: string;
- installing?: boolean;
-}) {
- return (
-
- {installing ? (
-
- ) : (
-
- )}
-
-
{title}
-
{description}
-
-
- );
-}
-
-function ModelsSettings({
- token,
- form,
- setForm,
- settings,
- dirty,
- creating,
- creatingSaving,
- callOrder,
- saving,
- orderSaving,
- migrationSaving,
- showBrandLogos,
- providerSaving,
- onChangeCallOrder,
- onProviderOAuthLogin,
- onSave,
- onMigrate,
- onBeginCreate,
- onCancelCreate,
- onSelectConfiguration,
- onDeleteConfiguration,
-}: {
- token: string;
- form: AgentSettingsDraft;
- setForm: Dispatch>;
- settings: SettingsPayload;
- dirty: boolean;
- creating: boolean;
- creatingSaving: boolean;
- callOrder: string[];
- saving: boolean;
- orderSaving: boolean;
- migrationSaving: boolean;
- showBrandLogos: boolean;
- providerSaving: string | null;
- onChangeCallOrder: (order: string[]) => void;
- onProviderOAuthLogin: (provider: string) => void;
- onSave: () => void;
- onMigrate: () => void;
- onBeginCreate: () => void;
- onCancelCreate: () => void;
- onSelectConfiguration: () => void;
- onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string, values?: Record) =>
- t(key, { defaultValue: fallback, ...(values ?? {}) });
- const [editorOpen, setEditorOpen] = useState(false);
- const [editorRowKey, setEditorRowKey] = useState(null);
- const [advancedOpen, setAdvancedOpen] = useState(false);
- const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState(null);
- const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState(null);
- const namedPresets = settings.model_presets.filter((preset) => !preset.is_default);
- const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
- const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
- const callOrderOccurrences = new Map();
- const presetRows = [
- ...callOrder.map((name, orderIndex) => {
- const occurrence = callOrderOccurrences.get(name) ?? 0;
- callOrderOccurrences.set(name, occurrence + 1);
- return {
- key: `ordered:${name}:${occurrence}`,
- name,
- orderIndex,
- preset: namedPresetsByName.get(name),
- };
- }),
- ...unorderedPresets.map((preset) => ({
- key: `disabled:${preset.name}`,
- name: preset.name,
- orderIndex: -1,
- preset,
- })),
- ];
- const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null;
- const activeEditorRowKey =
- editorRowKey ??
- presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
- null;
- useEffect(() => {
- setAdvancedOpen(false);
- }, [editorOpen, selectedPreset?.name]);
-
- const configuredProviders = settings.providers.filter((provider) => provider.configured);
- const selectedProvider = settings.providers.find((provider) => provider.name === form.provider);
- const selectableProviders = uniqueProviders([
- ...configuredProviders,
- ...(selectedProvider ? [selectedProvider] : []),
- ]);
- const showAutoProvider = selectedPreset?.provider === "auto" || form.provider === "auto";
- const providerOptions = showAutoProvider
- ? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
- : selectableProviders;
- const providerValue = providerOptions.some((provider) => provider.name === form.provider)
- ? form.provider
- : "";
- const selectedProviderNeedsSignIn =
- selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
- const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
- const selectedProviderConfigured = settingsProviderConfigured(
- settings,
- form.provider,
- selectedPreset?.resolved_provider,
- );
- const modelFieldsMissing =
- !form.model.trim() ||
- !form.provider.trim() ||
- !form.presetLabel.trim() ||
- form.maxTokens <= 0 ||
- form.temperature < 0 ||
- form.temperature > 2;
- const selectedPresetReferenced = Boolean(
- selectedPreset && callOrder.includes(selectedPreset.name),
- );
- const callOrderBusy = orderSaving || saving;
- const selectPreset = (
- preset: SettingsPayload["model_presets"][number],
- rowKey: string,
- ) => {
- const toggleCurrentPreset =
- !creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
- onSelectConfiguration();
- if (toggleCurrentPreset) {
- setEditorOpen((open) => !open);
- return;
- }
- setForm((prev) => ({
- ...prev,
- modelPreset: preset.name,
- model: preset.model,
- provider: preset.provider,
- presetLabel: preset.label,
- maxTokens: preset.max_tokens,
- contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
- temperature: preset.temperature,
- reasoningEffort: preset.reasoning_effort ?? "",
- }));
- setEditorRowKey(rowKey);
- setEditorOpen(true);
- };
-
- const moveCallOrderItem = (index: number, offset: -1 | 1) => {
- if (callOrderBusy) return;
- const nextIndex = index + offset;
- if (nextIndex < 0 || nextIndex >= callOrder.length) return;
- const next = [...callOrder];
- [next[index], next[nextIndex]] = [next[nextIndex], next[index]];
- onChangeCallOrder(next);
- };
-
- const removeCallOrderItem = (index: number) => {
- if (callOrderBusy || callOrder.length <= 1) return;
- onChangeCallOrder(callOrder.filter((_, itemIndex) => itemIndex !== index));
- };
-
- const dropCallOrderItem = (targetIndex: number) => {
- if (
- callOrderBusy ||
- draggedCallOrderIndex === null ||
- draggedCallOrderIndex === targetIndex
- ) {
- setDraggedCallOrderIndex(null);
- setDragOverCallOrderIndex(null);
- return;
- }
- const next = [...callOrder];
- const moved = next.splice(draggedCallOrderIndex, 1)[0];
- if (!moved) {
- setDraggedCallOrderIndex(null);
- setDragOverCallOrderIndex(null);
- return;
- }
- next.splice(targetIndex, 0, moved);
- setDraggedCallOrderIndex(null);
- setDragOverCallOrderIndex(null);
- onChangeCallOrder(next);
- };
-
- const renderPresetEditor = () => (
-
- {creating ? (
-
-
- {tx("settings.models.newPreset", "New model preset")}
-
-
- ) : null}
-
-
- setForm((prev) => ({ ...prev, presetLabel: event.target.value }))
- }
- className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
- />
-
-
-
- setForm((prev) => ({
- ...prev,
- provider,
- model: provider === prev.provider ? prev.model : "",
- }))
- }
- />
-
- {selectedProviderNeedsSignIn ? (
-
- selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
- disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
- className="rounded-full"
- >
- {selectedProviderSigningIn ? (
-
- ) : null}
- {selectedProviderSigningIn
- ? tx("settings.oauth.signingIn", "Signing in...")
- : tx("settings.oauth.signIn", "Sign in")}
-
-
- ) : null}
-
- setForm((prev) => ({ ...prev, model }))}
- />
-
-
setAdvancedOpen((value) => !value)}
- className="flex min-h-[62px] w-full items-center justify-between gap-4 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
- >
-
-
- {tx("settings.models.advancedOptions", "Advanced options")}
-
-
- {tx(
- "settings.models.advancedSummary",
- "Context {{context}} · Max {{max}} tokens",
- {
- context: formatModelContextWindow(form.contextWindowTokens),
- max: formatContextWindow(form.maxTokens),
- },
- )}
-
-
-
-
- {advancedOpen ? (
-
- setForm((prev) => ({ ...prev, ...value }))}
- />
-
- ) : null}
-
- {creating ? (
-
{
- setEditorOpen(false);
- onCancelCreate();
- }}
- >
- {tx("settings.actions.cancel", "Cancel")}
-
- ) : selectedPreset ? (
-
- onDeleteConfiguration(selectedPreset)}
- >
-
- {tx("settings.actions.delete", "Delete")}
-
- {selectedPresetReferenced ? (
-
- {tx(
- "settings.models.removeBeforeDelete",
- "Remove this preset from the call order before deleting it.",
- )}
-
- ) : null}
-
- ) : null}
-
-
- {saving || creatingSaving
- ? tx("settings.actions.saving", "Saving...")
- : tx("settings.actions.savePreset", "Save preset")}
-
-
-
-
- );
-
- return (
-
-
-
- {tx("settings.models.presets", "Model presets")}
-
-
- {!settings.model_call_order_editable ? (
-
-
-
-
-
-
-
- {tx("settings.models.convertTitle", "Convert the current model setup")}
-
-
- {tx(
- "settings.models.convertHelp",
- "Turn the existing primary and fallback models into presets so their order can be managed here.",
- )}
-
-
-
-
- {migrationSaving ? (
-
- ) : null}
- {migrationSaving
- ? tx("settings.models.converting", "Converting...")
- : tx("settings.models.convertAction", "Convert to presets")}
-
-
- ) : (
- <>
-
- {presetRows.map(({ key, name, orderIndex, preset }) => {
- const ordered = orderIndex >= 0;
- const provider = preset
- ? modelPresetProviderKey(preset, settings)
- : settings.agent.resolved_provider ?? settings.agent.provider;
- const presetConfigured = preset
- ? settingsProviderConfigured(
- settings,
- preset.provider,
- preset.resolved_provider,
- )
- : true;
- const isDropTarget =
- ordered &&
- dragOverCallOrderIndex === orderIndex &&
- draggedCallOrderIndex !== orderIndex;
- const dropAfterTarget =
- isDropTarget &&
- draggedCallOrderIndex !== null &&
- draggedCallOrderIndex < orderIndex;
- const isSelected =
- editorOpen &&
- !creating &&
- activeEditorRowKey === key &&
- selectedPreset?.name === name;
- const presetRow = (
-
{
- if (!ordered || callOrderBusy) {
- event.preventDefault();
- return;
- }
- event.dataTransfer.effectAllowed = "move";
- event.dataTransfer.setData("text/plain", name);
- setDraggedCallOrderIndex(orderIndex);
- setDragOverCallOrderIndex(orderIndex);
- }}
- onDragEnd={() => {
- setDraggedCallOrderIndex(null);
- setDragOverCallOrderIndex(null);
- }}
- onDragEnter={(event) => {
- if (ordered && draggedCallOrderIndex !== null) {
- event.preventDefault();
- setDragOverCallOrderIndex(orderIndex);
- }
- }}
- onDragOver={(event) => {
- if (!ordered || draggedCallOrderIndex === null) return;
- event.preventDefault();
- event.dataTransfer.dropEffect = "move";
- }}
- onDrop={(event) => {
- if (!ordered) return;
- event.preventDefault();
- dropCallOrderItem(orderIndex);
- }}
- onKeyDown={(event) => {
- if (event.currentTarget !== event.target) return;
- if (ordered && event.key === "ArrowUp") {
- event.preventDefault();
- moveCallOrderItem(orderIndex, -1);
- } else if (ordered && event.key === "ArrowDown") {
- event.preventDefault();
- moveCallOrderItem(orderIndex, 1);
- } else if ((event.key === "Enter" || event.key === " ") && preset) {
- event.preventDefault();
- selectPreset(preset, key);
- }
- }}
- className={cn(
- "group relative flex min-h-[76px] select-none items-center gap-3 px-4 py-3 outline-none transition-[background-color,opacity] duration-150 sm:px-5",
- ordered &&
- (callOrderBusy
- ? "cursor-wait"
- : "cursor-grab active:cursor-grabbing"),
- "hover:bg-muted/25",
- isDropTarget &&
- !dropAfterTarget &&
- "before:absolute before:inset-x-4 before:top-0 before:z-10 before:h-0.5 before:rounded-full before:bg-foreground sm:before:inset-x-5",
- isDropTarget &&
- dropAfterTarget &&
- "after:absolute after:inset-x-4 after:bottom-0 after:z-10 after:h-0.5 after:rounded-full after:bg-foreground sm:after:inset-x-5",
- ordered && draggedCallOrderIndex === orderIndex && "opacity-35",
- isSelected && "bg-muted/45 hover:bg-muted/45",
- "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
- )}
- >
- {ordered ? (
-
- ) : (
-
- )}
-
preset && selectPreset(preset, key)}
- className="flex min-w-0 flex-1 items-center gap-3 rounded-[12px] text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
- >
- {ordered ? (
-
- {orderIndex + 1}
-
- ) : (
-
- )}
-
-
-
-
- {preset?.label ?? name}
-
- {orderIndex === 0 ? (
-
- {tx("settings.models.primary", "Primary")}
-
- ) : !ordered ? (
-
- {tx("settings.models.disabled", "Disabled")}
-
- ) : null}
- {!presetConfigured ? (
-
- {tx(
- "settings.models.providerSetupRequired",
- "Provider setup required",
- )}
-
- ) : null}
-
-
- {preset?.model ?? name}
-
-
-
-
-
{
- if (ordered) {
- removeCallOrderItem(orderIndex);
- } else if (preset) {
- onChangeCallOrder([...callOrder, preset.name]);
- }
- }}
- className={cn(
- "relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40",
- ordered ? "bg-foreground" : "bg-muted-foreground/25",
- )}
- >
-
-
-
- );
- return (
-
- {presetRow}
- {isSelected ? renderPresetEditor() : null}
-
- );
- })}
-
-
- {!creating ? (
-
{
- setEditorRowKey(null);
- setEditorOpen(true);
- onBeginCreate();
- }}
- >
-
- {tx("settings.models.newPreset", "New model preset")}
-
- ) : (
-
- )}
- {orderSaving ? (
-
-
-
- {tx("settings.actions.saving", "Saving...")}
-
-
- ) : null}
-
- {creating && editorOpen ? renderPresetEditor() : null}
- >
- )}
-
-
-
- );
-}
-
-function ModelAdvancedFields({
- maxTokens,
- contextWindowTokens,
- temperature,
- reasoningEffort,
- onChange,
-}: {
- maxTokens: number;
- contextWindowTokens: number;
- temperature: number;
- reasoningEffort: string;
- onChange: (
- value: Partial<
- Pick<
- AgentSettingsDraft,
- "maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
- >
- >,
- ) => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const contextWindowOptions = Array.from(
- new Set([...CONTEXT_WINDOW_TOKEN_OPTIONS, contextWindowTokens]),
- ).sort((left, right) => left - right);
- return (
-
- );
-}
-
-function ProviderRequestOptions({
- providerName,
- form,
- onChange,
-}: {
- providerName: string;
- form: ProviderForm;
- onChange: (value: Partial) => void;
-}) {
- const { t } = useTranslation();
- const options = PROVIDER_REQUEST_OPTIONS[providerName] ?? [];
- if (options.length === 0) return null;
- const extraBody = parseProviderExtraBody(form.extraBody) ?? {};
-
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
-
- return (
-
- {options.map((option, index) => {
- const title = tx(option.titleKey, option.title);
- const Icon = option.kind === "priority" ? Zap : Globe2;
- const checked = providerRequestOptionEnabled(option, extraBody);
- return (
-
0 && "border-t border-border/45",
- )}
- >
-
-
-
-
-
-
{title}
-
- {tx(option.helpKey, option.help)}
-
-
-
-
onChange(
- updateProviderRequestOption(option, enabled, form),
- )}
- ariaLabel={title}
- label={checked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
- />
-
- );
- })}
-
- );
-}
-
-function ProviderAdvancedOptions({
- fields,
- form,
- onChange,
- footer,
-}: {
- fields: ProviderAdvancedField[];
- form: ProviderForm;
- onChange: (value: Partial) => void;
- footer?: ReactNode;
-}) {
- const { t } = useTranslation();
- const [open, setOpen] = useState(false);
- const enabled = new Set(fields);
- if (enabled.size === 0) return null;
-
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const thinkingStyleOptions = [
- { value: "", label: tx("settings.values.default", "Default") },
- { value: "thinking_type", label: "thinking_type" },
- { value: "enable_thinking", label: "enable_thinking" },
- { value: "reasoning_split", label: "reasoning_split" },
- ];
-
- return (
-
-
setOpen((value) => !value)}
- className="flex min-h-[48px] w-full items-center justify-between gap-4 px-1 py-2.5 text-left transition-colors hover:text-foreground"
- >
-
- {tx("settings.providers.advancedOptions", "Advanced options")}
-
-
-
- {open ? (
-
-
- {enabled.has("api_type") ? (
-
-
- {tx("settings.providers.apiType", "API type")}
-
-
-
-
-
- {OPENAI_API_TYPE_OPTIONS.find(
- (option) => option.value === form.apiType,
- )?.label ?? form.apiType}
-
-
-
-
-
- {OPENAI_API_TYPE_OPTIONS.map((option) => (
- onChange({ apiType: option.value })}
- >
- {option.label}
-
- ))}
-
-
-
- ) : null}
- {enabled.has("thinking_style") ? (
-
-
- {tx("settings.providers.thinkingStyle", "Thinking style")}
-
-
-
-
-
- {thinkingStyleOptions.find(
- (option) => option.value === form.thinkingStyle,
- )?.label ?? form.thinkingStyle}
-
-
-
-
-
- {thinkingStyleOptions.map((option) => (
- onChange({ thinkingStyle: option.value })}
- className="font-mono text-[12px]"
- >
- {option.label}
-
- ))}
-
-
-
- ) : null}
- {enabled.has("proxy") ? (
-
-
- {tx("settings.providers.proxy", "Network proxy")}
-
- onChange({ proxy: event.target.value })}
- placeholder="http://127.0.0.1:7890"
- autoCapitalize="none"
- autoComplete="off"
- autoCorrect="off"
- spellCheck={false}
- className="h-9 rounded-full font-mono text-[12px]"
- />
-
- ) : null}
- {enabled.has("region") ? (
-
-
- {tx("settings.providers.region", "Region")}
-
- onChange({ region: event.target.value })}
- placeholder="us-east-1"
- autoCapitalize="none"
- autoComplete="off"
- autoCorrect="off"
- spellCheck={false}
- className="h-9 rounded-full font-mono text-[12px]"
- />
-
- ) : null}
- {enabled.has("profile") ? (
-
-
- {tx("settings.providers.profile", "Profile")}
-
- onChange({ profile: event.target.value })}
- placeholder="default"
- autoCapitalize="none"
- autoComplete="off"
- autoCorrect="off"
- spellCheck={false}
- className="h-9 rounded-full font-mono text-[12px]"
- />
-
- ) : null}
- {enabled.has("extra_headers") ? (
-
-
- {tx("settings.providers.extraHeaders", "Extra headers")}
-
- onChange({ extraHeaders: event.target.value })}
- placeholder={'{"X-Header":"value"}'}
- spellCheck={false}
- className="min-h-[88px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
- />
-
- ) : null}
- {enabled.has("extra_query") ? (
-
-
- {tx("settings.providers.extraQuery", "Extra query")}
-
- onChange({ extraQuery: event.target.value })}
- placeholder={'{"api-version":"2024-02-01"}'}
- spellCheck={false}
- className="min-h-[88px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
- />
-
- ) : null}
- {enabled.has("extra_body") ? (
-
-
- {tx("settings.providers.extraBody", "Extra body")}
-
- onChange({ extraBody: event.target.value })}
- placeholder={'{"service_tier":"priority"}'}
- spellCheck={false}
- className="min-h-[96px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
- />
-
- ) : null}
-
-
- ) : null}
- {footer ? (
-
- {footer}
-
- ) : null}
-
- );
-}
-
-function ProvidersSettings({
- settings,
- nanobotFeatures,
- featureAction,
- capabilityError,
- expandedProvider,
- providerForms,
- visibleProviderKeys,
- editingProviderKeys,
- providerSaving,
- showBrandLogos,
- remoteBrowserAccess,
- onToggleProvider,
- onToggleProviderKey,
- onToggleProviderKeyEditing,
- onChangeProviderForm,
- onSaveProvider,
- onCreateCustomProvider,
- onProviderOAuthLogin,
- onProviderOAuthLogout,
- imageProviderRestartPending,
- onRestart,
- isRestarting,
-}: {
- settings: SettingsPayload;
- nanobotFeatures: NanobotFeaturesPayload | null;
- featureAction: string | null;
- capabilityError: string | null;
- expandedProvider: string | null;
- providerForms: Record;
- visibleProviderKeys: Record;
- editingProviderKeys: Record;
- providerSaving: string | null;
- showBrandLogos: boolean;
- remoteBrowserAccess: boolean;
- onToggleProvider: (provider: string) => void;
- onToggleProviderKey: (provider: string) => void;
- onToggleProviderKeyEditing: (provider: string) => void;
- onChangeProviderForm: (provider: string, value: Partial) => void;
- onSaveProvider: (provider: string) => void;
- onCreateCustomProvider: (draft: CustomProviderDraft) => Promise;
- onProviderOAuthLogin: (provider: string) => void;
- onProviderOAuthLogout: (provider: string) => void;
- imageProviderRestartPending: boolean;
- onRestart?: () => void;
- isRestarting?: boolean;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const [creatingCustomProvider, setCreatingCustomProvider] = useState(false);
- const [customProviderKeyVisible, setCustomProviderKeyVisible] = useState(false);
- const [customProviderDraft, setCustomProviderDraft] = useState(
- emptyCustomProviderDraft,
- );
- const configuredProviders = settings.providers.filter((provider) => provider.configured);
- const unconfiguredProviders = useMemo(
- () =>
- orderUnconfiguredProviders(
- settings.providers.filter(
- (provider) => !provider.configured && provider.name !== "custom",
- ),
- ),
- [settings.providers],
- );
- const selectedUnconfiguredProvider =
- unconfiguredProviders.find((provider) => provider.name === expandedProvider) ?? null;
- const customProviderSaving = providerSaving === CUSTOM_PROVIDER_CREATION_KEY;
- const toggleProvider = (providerName: string) => {
- setCreatingCustomProvider(false);
- onToggleProvider(providerName);
- };
- const beginCustomProviderCreation = () => {
- if (expandedProvider) onToggleProvider(expandedProvider);
- setCustomProviderDraft(emptyCustomProviderDraft());
- setCustomProviderKeyVisible(false);
- setCreatingCustomProvider(true);
- };
- const cancelCustomProviderCreation = () => {
- setCreatingCustomProvider(false);
- setCustomProviderDraft(emptyCustomProviderDraft());
- setCustomProviderKeyVisible(false);
- };
- const saveCustomProvider = async () => {
- if (customProviderSaving) return;
- if (await onCreateCustomProvider(customProviderDraft)) {
- cancelCustomProviderCreation();
- }
- };
- const renderProviderRow = (provider: SettingsPayload["providers"][number]) => {
- const expanded = expandedProvider === provider.name;
- const form = providerForms[provider.name] ?? providerFormFromRow(provider);
- const saving = providerSaving === provider.name;
- const isOauthProvider = provider.auth_type === "oauth";
- const supportsOauthAdvancedSettings =
- isOauthProvider && OAUTH_PROXY_PROVIDERS.has(provider.name);
- const keyVisible = !!visibleProviderKeys[provider.name];
- const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
- const apiKeyRequired = provider.api_key_required ?? true;
- const apiKey = form.apiKey.trim();
- const apiBase = form.apiBase.trim();
- const advancedFields = provider.advanced_fields ?? [];
- const oauthSettingsDirty = isOauthProvider && (
- form.proxy.trim() !== (provider.proxy ?? "").trim()
- || form.extraBody.trim() !== providerJsonValue(provider.extra_body).trim()
- );
- const oauthSettingsSaving = saving && oauthSettingsDirty;
- const oauthActionBusy = saving && !oauthSettingsSaving;
- const missingRequiredApiKey = !isOauthProvider && apiKeyRequired && !provider.configured && !apiKey;
- const hasOptionalProviderSetting = Boolean(
- apiKey
- || apiBase
- || form.proxy.trim()
- || form.extraHeaders.trim()
- || form.extraBody.trim()
- || form.extraQuery.trim()
- || form.thinkingStyle.trim()
- || form.region.trim()
- || form.profile.trim(),
- );
- const missingOptionalCredential =
- !isOauthProvider
- && !apiKeyRequired
- && !provider.configured
- && !hasOptionalProviderSetting;
- const supportName = provider.name === "bedrock"
- ? "bedrock"
- : provider.name === "azure_openai"
- ? "azure"
- : null;
- const supportFeature = supportName
- ? (nanobotFeatures?.features ?? []).find((feature) => feature.name === supportName)
- : null;
- return (
-
-
toggleProvider(provider.name)}
- className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
- >
-
-
-
-
- {provider.label}
-
- {provider.api_base ? (
-
- {provider.api_base}
-
- ) : null}
-
-
-
-
-
- {expanded ? (
-
- {supportFeature && !supportFeature.installed ? (
-
- ) : null}
- {supportName && capabilityError ? (
-
{capabilityError}
- ) : null}
- {isOauthProvider ? (
- <>
-
-
-
- {tx("settings.oauth.authentication", "OAuth authentication")}
-
-
- {provider.configured
- ? t("settings.oauth.signedInAs", {
- account: provider.oauth_account || provider.label,
- defaultValue: "Signed in as {{account}}",
- })
- : provider.name === "openai_codex" && remoteBrowserAccess
- ? tx(
- "settings.oauth.codexRemoteSignInHelp",
- "Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
- )
- : provider.name === "xai_grok" && remoteBrowserAccess
- ? tx(
- "settings.oauth.remoteSignInHelp",
- "Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
- )
- : tx("settings.oauth.signInHelp", "Sign in from this device; no API key is stored in config.")}
-
-
-
- {provider.configured ? (
- onProviderOAuthLogout(provider.name)}
- disabled={saving}
- className="rounded-full"
- >
- {tx("settings.oauth.signOut", "Sign out")}
-
- ) : null}
- onProviderOAuthLogin(provider.name)}
- disabled={saving || oauthSettingsDirty || !provider.oauth_login_supported}
- title={
- oauthSettingsDirty
- ? tx(
- "settings.providers.saveAdvancedBeforeSignIn",
- "Save advanced changes before signing in.",
- )
- : undefined
- }
- className="rounded-full"
- >
- {oauthActionBusy ? (
-
- ) : null}
- {oauthActionBusy
- ? tx("settings.oauth.signingIn", "Signing in...")
- : provider.configured
- ? tx("settings.oauth.signInAgain", "Sign in again")
- : tx("settings.oauth.signIn", "Sign in")}
-
-
-
-
onChangeProviderForm(provider.name, value)}
- />
- {supportsOauthAdvancedSettings ? (
- onChangeProviderForm(provider.name, value)}
- footer={
- <>
- toggleProvider(provider.name)}
- disabled={saving}
- className="rounded-full"
- >
- {t("settings.actions.cancel")}
-
- onSaveProvider(provider.name)}
- disabled={saving || !oauthSettingsDirty}
- className="rounded-full"
- >
- {oauthSettingsSaving ? (
-
- ) : null}
- {oauthSettingsSaving
- ? t("settings.actions.saving")
- : tx("settings.providers.saveProvider", "Save provider")}
-
- >
- }
- />
- ) : null}
- >
- ) : (
- <>
- {provider.is_custom ? (
-
-
- {tx("settings.providers.customProviderName", "Provider name")}
-
-
- onChangeProviderForm(provider.name, { displayName: event.target.value })
- }
- className="h-9 rounded-full text-[13px]"
- />
-
- ) : null}
-
-
- {t("settings.byok.apiKey")}
-
-
- {editingKey ? (
- <>
-
- onChangeProviderForm(provider.name, { apiKey: event.target.value })
- }
- placeholder={
- provider.configured
- ? t("settings.byok.apiKeyConfiguredPlaceholder")
- : t("settings.byok.apiKeyPlaceholder")
- }
- className="h-9 rounded-full pr-11 text-[13px]"
- />
-
onToggleProviderKey(provider.name)}
- aria-label={
- keyVisible
- ? t("settings.byok.hideApiKey")
- : t("settings.byok.showApiKey")
- }
- className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
- >
- {keyVisible ? (
-
- ) : (
-
- )}
-
- >
- ) : (
- <>
-
- {provider.api_key_hint ?? t("settings.byok.configuredKeyHint")}
-
-
onToggleProviderKeyEditing(provider.name)}
- aria-label={t("settings.actions.edit")}
- className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
- >
-
-
- >
- )}
-
-
-
-
- {t("settings.byok.apiBase")}
-
-
- onChangeProviderForm(provider.name, { apiBase: event.target.value })
- }
- placeholder={provider.default_api_base ?? t("settings.byok.apiBasePlaceholder")}
- className="h-9 rounded-full text-[13px]"
- />
-
- onChangeProviderForm(provider.name, value)}
- />
- onChangeProviderForm(provider.name, value)}
- />
-
- toggleProvider(provider.name)}
- className="rounded-full"
- >
- {t("settings.actions.cancel")}
-
- onSaveProvider(provider.name)}
- disabled={
- saving
- || missingRequiredApiKey
- || missingOptionalCredential
- || (provider.is_custom && !form.displayName.trim())
- }
- className="rounded-full"
- >
- {saving
- ? t("settings.actions.saving")
- : tx("settings.providers.saveProvider", "Save provider")}
-
-
- >
- )}
-
- ) : null}
-
- );
- };
- const customProviderForm = creatingCustomProvider ? (
-
-
-
-
-
- {tx("settings.providers.customProvider", "Custom provider")}
-
-
-
-
-
-
-
- {tx("settings.providers.customProviderName", "Provider name")}
-
-
- setCustomProviderDraft((current) => ({
- ...current,
- name: event.target.value,
- }))
- }
- placeholder={tx(
- "settings.providers.customProviderNamePlaceholder",
- "My model provider",
- )}
- className="h-9 rounded-full text-[13px]"
- />
-
-
-
- {t("settings.byok.apiBase")}
-
-
- setCustomProviderDraft((current) => ({
- ...current,
- apiBase: event.target.value,
- }))
- }
- placeholder="https://api.example.com/v1"
- autoCapitalize="none"
- autoComplete="off"
- autoCorrect="off"
- spellCheck={false}
- className="h-9 rounded-full text-[13px]"
- />
-
-
-
- {t("settings.byok.apiKey")}
-
-
-
- setCustomProviderDraft((current) => ({
- ...current,
- apiKey: event.target.value,
- }))
- }
- placeholder={t("settings.byok.apiKeyPlaceholder")}
- autoCapitalize="none"
- autoComplete="off"
- autoCorrect="off"
- spellCheck={false}
- className="h-9 rounded-full pr-11 text-[13px]"
- />
- setCustomProviderKeyVisible((visible) => !visible)}
- aria-label={
- customProviderKeyVisible
- ? t("settings.byok.hideApiKey")
- : t("settings.byok.showApiKey")
- }
- className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
- >
- {customProviderKeyVisible ? (
-
- ) : (
-
- )}
-
-
-
-
- setCustomProviderDraft((current) => ({ ...current, ...value }))
- }
- />
-
-
- {t("settings.actions.cancel")}
-
-
- {customProviderSaving
- ? t("settings.actions.saving")
- : tx("settings.providers.saveProvider", "Save provider")}
-
-
-
-
- ) : null;
- return (
-
- {imageProviderRestartPending && onRestart ? (
-
-
- {tx("settings.status.imageProviderRestart", "Provider support changed. Restart when ready.")}
-
-
-
- {isRestarting ? (
-
- ) : (
-
- )}
- {isRestarting ? t("app.system.restarting") : t("app.system.restart")}
-
-
-
- ) : null}
-
-
- {tx("settings.providers.title", "Model providers")}
-
-
- {configuredProviders.map(renderProviderRow)}
- {selectedUnconfiguredProvider
- ? renderProviderRow(selectedUnconfiguredProvider)
- : null}
- {customProviderForm}
- {!expandedProvider && !creatingCustomProvider ? (
-
-
-
-
-
-
-
-
- {tx(
- "settings.providers.addOwnProvider",
- "Add your own model provider",
- )}
-
-
-
-
-
-
-
-
-
- {tx("settings.providers.customProvider", "Custom provider")}
-
-
- {unconfiguredProviders.length > 0 ? : null}
- {unconfiguredProviders.map((provider) => (
- {
- setCreatingCustomProvider(false);
- if (expandedProvider !== provider.name) {
- onToggleProvider(provider.name);
- }
- }}
- className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
- >
-
-
- {provider.label}
-
-
- ))}
-
-
- ) : null}
-
-
-
- );
-}
-
-function ImageGenerationSettings({
- token,
- settings,
- form,
- dirty,
- saving,
- onChangeForm,
- onSave,
- onOpenProviders,
- showBrandLogos,
- onRestart,
- isRestarting,
- requiresRestartPending,
-}: {
- token: string;
- settings: SettingsPayload;
- form: ImageGenerationSettingsUpdate;
- dirty: boolean;
- saving: boolean;
- onChangeForm: Dispatch>;
- onSave: () => void;
- onOpenProviders: () => void;
- showBrandLogos: boolean;
- onRestart?: () => void;
- isRestarting?: boolean;
- requiresRestartPending: boolean;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const selectedProvider =
- settings.image_generation.providers.find((provider) => provider.name === form.provider) ??
- settings.image_generation.providers[0];
- const providerConfigured = !!selectedProvider?.configured;
- const missingCredential = form.enabled && !providerConfigured;
- const aspectOptions = optionRowsWithCurrent(
- IMAGE_ASPECT_RATIO_OPTIONS.map((value) => ({ name: value, label: value })),
- form.defaultAspectRatio,
- );
- const sizeOptions = optionRowsWithCurrent(
- IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })),
- form.defaultImageSize,
- );
- const selectProvider = (provider: string) => {
- const nextProvider = settings.image_generation.providers.find((row) => row.name === provider);
- onChangeForm((prev) => ({
- ...prev,
- provider,
- model: nextProvider?.default_model || nextProvider?.models?.[0] || prev.model,
- }));
- };
-
- return (
-
-
- {tx("settings.sections.imageGeneration", "Image generation")}
-
-
- onChangeForm((prev) => ({ ...prev, enabled }))}
- ariaLabel={tx("settings.rows.imageGeneration", "Image generation")}
- label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
- />
-
-
-
-
-
-
-
- {providerConfigured
- ? tx("settings.values.configured", "Configured")
- : tx("settings.values.notConfigured", "Not configured")}
-
- {!providerConfigured ? (
-
- {tx("settings.image.configureProvider", "Configure provider")}
-
- ) : null}
-
-
-
-
- {selectedProvider?.api_base || selectedProvider?.default_api_base || selectedProvider?.name || tx("settings.values.notAvailable", "Not available")}
-
-
-
-
-
-
- {tx("settings.sections.imageDefaults", "Defaults")}
-
-
- onChangeForm((prev) => ({ ...prev, model }))}
- />
-
-
-
- onChangeForm((prev) => ({ ...prev, defaultAspectRatio }))
- }
- />
-
-
-
- onChangeForm((prev) => ({ ...prev, defaultImageSize }))
- }
- />
-
-
-
- onChangeForm((prev) => ({ ...prev, maxImagesPerTurn }))
- }
- />
-
-
-
-
-
-
- );
-}
-
-function TranscriptionSettings({
- settings,
- form,
- dirty,
- saving,
- onChangeForm,
- onSave,
- onOpenProviders,
- showBrandLogos,
- onRestart,
- isRestarting,
- requiresRestartPending,
-}: {
- settings: SettingsPayload;
- form: TranscriptionSettingsUpdate;
- dirty: boolean;
- saving: boolean;
- onChangeForm: Dispatch>;
- onSave: () => void;
- onOpenProviders: () => void;
- showBrandLogos: boolean;
- onRestart?: () => void;
- isRestarting?: boolean;
- requiresRestartPending: boolean;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
- const selectedProvider =
- transcription.providers.find((provider) => provider.name === form.provider) ??
- transcription.providers[0];
- const providerConfigured = !!selectedProvider?.configured;
-
- return (
-
- {tx("settings.sections.voiceInput", "Voice input")}
-
-
- onChangeForm((prev) => ({ ...prev, enabled }))}
- ariaLabel={tx("settings.rows.transcription", "Transcription")}
- label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
- />
-
-
- onChangeForm((prev) => ({ ...prev, provider }))}
- />
-
-
-
-
- {providerConfigured
- ? tx("settings.values.configured", "Configured")
- : tx("settings.values.notConfigured", "Not configured")}
-
- {!providerConfigured ? (
-
- {tx("settings.voice.configureProvider", "Configure provider")}
-
- ) : null}
-
-
-
- onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
- className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
- />
-
-
- onChangeForm((prev) => ({ ...prev, language: event.target.value }))}
- placeholder={tx("settings.voice.languageAuto", "Auto")}
- className="h-8 w-[min(180px,60vw)] rounded-full text-[13px]"
- />
-
-
-
- onChangeForm((prev) => ({ ...prev, maxDurationSec }))}
- />
- onChangeForm((prev) => ({ ...prev, maxUploadMb }))}
- />
-
-
-
-
-
- );
-}
-
-function WebSettings({
- settings,
- form,
- keyVisible,
- keyEditing,
- saving,
- onChangeForm,
- onChangeProvider,
- onToggleKey,
- onToggleKeyEditing,
- onReset,
- onSave,
- showBrandLogos,
- onRestart,
- isRestarting,
- requiresRestartPending,
- olostepFeature,
- olostepInstalling,
- capabilityError,
-}: {
- settings: SettingsPayload;
- form: WebSearchSettingsUpdate;
- keyVisible: boolean;
- keyEditing: boolean;
- saving: boolean;
- onChangeForm: Dispatch>;
- onChangeProvider: (provider: string) => void;
- onToggleKey: () => void;
- onToggleKeyEditing: () => void;
- onReset: () => void;
- onSave: () => void;
- showBrandLogos: boolean;
- onRestart?: () => void;
- isRestarting?: boolean;
- requiresRestartPending: boolean;
- olostepFeature?: NanobotFeatureInfo;
- olostepInstalling: boolean;
- capabilityError: string | null;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const selectedProvider =
- settings.web_search.providers.find((provider) => provider.name === form.provider) ??
- settings.web_search.providers[0];
- const hasExistingSecret =
- webSearchProviderAcceptsApiKey(selectedProvider) &&
- form.provider === settings.web_search.provider &&
- !!settings.web_search.api_key_hint;
- const showKeyInput = webSearchProviderAcceptsApiKey(selectedProvider) && (!hasExistingSecret || keyEditing);
- const apiKey = form.apiKey?.trim() ?? "";
- const baseUrl = form.baseUrl?.trim() ?? "";
- const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader;
- const dirty =
- form.provider !== settings.web_search.provider ||
- apiKey.length > 0 ||
- baseUrl !== (settings.web_search.base_url ?? "") ||
- form.maxResults !== settings.web_search.max_results ||
- form.timeout !== settings.web_search.timeout ||
- effectiveJinaReader !== settings.web.fetch.use_jina_reader;
- const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader;
- const missingCredential =
- webSearchProviderRequiresApiKey(selectedProvider)
- ? !apiKey && !hasExistingSecret
- : selectedProvider?.credential === "base_url"
- ? !baseUrl
- : false;
-
- return (
-
-
- {tx("settings.sections.webSearch", "Web search")}
- {form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
-
-
-
- ) : null}
- {capabilityError ? (
- {capabilityError}
- ) : null}
-
-
-
-
-
- {selectedProvider?.credential === "none" ? (
-
- {t("settings.byok.webSearch.noCredentialRequired")}
-
- ) : null}
-
- {webSearchProviderAcceptsApiKey(selectedProvider) ? (
-
-
- {showKeyInput ? (
- <>
-
- onChangeForm((prev) => ({ ...prev, apiKey: event.target.value }))
- }
- placeholder={
- hasExistingSecret
- ? t("settings.byok.apiKeyConfiguredPlaceholder")
- : t("settings.byok.apiKeyPlaceholder")
- }
- className="h-9 rounded-full pr-11 text-[13px]"
- />
-
- {keyVisible ? (
-
- ) : (
-
- )}
-
- >
- ) : (
- <>
-
- {settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")}
-
-
-
-
- >
- )}
-
-
- ) : null}
-
- {selectedProvider?.credential === "base_url" ? (
-
-
- onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value }))
- }
- placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")}
- className="h-9 w-[280px] rounded-full text-[13px]"
- />
-
- ) : null}
-
-
-
-
- {tx("settings.sections.webBehavior", "Behavior")}
-
-
- onChangeForm((prev) => ({ ...prev, maxResults }))}
- />
-
-
- onChangeForm((prev) => ({ ...prev, timeout }))}
- suffix="s"
- />
-
-
- onChangeForm((prev) => ({ ...prev, useJinaReader }))}
- ariaLabel={tx("settings.rows.jinaReader", "Jina reader")}
- label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
- />
-
-
-
-
-
- );
-}
-
-function AutomationsSettings({
- payload,
- loading,
- query,
- filter,
- sort,
- actionKey,
- error,
- onQueryChange,
- onFilterChange,
- onSortChange,
- onAction,
- onRequestEdit,
- onRequestDelete,
- onBackToChat,
-}: {
- payload: AutomationsPayload | null;
- loading: boolean;
- query: string;
- filter: AutomationFilter;
- sort: AutomationSort;
- actionKey: string | null;
- error: string | null;
- onQueryChange: (value: string) => void;
- onFilterChange: (value: AutomationFilter) => void;
- onSortChange: (value: AutomationSort) => void;
- onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise;
- onRequestEdit: (job: SessionAutomationJob) => void;
- onRequestDelete: (job: SessionAutomationJob) => void;
- onBackToChat: () => void;
-}) {
- const { t, i18n } = useTranslation();
- const tx = (key: string, fallback: string, values?: Record) =>
- t(key, { defaultValue: fallback, ...(values ?? {}) });
- const jobs = payload?.jobs ?? [];
- const locale = i18n.resolvedLanguage || i18n.language;
- const [selectedJobId, setSelectedJobId] = useState(null);
- const filtered = useMemo(() => {
- const searchTokens = parseAutomationSearchQuery(query);
- return sortAutomationJobs(jobs, sort)
- .filter((job) => automationMatchesFilter(job, filter))
- .filter((job) => !searchTokens.length || automationMatchesSearch(job, searchTokens));
- }, [filter, jobs, query, sort]);
- const activeCount = jobs.filter((job) => {
- const key = automationStatusKey(job);
- return key === "active" || key === "running";
- }).length;
- const pausedCount = jobs.filter((job) => automationStatusKey(job) === "paused").length;
- const failedCount = jobs.filter(automationNeedsAttention).length;
- const systemCount = jobs.filter((job) => job.protected).length;
- const summaryOptions: Array<{ value: AutomationFilter; label: string; count: number }> = [
- { value: "all", label: tx("settings.automations.filters.all", "All"), count: jobs.length },
- { value: "active", label: tx("settings.automations.filters.active", "Active"), count: activeCount },
- { value: "paused", label: tx("settings.automations.filters.paused", "Paused"), count: pausedCount },
- { value: "failed", label: tx("settings.automations.filters.failed", "Needs attention"), count: failedCount },
- { value: "system", label: tx("settings.automations.filters.system", "System"), count: systemCount },
- ];
- const sortLabel = {
- next: tx("settings.automations.sort.next", "Next run"),
- last: tx("settings.automations.sort.last", "Last run"),
- updated: tx("settings.automations.sort.updated", "Updated"),
- name: tx("settings.automations.sort.name", "Name"),
- } satisfies Record;
- const selectedJob = filtered.find((job) => job.id === selectedJobId) ?? filtered[0] ?? null;
-
- useEffect(() => {
- if (!filtered.length) {
- if (selectedJobId !== null) setSelectedJobId(null);
- return;
- }
- if (!selectedJobId || !filtered.some((job) => job.id === selectedJobId)) {
- setSelectedJobId(filtered[0].id);
- }
- }, [filtered, selectedJobId]);
-
- return (
-
- {jobs.length ? (
-
-
-
-
- {summaryOptions.map((option) => (
- onFilterChange(option.value)}
- className={cn(
- "inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
- filter === option.value && "bg-background text-foreground",
- automationFilterToneClass(option.value, option.count, filter === option.value),
- )}
- >
- {option.label}
-
- {option.count}
-
-
- ))}
-
-
-
-
-
-
- onQueryChange(event.target.value)}
- placeholder={tx(
- "settings.automations.search",
- "Search task, message, linked chat, or schedule",
- )}
- className={cn(
- "h-9 w-full rounded-[13px] pl-9 text-[13px]",
- SETTINGS_SEARCH_INPUT_CLASS,
- )}
- />
-
-
-
-
-
- {sortLabel[sort]}
-
-
-
-
- {(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
- onSortChange(value)}>
- {sortLabel[value]}
- {sort === value ? : null}
-
- ))}
-
-
-
-
-
- ) : null}
-
- {error ? (
-
-
- {error}
-
- ) : null}
-
- {loading && !payload ? (
-
-
- {tx("settings.automations.loading", "Loading automations...")}
-
- ) : filtered.length && selectedJob ? (
-
-
-
-
- {tx("settings.automations.queue", "Queue")}
-
-
- {filtered.length}
-
-
-
- {filtered.map((job) => (
-
setSelectedJobId(job.id)}
- />
- ))}
-
-
-
-
- ) : (
-
-
- {jobs.length
- ? tx("settings.automations.noMatches", "No automations match this view.")
- : tx("settings.automations.empty", "No automations yet.")}
-
- {!jobs.length ? (
- <>
-
- {tx(
- "settings.automations.emptyHint",
- "Create automations in a chat so they keep the right context.",
- )}
-
-
- {tx("settings.automations.emptyAction", "Open a chat")}
-
- >
- ) : (
-
{
- onQueryChange("");
- onFilterChange("all");
- }}
- >
- {tx("settings.automations.clearFilters", "Clear filters")}
-
- )}
-
- )}
-
- );
-}
-
-function AutomationListItem({
- job,
- locale,
- selected,
- onSelect,
-}: {
- job: SessionAutomationJob;
- locale: string;
- selected: boolean;
- onSelect: () => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string, values?: Record) =>
- t(key, { defaultValue: fallback, ...(values ?? {}) });
- const status = automationStatus(job, tx);
- const origin = automationOriginLabel(job, tx);
- const nextRun = formatAutomationNext(job, tx);
- const summary = automationSummary(job, tx);
-
- return (
-
-
-
-
-
-
- {job.name || job.id}
-
-
-
- {summary}
-
-
-
- {nextRun}
-
-
- {origin}
-
-
-
-
- {status.label}
-
- {job.delete_after_run ? (
-
- {tx("settings.automations.oneShot", "One-time")}
-
- ) : null}
-
-
-
-
- );
-}
-
-function AutomationDetailPanel({
- job,
- locale,
- actionKey,
- onAction,
- onRequestEdit,
- onRequestDelete,
-}: {
- job: SessionAutomationJob;
- locale: string;
- actionKey: string | null;
- onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise;
- onRequestEdit: (job: SessionAutomationJob) => void;
- onRequestDelete: (job: SessionAutomationJob) => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string, values?: Record) =>
- t(key, { defaultValue: fallback, ...(values ?? {}) });
- const status = automationStatus(job, tx);
- const origin = automationOriginLabel(job, tx);
- const originHref = job.origin?.channel === "websocket" && job.origin.session_key
- ? `#/chat/${encodeURIComponent(job.origin.session_key)}`
- : null;
- const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null;
- const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null;
- const localTrigger = isLocalTriggerAutomation(job);
- const triggerCommand = automationTriggerCommand(job);
- const message = automationDetailText(job, tx);
- const messageLabel = localTrigger
- ? tx("settings.automations.fields.command", "Command")
- : tx("settings.automations.fields.message", "Message");
- const schedule = formatAutomationSchedule(job, locale, tx);
- const [messageExpanded, setMessageExpanded] = useState(false);
- const [commandCopied, setCommandCopied] = useState(false);
- const messageNeedsExpansion = automationMessageNeedsExpansion(message);
-
- useEffect(() => {
- setMessageExpanded(false);
- setCommandCopied(false);
- }, [job.id]);
-
- return (
-
-
-
-
-
-
- {job.name || job.id}
-
-
{status.label}
- {job.delete_after_run ? (
-
{tx("settings.automations.oneShot", "One-time")}
- ) : null}
-
-
- {schedule} · {origin}
-
-
-
-
-
-
-
-
-
-
-
- {messageLabel}
-
- {localTrigger && triggerCommand ? (
-
{
- void copyTextToClipboard(triggerCommand).then((ok) => {
- if (ok) setCommandCopied(true);
- });
- }}
- >
- {commandCopied ? (
-
- ) : (
-
- )}
- {commandCopied
- ? tx("settings.automations.commandCopied", "Copied")
- : tx("settings.automations.copyCommand", "Copy")}
-
- ) : null}
-
-
- {message}
-
- {messageNeedsExpansion ? (
- setMessageExpanded((value) => !value)}
- >
- {messageExpanded
- ? tx("settings.automations.message.showLess", "Show less")
- : tx("settings.automations.message.showMore", "Show full message")}
-
- ) : null}
-
-
-
-
- {formatAutomationNext(job, tx)}
-
-
- {originHref ? (
-
- {origin}
-
-
- ) : (
- origin
- )}
-
-
-
- {job.state.last_error ? (
-
- {job.state.last_error}
-
- ) : null}
-
-
-
-
-
- {schedule}
-
-
-
- {created ? (
-
-
- {tx("settings.automations.labels.created", "Created")}
-
-
{created}
-
- ) : null}
- {updated ? (
-
-
- {tx("settings.automations.labels.updated", "Updated")}
-
-
{updated}
-
- ) : null}
-
-
-
-
-
-
-
- );
-}
-
-function AutomationActionGroup({
- job,
- actionKey,
- onAction,
- onRequestEdit,
- onRequestDelete,
-}: {
- job: SessionAutomationJob;
- actionKey: string | null;
- onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise;
- onRequestEdit: (job: SessionAutomationJob) => void;
- onRequestDelete: (job: SessionAutomationJob) => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string, values?: Record) =>
- t(key, { defaultValue: fallback, ...(values ?? {}) });
- const canManage = !job.protected;
- const hasLinkedChat = Boolean(job.origin);
- const localTrigger = isLocalTriggerAutomation(job);
- const canRun = canManage && hasLinkedChat && job.enabled && !job.state.pending && !localTrigger;
- const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
- const canToggle = canManage && (job.enabled || hasLinkedChat);
- const toggleBusy = actionKey === `${toggleAction}:${job.id}`;
-
- if (!canManage) {
- return (
-
- {tx("settings.automations.protected", "Protected")}
-
- );
- }
-
- return (
-
-
onRequestEdit(job)}
- >
-
-
- {!localTrigger ? (
-
void onAction("run", job)}
- >
-
-
- ) : null}
-
void onAction(toggleAction, job)}
- >
- {job.enabled ? (
-
- ) : (
-
- )}
-
-
onRequestDelete(job)}
- >
-
-
-
- );
-}
-
-function AutomationStatusBadge({
- tone = "neutral",
- children,
-}: {
- tone?: "neutral" | "success" | "warning";
- children: ReactNode;
-}) {
- return (
-
- {children}
-
- );
-}
-
-function automationMessageNeedsExpansion(message: string): boolean {
- return message.length > 360 || message.split(/\r?\n/).length > 6;
-}
-
-function AutomationDetail({
- label,
- title,
- secondary,
- children,
-}: {
- label: string;
- title?: string;
- secondary?: ReactNode;
- children: ReactNode;
-}) {
- return (
-
-
- {label}
-
-
-
- {children}
-
- {secondary ? (
-
- {secondary}
-
- ) : null}
-
-
- );
-}
-
-type AutomationEveryUnit = "second" | "minute" | "hour" | "day";
-
-type AutomationEditDraft = {
- name: string;
- message: string;
- scheduleKind: "at" | "every" | "cron";
- everyValue: string;
- everyUnit: AutomationEveryUnit;
- cronExpr: string;
- tz: string;
- atLocal: string;
-};
-type AutomationScheduleUpdate = NonNullable;
-
-const AUTOMATION_EVERY_UNITS: Array<{ value: AutomationEveryUnit; ms: number }> = [
- { value: "second", ms: 1000 },
- { value: "minute", ms: 60_000 },
- { value: "hour", ms: 3_600_000 },
- { value: "day", ms: 86_400_000 },
-];
-
-function AutomationEditDialog({
- job,
- saving,
- onOpenChange,
- onSave,
-}: {
- job: SessionAutomationJob | null;
- saving: boolean;
- onOpenChange: (open: boolean) => void;
- onSave: (job: SessionAutomationJob, values: AutomationUpdatePayload) => void | Promise;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string, values?: Record) =>
- t(key, { defaultValue: fallback, ...(values ?? {}) });
- const [draft, setDraft] = useState(() => automationDraftFromJob(null));
- const localTrigger = isLocalTriggerAutomation(job);
-
- useEffect(() => {
- setDraft(automationDraftFromJob(job));
- }, [job]);
-
- const validation = automationEditDraftError(draft, job, tx);
- const scheduleOptions = [
- { value: "every", label: tx("settings.automations.scheduleTypes.every", "Interval") },
- { value: "cron", label: tx("settings.automations.scheduleTypes.cron", "Cron") },
- { value: "at", label: tx("settings.automations.scheduleTypes.at", "Once") },
- ];
- const unitLabels: Record = {
- second: tx("settings.automations.everyUnits.second", "Seconds"),
- minute: tx("settings.automations.everyUnits.minute", "Minutes"),
- hour: tx("settings.automations.everyUnits.hour", "Hours"),
- day: tx("settings.automations.everyUnits.day", "Days"),
- };
-
- const submit = (event: FormEvent) => {
- event.preventDefault();
- const payload = automationUpdatePayloadFromDraft(draft, job);
- if (!job || typeof payload === "string") return;
- void onSave(job, payload);
- };
-
- return (
-
- {job ? (
-
-
-
- {tx("settings.automations.editTitle", "Edit automation")}
-
-
-
-
-
- {tx("settings.automations.fields.name", "Name")}
-
- setDraft((prev) => ({ ...prev, name: event.target.value }))}
- className="h-10 rounded-[12px]"
- />
-
-
- {!localTrigger ? (
-
-
- {tx("settings.automations.fields.message", "Message")}
-
- setDraft((prev) => ({ ...prev, message: event.target.value }))}
- className="min-h-[160px] resize-none rounded-[12px] text-[13px] leading-5"
- />
-
- ) : null}
-
- {!localTrigger ? (
-
-
- {tx("settings.automations.fields.scheduleType", "Schedule type")}
-
-
- setDraft((prev) => ({
- ...prev,
- scheduleKind: value as AutomationEditDraft["scheduleKind"],
- }))
- }
- />
-
- ) : null}
-
- {!localTrigger && draft.scheduleKind === "every" ? (
-
-
-
- {tx("settings.automations.fields.every", "Every")}
-
-
- setDraft((prev) => ({ ...prev, everyValue: event.target.value }))
- }
- className="h-10 rounded-[12px]"
- />
-
-
-
- {tx("settings.automations.fields.unit", "Unit")}
-
-
- setDraft((prev) => ({
- ...prev,
- everyUnit: event.target.value as AutomationEveryUnit,
- }))
- }
- className="h-10 w-full rounded-[12px] border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
- >
- {AUTOMATION_EVERY_UNITS.map((unit) => (
-
- {unitLabels[unit.value]}
-
- ))}
-
-
-
- ) : null}
-
- {!localTrigger && draft.scheduleKind === "cron" ? (
-
-
-
- {tx("settings.automations.fields.cronExpression", "Cron expression")}
-
- setDraft((prev) => ({ ...prev, cronExpr: event.target.value }))}
- placeholder="0 9 * * *"
- className="h-10 rounded-[12px] font-mono text-[13px]"
- />
-
-
-
- {tx("settings.automations.fields.timezone", "Timezone")}
-
- setDraft((prev) => ({ ...prev, tz: event.target.value }))}
- placeholder="Asia/Shanghai"
- className="h-10 rounded-[12px] text-[13px]"
- />
-
-
- ) : null}
-
- {!localTrigger && draft.scheduleKind === "at" ? (
-
-
- {tx("settings.automations.fields.runAt", "Run at")}
-
- setDraft((prev) => ({ ...prev, atLocal: event.target.value }))}
- className="h-10 rounded-[12px]"
- />
-
- ) : null}
-
- {validation ? (
-
- {validation}
-
- ) : null}
-
-
-
- onOpenChange(false)}
- disabled={saving}
- className="rounded-full"
- >
- {tx("settings.automations.cancel", "Cancel")}
-
-
- {saving ? : null}
- {tx("settings.automations.save", "Save")}
-
-
-
-
- ) : null}
-
- );
-}
-
-function AutomationDeleteDialog({
- job,
- deleting,
- onOpenChange,
- onConfirm,
-}: {
- job: SessionAutomationJob | null;
- deleting: boolean;
- onOpenChange: (open: boolean) => void;
- onConfirm: (job: SessionAutomationJob) => void | Promise;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string, values?: Record) =>
- t(key, { defaultValue: fallback, ...(values ?? {}) });
- return (
-
-
-
- {tx("settings.automations.deleteTitle", "Delete automation")}
-
- {tx(
- "settings.automations.deleteDescription",
- "This removes {{name}} from automations. Past chat messages stay in the session.",
- { name: job?.name || job?.id || "" },
- )}
-
-
-
- onOpenChange(false)}
- disabled={deleting}
- className="rounded-full"
- >
- {tx("settings.automations.cancel", "Cancel")}
-
- job && void onConfirm(job)}
- disabled={!job || deleting}
- className="rounded-full"
- >
- {deleting ? : null}
- {tx("settings.automations.delete", "Delete")}
-
-
-
-
- );
-}
-
-function NanobotFeatureInstallDialog({
- feature,
- installing,
- onOpenChange,
- onConfirm,
-}: {
- feature: NanobotFeatureInfo | null;
- installing: boolean;
- onOpenChange: (open: boolean) => void;
- onConfirm: (feature: NanobotFeatureInfo) => void | Promise;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string, values?: Record) =>
- t(key, { defaultValue: fallback, ...(values ?? {}) });
- const name = feature?.display_name || feature?.name || "";
- return (
-
-
-
-
- {tx("settings.nanobotFeatures.installConfirmTitle", "Install support for {{name}}?", { name })}
-
-
- {tx(
- "settings.nanobotFeatures.installConfirmDescription",
- "nanobot will add what {{name}} needs, then turn it on. Continue?",
- { name },
- )}
-
-
-
- onOpenChange(false)}
- disabled={installing}
- className="h-11 w-full min-w-0 rounded-full bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
- >
- {tx("settings.automations.cancel", "Cancel")}
-
- feature && void onConfirm(feature)}
- disabled={!feature || installing}
- className="h-11 w-full min-w-0 !whitespace-normal rounded-full px-5 text-center text-[15px] font-semibold"
- >
- {installing ? : null}
- {tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")}
-
-
-
-
- );
-}
-
-function isLocalTriggerAutomation(job: SessionAutomationJob | null): boolean {
- if (!job) return false;
- return job.kind === "local_trigger"
- || job.payload.kind === "local_trigger"
- || job.schedule.kind === "local";
-}
-
-function automationTriggerCommand(job: SessionAutomationJob): string {
- return job.trigger?.command || job.payload.command || job.payload.message || "";
-}
-
-function automationSummary(
- job: SessionAutomationJob,
- tx: (key: string, fallback: string, values?: Record) => string,
-): string {
- if (isLocalTriggerAutomation(job)) {
- return automationTriggerCommand(job) || tx("settings.automations.localTrigger", "Local trigger");
- }
- return job.payload.message || tx("settings.automations.systemTask", "System-managed automation");
-}
-
-function automationDetailText(
- job: SessionAutomationJob,
- tx: (key: string, fallback: string, values?: Record) => string,
-): string {
- return automationSummary(job, tx);
-}
-
-function automationNeedsAttention(job: SessionAutomationJob): boolean {
- return job.state.last_status === "error";
-}
-
-function automationStatusKey(
- job: SessionAutomationJob,
-): "active" | "running" | "paused" | "failed" | "system" | "completed" | "idle" {
- if (job.protected) return "system";
- if (job.state.pending) return "running";
- if (!job.enabled) return "paused";
- if (job.state.last_status === "error") return "failed";
- if (isLocalTriggerAutomation(job)) return "active";
- if (job.delete_after_run && !job.state.next_run_at_ms && job.state.last_status === "ok") {
- return "completed";
- }
- if (!job.state.next_run_at_ms) return "idle";
- return "active";
-}
-
-function sortAutomationJobs(jobs: SessionAutomationJob[], sort: AutomationSort): SessionAutomationJob[] {
- const byName = (left: SessionAutomationJob, right: SessionAutomationJob) =>
- (left.name || left.id).localeCompare(right.name || right.id);
- return [...jobs].sort((left, right) => {
- if (sort === "name") return byName(left, right);
- if (sort === "last") {
- return (right.state.last_run_at_ms ?? 0) - (left.state.last_run_at_ms ?? 0) || byName(left, right);
- }
- if (sort === "updated") {
- return (right.updated_at_ms ?? 0) - (left.updated_at_ms ?? 0) || byName(left, right);
- }
- const leftNext = left.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
- const rightNext = right.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
- return leftNext - rightNext || byName(left, right);
- });
-}
-
-function automationDraftFromJob(job: SessionAutomationJob | null): AutomationEditDraft {
- const every = automationIntervalDraft(job?.schedule.every_ms ?? 3_600_000);
- const scheduleKind = job?.schedule.kind === "at" || job?.schedule.kind === "cron"
- ? job.schedule.kind
- : "every";
- return {
- name: job?.name ?? "",
- message: job?.payload.message ?? "",
- scheduleKind,
- everyValue: every.value,
- everyUnit: every.unit,
- cronExpr: job?.schedule.expr ?? "0 9 * * *",
- tz: job?.schedule.tz ?? "",
- atLocal: formatLocalDateTimeInput(job?.schedule.at_ms ?? Date.now() + 3_600_000),
- };
-}
-
-function automationIntervalDraft(ms: number): { value: string; unit: AutomationEveryUnit } {
- for (const unit of [...AUTOMATION_EVERY_UNITS].reverse()) {
- if (ms >= unit.ms && ms % unit.ms === 0) {
- return { value: String(ms / unit.ms), unit: unit.value };
- }
- }
- return { value: String(Math.max(1, Math.round(ms / 60_000))), unit: "minute" };
-}
-
-function formatLocalDateTimeInput(ms: number): string {
- const date = new Date(ms);
- if (!Number.isFinite(date.getTime())) return "";
- const local = new Date(ms - date.getTimezoneOffset() * 60_000);
- return local.toISOString().slice(0, 16);
-}
-
-function automationEditDraftError(
- draft: AutomationEditDraft,
- job: SessionAutomationJob | null,
- tx: (key: string, fallback: string, values?: Record) => string,
-): string | null {
- if (!draft.name.trim()) return tx("settings.automations.validation.nameRequired", "Name is required.");
- if (isLocalTriggerAutomation(job)) return null;
- if (!draft.message.trim()) {
- return tx("settings.automations.validation.messageRequired", "Message is required.");
- }
- if (draft.scheduleKind === "every") {
- const value = Number(draft.everyValue);
- if (!Number.isInteger(value) || value <= 0) {
- return tx("settings.automations.validation.intervalRequired", "Interval must be a positive number.");
- }
- }
- if (draft.scheduleKind === "cron" && !draft.cronExpr.trim()) {
- return tx("settings.automations.validation.cronRequired", "Cron expression is required.");
- }
- if (draft.scheduleKind === "at") {
- const atMs = new Date(draft.atLocal).getTime();
- if (!Number.isFinite(atMs)) {
- return tx("settings.automations.validation.timeRequired", "Run time is required.");
- }
- if (atMs <= Date.now() && automationScheduleChanged(draft, job)) {
- return tx("settings.automations.validation.futureRequired", "Run time must be in the future.");
- }
- }
- return null;
-}
-
-function automationUpdatePayloadFromDraft(
- draft: AutomationEditDraft,
- job: SessionAutomationJob | null,
-): AutomationUpdatePayload | string {
- const name = draft.name.trim();
- if (isLocalTriggerAutomation(job)) {
- if (!name) return "invalid";
- return { name };
- }
- const message = draft.message.trim();
- if (!name || !message) return "invalid";
- const payload: AutomationUpdatePayload = { name, message };
- const schedule = automationSchedulePayloadFromDraft(draft);
- if (typeof schedule === "string") return schedule;
- if (automationScheduleChanged(draft, job, schedule)) {
- payload.schedule = schedule;
- }
- return payload;
-}
-
-function automationSchedulePayloadFromDraft(draft: AutomationEditDraft): AutomationScheduleUpdate | string {
- if (draft.scheduleKind === "every") {
- const unit = AUTOMATION_EVERY_UNITS.find((candidate) => candidate.value === draft.everyUnit);
- const value = Number(draft.everyValue);
- if (!unit || !Number.isInteger(value) || value <= 0) return "invalid";
- return { kind: "every", every_ms: value * unit.ms };
- } else if (draft.scheduleKind === "cron") {
- const expr = draft.cronExpr.trim();
- if (!expr) return "invalid";
- return { kind: "cron", expr, ...(draft.tz.trim() ? { tz: draft.tz.trim() } : {}) };
- } else {
- const atMs = new Date(draft.atLocal).getTime();
- if (!Number.isFinite(atMs)) return "invalid";
- return { kind: "at", at_ms: atMs };
- }
-}
-
-function automationScheduleChanged(
- draft: AutomationEditDraft,
- job: SessionAutomationJob | null,
- schedule: AutomationScheduleUpdate | string = automationSchedulePayloadFromDraft(draft),
-): boolean {
- if (!job || typeof schedule === "string") return true;
- if (schedule.kind !== job.schedule.kind) return true;
- if (schedule.kind === "every") return schedule.every_ms !== job.schedule.every_ms;
- if (schedule.kind === "cron") {
- return schedule.expr !== (job.schedule.expr ?? "") || (schedule.tz ?? null) !== (job.schedule.tz ?? null);
- }
- return draft.atLocal !== formatLocalDateTimeInput(job.schedule.at_ms ?? NaN);
-}
-
-type AutomationSearchField = "id" | "name" | "message" | "chat" | "cron" | "schedule" | "status";
-
-interface AutomationSearchToken {
- field: AutomationSearchField | null;
- value: string;
-}
-
-const AUTOMATION_SEARCH_FIELDS = new Set([
- "id",
- "name",
- "message",
- "chat",
- "cron",
- "schedule",
- "status",
-]);
-
-const HOST_AUTOMATION_CHANNEL_LABELS: Record = {
- api: "API",
- cli: "CLI",
-};
-
-function parseAutomationSearchQuery(query: string): AutomationSearchToken[] {
- return (query.match(/[^\s:]+:"[^"]+"|"[^"]+"|\S+/g) ?? [])
- .map((rawPart): AutomationSearchToken | null => {
- const part = trimAutomationSearchValue(rawPart);
- if (!part) return null;
- const fieldMatch = part.match(/^([A-Za-z]+):(.*)$/);
- if (!fieldMatch) return { field: null, value: part.toLowerCase() };
- const field = fieldMatch[1].toLowerCase() as AutomationSearchField;
- const value = trimAutomationSearchValue(fieldMatch[2]).toLowerCase();
- if (!value) return null;
- return AUTOMATION_SEARCH_FIELDS.has(field)
- ? { field, value }
- : { field: null, value: part.toLowerCase() };
- })
- .filter((token): token is AutomationSearchToken => Boolean(token));
-}
-
-function trimAutomationSearchValue(value: string): string {
- return value.trim().replace(/^"|"$/g, "").trim();
-}
-
-function automationMatchesSearch(job: SessionAutomationJob, tokens: AutomationSearchToken[]): boolean {
- return tokens.every((token) => automationSearchText(job, token.field).includes(token.value));
-}
-
-function automationSearchText(job: SessionAutomationJob, field: AutomationSearchField | null = null): string {
- return automationSearchParts(job, field)
- .filter(Boolean)
- .join(" ")
- .toLowerCase();
-}
-
-function automationSearchParts(
- job: SessionAutomationJob,
- field: AutomationSearchField | null,
-): Array {
- const originParts = automationOriginSearchParts(job);
- const scheduleParts = automationScheduleSearchParts(job);
- if (field === "id") return [job.id];
- if (field === "name") return [job.name, job.id];
- if (field === "message") return [job.payload.message, job.payload.command, job.trigger?.command];
- if (field === "chat") return originParts;
- if (field === "cron" || field === "schedule") return scheduleParts;
- if (field === "status") return [automationStatusKey(job), job.enabled ? "enabled" : "disabled"];
- return [
- job.id,
- job.name,
- job.payload.message,
- job.payload.command,
- job.trigger?.command,
- isLocalTriggerAutomation(job) ? "trigger local" : null,
- ...scheduleParts,
- automationStatusKey(job),
- ...originParts,
- ];
-}
-
-function automationOriginSearchParts(job: SessionAutomationJob): Array {
- const origin = job.origin;
- if (!origin) return [];
- const channel = origin.channel.trim().toLowerCase();
- return [
- origin.session_key,
- origin.title,
- origin.preview,
- origin.channel,
- automationChannelDisplayName(channel),
- ];
-}
-
-function automationScheduleSearchParts(job: SessionAutomationJob): Array {
- const schedule = job.schedule;
- const parts: Array = [
- schedule.kind,
- schedule.expr,
- schedule.tz,
- schedule.every_ms,
- schedule.at_ms,
- ];
- if (schedule.kind === "cron" && schedule.expr) {
- parts.push(...automationCronSearchParts(schedule.expr));
- }
- return parts;
-}
-
-function automationCronSearchParts(expr: string): string[] {
- const parts = expr.trim().split(/\s+/);
- if (parts.length !== 5) return [];
- const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
- const everyDay = dayOfMonth === "*" && month === "*" && dayOfWeek === "*";
- const numericMinute = cronNumericToken(minute, 59);
- const numericHour = cronNumericToken(hour, 23);
- if (numericMinute === null) return [];
- const paddedMinute = String(numericMinute).padStart(2, "0");
-
- if (numericHour !== null) {
- const time = `${String(numericHour).padStart(2, "0")}:${paddedMinute}`;
- return [time, `:${paddedMinute}`];
- }
-
- if (everyDay && hour === "*") {
- return [`:${paddedMinute}`, `hourly at :${paddedMinute}`];
- }
-
- const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
- if (!everyDay || !range) return [];
- const start = Number(range[1]);
- const end = Number(range[2]);
- if (start > 23 || end > 23) return [];
- const paddedRange = `${String(start).padStart(2, "0")}-${String(end).padStart(2, "0")}`;
- const rawRange = `${start}-${end}`;
- return [
- paddedRange,
- rawRange,
- `:${paddedMinute}`,
- `${paddedRange} at :${paddedMinute}`,
- `hourly ${paddedRange} at :${paddedMinute}`,
- ];
-}
-
-function automationMatchesFilter(job: SessionAutomationJob, filter: AutomationFilter): boolean {
- const status = automationStatusKey(job);
- if (filter === "active") return status === "active" || status === "running";
- if (filter === "paused") return status === "paused";
- if (filter === "failed") return automationNeedsAttention(job);
- if (filter === "system") return Boolean(job.protected);
- return true;
-}
-
-const AUTOMATION_FILTER_TONES: Partial<
- Record
-> = {
- active: {
- text: "text-emerald-600 dark:text-emerald-400",
- selectedText: "text-emerald-700 dark:text-emerald-300",
- count: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
- },
- paused: {
- text: "text-amber-600 dark:text-amber-400",
- selectedText: "text-amber-700 dark:text-amber-300",
- count: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
- },
- failed: {
- text: "text-rose-600 dark:text-rose-400",
- selectedText: "text-rose-700 dark:text-rose-300",
- count: "bg-rose-500/10 text-rose-700 dark:text-rose-300",
- },
- system: {
- text: "text-sky-600 dark:text-sky-400",
- selectedText: "text-sky-700 dark:text-sky-300",
- count: "bg-sky-500/10 text-sky-700 dark:text-sky-300",
- },
-};
-
-function automationFilterToneClass(value: AutomationFilter, count: number, selected: boolean): string {
- const tone = AUTOMATION_FILTER_TONES[value];
- if (count <= 0 || !tone) return "";
- return selected ? tone.selectedText : tone.text;
-}
-
-function automationFilterCountClass(value: AutomationFilter, count: number): string {
- const tone = AUTOMATION_FILTER_TONES[value];
- return count > 0 && tone ? tone.count : "";
-}
-
-function automationStatus(
- job: SessionAutomationJob,
- tx: (key: string, fallback: string, values?: Record) => string,
-): { label: string; tone: "neutral" | "success" | "warning" } {
- const status = automationStatusKey(job);
- if (status === "system") return { label: tx("settings.automations.status.system", "System"), tone: "neutral" };
- if (status === "running") {
- return { label: tx("settings.automations.status.running", "Running now"), tone: "warning" };
- }
- if (status === "paused") return { label: tx("settings.automations.status.paused", "Paused"), tone: "neutral" };
- if (status === "failed") {
- return { label: tx("settings.automations.status.failed", "Failed"), tone: "warning" };
- }
- if (status === "completed") {
- return { label: tx("settings.automations.status.completed", "Completed"), tone: "neutral" };
- }
- if (status === "idle") {
- return { label: tx("settings.automations.status.noSchedule", "No schedule"), tone: "neutral" };
- }
- return { label: tx("settings.automations.status.active", "Active"), tone: "success" };
-}
-
-function automationOriginLabel(
- job: SessionAutomationJob,
- tx: (key: string, fallback: string, values?: Record) => string,
-): string {
- if (job.protected) return tx("settings.automations.origin.system", "System");
- const origin = job.origin;
- if (!origin) return tx("settings.automations.origin.unknown", "No linked chat");
- if (origin.channel !== "websocket") return automationChannelLabel(origin.channel, tx);
- return origin.title || origin.preview || origin.session_key || automationChannelLabel(origin.channel, tx);
-}
-
-function automationChannelLabel(
- channel: string,
- tx: (key: string, fallback: string, values?: Record) => string,
-): string {
- const key = channel.trim().toLowerCase();
- const displayName = automationChannelDisplayName(key);
- return displayName
- ? tx(`settings.automations.channels.${key}`, displayName)
- : channel;
-}
-
-function automationChannelDisplayName(channel: string): string | undefined {
- const key = channel.trim().toLowerCase();
- return channelUiPresentation(key)?.displayName ?? HOST_AUTOMATION_CHANNEL_LABELS[key];
-}
-
-function formatAutomationSchedule(
- job: SessionAutomationJob,
- locale: string,
- tx: (key: string, fallback: string, values?: Record) => string,
-): string {
- if (job.schedule.kind === "at" && job.schedule.at_ms) {
- return tx("settings.automations.schedule.at", "At {{time}}", {
- time: fmtDateTime(job.schedule.at_ms, locale),
- });
- }
- if (job.schedule.kind === "every" && job.schedule.every_ms) {
- return tx("settings.automations.schedule.every", "Every {{duration}}", {
- duration: formatAutomationInterval(job.schedule.every_ms, locale),
- });
- }
- if (job.schedule.kind === "cron" && job.schedule.expr) {
- const summary = formatCronScheduleSummary(job.schedule.expr, tx);
- if (summary) {
- return job.schedule.tz
- ? tx("settings.automations.schedule.withTz", "{{summary}} · {{tz}}", {
- summary,
- tz: job.schedule.tz,
- })
- : summary;
- }
- return job.schedule.tz
- ? tx("settings.automations.schedule.cronWithTz", "Cron {{expr}} · {{tz}}", {
- expr: job.schedule.expr,
- tz: job.schedule.tz,
- })
- : tx("settings.automations.schedule.cron", "Cron {{expr}}", { expr: job.schedule.expr });
- }
- if (isLocalTriggerAutomation(job)) {
- return tx("settings.automations.schedule.local", "Local trigger");
- }
- return tx("settings.automations.schedule.custom", "Custom schedule");
-}
-
-function formatCronScheduleSummary(
- expr: string,
- tx: (key: string, fallback: string, values?: Record) => string,
-): string | null {
- const parts = expr.trim().split(/\s+/);
- if (parts.length !== 5) return null;
- const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
- const numericMinute = cronNumericToken(minute, 59);
- const numericHour = cronNumericToken(hour, 23);
- const everyDay = dayOfMonth === "*" && month === "*" && dayOfWeek === "*";
- const workdays = dayOfMonth === "*" && month === "*" && ["1-5", "MON-FRI", "mon-fri"].includes(dayOfWeek);
-
- if (numericMinute !== null && numericHour !== null) {
- const time = `${String(numericHour).padStart(2, "0")}:${String(numericMinute).padStart(2, "0")}`;
- if (everyDay) return tx("settings.automations.schedule.dailyAt", "Daily at {{time}}", { time });
- if (workdays) return tx("settings.automations.schedule.weekdaysAt", "Weekdays at {{time}}", { time });
- }
-
- if (everyDay && numericMinute !== null && hour === "*") {
- return tx("settings.automations.schedule.hourlyAt", "Hourly at :{{minute}}", {
- minute: String(numericMinute).padStart(2, "0"),
- });
- }
-
- const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
- if (everyDay && numericMinute !== null && range) {
- const start = Number(range[1]);
- const end = Number(range[2]);
- if (start > 23 || end > 23) return null;
- return tx("settings.automations.schedule.hourlyWindow", "Hourly {{start}}-{{end}} at :{{minute}}", {
- start: String(start).padStart(2, "0"),
- end: String(end).padStart(2, "0"),
- minute: String(numericMinute).padStart(2, "0"),
- });
- }
-
- return null;
-}
-
-function cronNumericToken(value: string, max: number): number | null {
- if (!/^\d{1,2}$/.test(value)) return null;
- const parsed = Number(value);
- return parsed <= max ? parsed : null;
-}
-
-function formatAutomationNext(
- job: SessionAutomationJob,
- tx: (key: string, fallback: string, values?: Record) => string,
-): string {
- if (!job.enabled) return tx("settings.automations.next.paused", "Paused");
- if (job.state.pending) return tx("settings.automations.next.pending", "Running now");
- if (isLocalTriggerAutomation(job)) {
- return tx("settings.automations.next.local", "Waiting for trigger");
- }
- if (!job.state.next_run_at_ms) return tx("settings.automations.next.none", "No next run");
- return relativeTime(job.state.next_run_at_ms);
-}
-
-function formatAutomationNextTitle(
- job: SessionAutomationJob,
- locale: string,
- tx: (key: string, fallback: string, values?: Record) => string,
-): string {
- if (!job.state.next_run_at_ms) return formatAutomationNext(job, tx);
- return fmtDateTime(job.state.next_run_at_ms, locale);
-}
-
-function automationStatusDotClass(job: SessionAutomationJob): string {
- const status = automationStatusKey(job);
- if (status === "active" || status === "running") return "bg-orange-500";
- if (status === "failed") return "bg-amber-500";
- return "bg-muted-foreground/45";
-}
-
-function formatAutomationUnit(
- value: number,
- unit: Intl.NumberFormatOptions["unit"],
- locale: string,
- maximumFractionDigits = 0,
-): string {
- return new Intl.NumberFormat(locale, {
- style: "unit",
- unit,
- unitDisplay: "long",
- maximumFractionDigits,
- }).format(value);
-}
-
-function formatAutomationInterval(ms: number, locale: string): string {
- const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
- ["day", 86_400_000],
- ["hour", 3_600_000],
- ["minute", 60_000],
- ["second", 1000],
- ];
- for (const [unit, size] of units) {
- if (ms >= size && ms % size === 0) return formatAutomationUnit(ms / size, unit, locale);
- }
- const fallbackUnit = ms < 60_000 ? "second" : "minute";
- const fallbackSize = fallbackUnit === "second" ? 1000 : 60_000;
- return formatAutomationUnit(ms / fallbackSize, fallbackUnit, locale, 1);
-}
-
-function DismissibleStatusMessage({
- message,
- isError,
- onDismiss,
-}: {
- message: string;
- isError: boolean;
- onDismiss: () => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- return (
-
- {message}
-
-
-
-
- );
-}
-
-function RestartRequiredNotice({
- message,
- onRestart,
- isRestarting,
-}: {
- message: string;
- onRestart?: () => void;
- isRestarting?: boolean;
-}) {
- const { t } = useTranslation();
- return (
-
- {message}
- {onRestart ? (
-
- {isRestarting ? (
-
- ) : (
-
- )}
- {isRestarting ? t("app.system.restarting") : t("app.system.restart")}
-
- ) : null}
-
- );
-}
-
-function ChannelsSettings({
- token,
- nanobotFeatures,
- loading,
- query,
- actionKey,
- chatAppsDocsUrl,
- showBrandLogos,
- error,
- requiresRestartPending,
- onQueryChange,
- onAction,
- onFeaturesUpdate,
- onDismissStatus,
- onRestart,
- isRestarting,
-}: {
- token: string;
- nanobotFeatures: NanobotFeaturesPayload | null;
- loading: boolean;
- query: string;
- actionKey: string | null;
- chatAppsDocsUrl?: string;
- showBrandLogos: boolean;
- error: string | null;
- requiresRestartPending: boolean;
- onQueryChange: (value: string) => void;
- onAction: (action: "enable" | "disable", name: string) => void;
- onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
- onDismissStatus: () => void;
- onRestart?: () => void;
- isRestarting?: boolean;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const normalizedQuery = query.trim().toLowerCase();
- const [filter, setFilter] = useState("all");
- const splitLayout = useMediaQuery("(min-width: 1280px)");
- const containerRef = useRef(null);
- const compactDetailTopRef = useRef(null);
- const [compactDetailOpen, setCompactDetailOpen] = useState(false);
- const allChannels = (nanobotFeatures?.features ?? [])
- .filter((feature) => feature.type === "channel")
- .filter((feature) => feature.settings_visible !== false)
- .filter((feature) => !normalizedQuery || channelSearchText(feature, t).includes(normalizedQuery))
- .sort((left, right) => {
- const rank = Number(!left.ready) - Number(!right.ready);
- return rank || localizedChannelDisplayName(left, t).localeCompare(
- localizedChannelDisplayName(right, t),
- );
- });
- const channels = allChannels.filter((feature) => channelMatchesFilter(feature, filter));
- const [selectedChannelName, setSelectedChannelName] = useState(null);
- const selectedChannel =
- channels.find((feature) => feature.name === selectedChannelName) ?? channels[0] ?? null;
- const enabledCount = allChannels.filter(channelIsRunning).length;
- const offCount = Math.max(0, allChannels.length - enabledCount);
- const filterOptions: Array<{ value: ChannelFilter; label: string; count: number }> = [
- { value: "all", label: tx("settings.channels.filterAll", "All"), count: allChannels.length },
- { value: "on", label: tx("settings.channels.filterOn", "On"), count: enabledCount },
- { value: "off", label: tx("settings.channels.filterOff", "Off"), count: offCount },
- ];
- const statusMessage = error;
- const statusIsError = true;
-
- useEffect(() => {
- if (!channels.length) {
- if (selectedChannelName !== null) setSelectedChannelName(null);
- setCompactDetailOpen(false);
- return;
- }
- if (!selectedChannelName || !channels.some((feature) => feature.name === selectedChannelName)) {
- setSelectedChannelName(channels[0].name);
- setCompactDetailOpen(false);
- }
- }, [channels, selectedChannelName]);
-
- useEffect(() => {
- if (splitLayout) return;
- const resetScroll = () => {
- let node = containerRef.current?.parentElement ?? null;
- while (node) {
- node.scrollTop = 0;
- node = node.parentElement;
- }
- if (compactDetailOpen) {
- compactDetailTopRef.current?.scrollIntoView?.({ block: "start" });
- }
- };
- resetScroll();
- const frame = window.requestAnimationFrame(resetScroll);
- return () => window.cancelAnimationFrame(frame);
- }, [compactDetailOpen, selectedChannelName, splitLayout]);
-
- const openChannel = (name: string) => {
- setSelectedChannelName(name);
- if (!splitLayout) setCompactDetailOpen(true);
- };
-
- const setupPanel = selectedChannel ? (
-
- ) : null;
- const showingCompactDetail = !splitLayout && compactDetailOpen && selectedChannel !== null;
-
- return (
-
- {!showingCompactDetail ? (
-
-
-
-
- onQueryChange(event.target.value)}
- placeholder={tx("settings.channels.searchPlaceholder", "Search channels")}
- className={cn(
- "h-12 rounded-[14px] pl-11 text-[15px]",
- SETTINGS_SEARCH_INPUT_CLASS,
- )}
- />
-
-
- {filterOptions.map((option) => (
- setFilter(option.value)}
- className={cn(
- "rounded-[11px] px-3 py-1.5 text-[12px] font-medium transition-colors",
- filter === option.value
- ? "bg-background text-foreground"
- : "text-muted-foreground hover:text-foreground",
- )}
- >
- {option.label}
- {option.count}
-
- ))}
-
-
-
- ) : null}
-
- {statusMessage ? (
-
-
-
- ) : null}
-
- {requiresRestartPending ? (
-
-
-
- ) : null}
-
-
- {loading && !nanobotFeatures ? (
-
-
- {tx("settings.channels.loading", "Loading Channels...")}
-
- ) : channels.length ? splitLayout ? (
-
-
- {channels.map((feature) => (
- openChannel(feature.name)}
- />
- ))}
-
-
{setupPanel}
-
- ) : showingCompactDetail ? (
-
- setCompactDetailOpen(false)}
- className="mb-4 inline-flex h-9 items-center gap-1.5 rounded-full px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
- >
-
- {tx("settings.channels.backToChannels", "All channels")}
-
- {setupPanel}
-
- ) : (
-
- {channels.map((feature) => (
- openChannel(feature.name)}
- />
- ))}
-
- ) : (
-
- {tx("settings.channels.empty", "No channels match this filter.")}
-
- )}
-
-
- );
-}
-
-function AppsCatalogSettings({
- cliApps,
- mcpPresets,
- cliAppsLoading,
- mcpPresetsLoading,
- query,
- filter,
- cliActionKey,
- mcpActionKey,
- mcpOAuthFlow,
- mcpOAuthPopupBlocked,
- mcpOAuthCallbackUrl,
- mcpOAuthCompleting,
- mcpOAuthCallbackError,
- cliMessage,
- cliError,
- cliFocusName,
- mcpMessage,
- mcpError,
- mcpFieldValues,
- customMcpForm,
- mcpConfigImport,
- showBrandLogos,
- requiresRestartPending,
- onQueryChange,
- onFilterChange,
- onCliAction,
- onMcpAction,
- onMcpOAuthConnect,
- onMcpOAuthCancel,
- onMcpOAuthOpen,
- onMcpOAuthCallbackUrlChange,
- onMcpOAuthComplete,
- onDismissStatus,
- onBackToChat,
- onMcpFieldChange,
- onCustomMcpFormChange,
- onMcpConfigImportChange,
- onSaveCustomMcp,
- onImportMcpConfig,
- onMcpToolsChange,
- onRestart,
- isRestarting,
-}: {
- cliApps: CliAppsPayload | null;
- mcpPresets: McpPresetsPayload | null;
- cliAppsLoading: boolean;
- mcpPresetsLoading: boolean;
- query: string;
- filter: AppsKindFilter;
- cliActionKey: string | null;
- mcpActionKey: string | null;
- mcpOAuthFlow: McpOAuthFlowPayload | null;
- mcpOAuthPopupBlocked: boolean;
- mcpOAuthCallbackUrl: string;
- mcpOAuthCompleting: boolean;
- mcpOAuthCallbackError: string | null;
- cliMessage: string | null;
- cliError: string | null;
- cliFocusName: string | null;
- mcpMessage: string | null;
- mcpError: string | null;
- mcpFieldValues: Record>;
- customMcpForm: CustomMcpForm;
- mcpConfigImport: string;
- showBrandLogos: boolean;
- requiresRestartPending: boolean;
- onQueryChange: (value: string) => void;
- onFilterChange: (value: AppsKindFilter) => void;
- onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
- onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void;
- onMcpOAuthConnect: (name: string) => void;
- onMcpOAuthCancel: () => void;
- onMcpOAuthOpen: () => void;
- onMcpOAuthCallbackUrlChange: (value: string) => void;
- onMcpOAuthComplete: () => void;
- onDismissStatus: () => void;
- onBackToChat: () => void;
- onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void;
- onCustomMcpFormChange: Dispatch>;
- onMcpConfigImportChange: (value: string) => void;
- onSaveCustomMcp: () => void;
- onImportMcpConfig: () => void;
- onMcpToolsChange: (name: string, enabledTools: string[]) => void;
- onRestart?: () => void;
- isRestarting?: boolean;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const filterOptions = [
- { value: "ready", label: tx("settings.apps.filterAll", "Ready") },
- { value: "cli", label: tx("settings.apps.filterCli", "Apps") },
- { value: "mcp", label: tx("settings.apps.filterMcp", "MCP") },
- ];
- const normalizedQuery = query.trim().toLowerCase();
- const items: AppsCatalogItem[] = [
- ...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })),
- ...(mcpPresets?.presets ?? []).map((preset) => ({
- id: `mcp:${preset.name}`,
- kind: "mcp" as const,
- preset,
- })),
- ]
- .filter((item) => {
- if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery);
- return filter === "ready" ? appsReady(item) : item.kind === filter;
- })
- .sort((left, right) => {
- const rank = Number(!appsReady(left)) - Number(!appsReady(right));
- return rank || appsTitle(left).localeCompare(appsTitle(right));
- });
- const focusedApp = cliFocusName
- ? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed)
- : null;
- const loading =
- (cliAppsLoading || mcpPresetsLoading) &&
- !cliApps &&
- !mcpPresets;
- const cliAppCount = cliApps?.apps.length ?? 0;
- const emptyTitle = normalizedQuery
- ? tx("settings.apps.empty", "No tools match your search.")
- : filter === "cli"
- ? tx("settings.apps.emptyApps", "No apps available.")
- : filter === "mcp"
- ? tx("settings.apps.emptyIntegrations", "No MCP tools available.")
- : tx("settings.apps.emptyReady", "No tools are ready yet.");
- const emptyBrowseTarget: AppsKindFilter | null = normalizedQuery
- ? null
- : filter === "cli"
- ? "mcp"
- : filter === "mcp"
- ? (cliAppCount ? "cli" : null)
- : cliAppCount
- ? "cli"
- : "mcp";
- const statusMessage =
- cliError ||
- mcpError ||
- (!focusedApp ? cliMessage || mcpMessage : null);
- const statusIsError = Boolean(cliError || mcpError);
- const oauthStatusAnnouncement = mcpOAuthFlow
- ? mcpOAuthStatusText(
- mcpOAuthFlow.status,
- mcpOAuthPopupBlocked,
- tx,
- mcpOAuthFlow.completion_input,
- )
- : "";
- return (
-
-
{oauthStatusAnnouncement}
-
-
-
-
- onQueryChange(event.target.value)}
- placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")}
- className={cn(
- "h-12 rounded-[14px] pl-11 text-[15px]",
- SETTINGS_SEARCH_INPUT_CLASS,
- )}
- />
-
-
onFilterChange(value as AppsKindFilter)}
- />
-
-
-
- {statusMessage ? (
-
- ) : null}
-
- {focusedApp ? (
-
- ) : null}
-
- {requiresRestartPending ? (
-
- ) : null}
-
-
-
-
- {filter === "mcp"
- ? tx("settings.apps.mcpTools", "MCP tools")
- : tx("settings.apps.featured", "Tools")}
-
-
- {items.length}
-
-
- {loading ? (
-
-
- {tx("settings.apps.loading", "Loading Apps...")}
-
- ) : items.length ? (
-
- {items.map((item) =>
- item.kind === "cli" ? (
-
- ) : (
-
- ),
- )}
-
- ) : (
-
-
{emptyTitle}
- {normalizedQuery ? (
-
onQueryChange("")}
- >
- {tx("settings.apps.clearSearch", "Clear search")}
-
- ) : emptyBrowseTarget ? (
-
onFilterChange(emptyBrowseTarget)}
- >
- {emptyBrowseTarget === "cli"
- ? tx("settings.apps.browseApps", "Browse apps")
- : tx("settings.apps.browseIntegrations", "Browse MCP tools")}
-
- ) : (
-
- {tx(
- "settings.apps.emptyIntegrationsHint",
- "Add a custom MCP server below.",
- )}
-
- )}
-
- )}
-
-
- {filter === "mcp" ? (
-
- ) : null}
-
- );
-}
-
-function CliAppsCatalogRow({
- app,
- actionKey,
- showBrandLogos,
- onAction,
-}: {
- app: CliAppInfo;
- actionKey: string | null;
- showBrandLogos: boolean;
- onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const installBusy = actionKey === `install:${app.name}`;
- const updateBusy = actionKey === `update:${app.name}`;
- const uninstallBusy = actionKey === `uninstall:${app.name}`;
- const testBusy = actionKey === `test:${app.name}`;
- const busy = installBusy || updateBusy || uninstallBusy || testBusy;
- const description = app.description || app.requires || app.entry_point || app.name;
-
- return (
-
-
-
-
-
{app.display_name}
-
{tx("settings.apps.cliLabel", "App")}
-
-
{description}
-
-
- {app.installed ? (
- <>
-
-
-
-
-
-
-
- onAction("test", app.name)}>
-
- {tx("settings.cliApps.test", "Test CLI")}
-
- onAction("update", app.name)}>
-
- {tx("settings.cliApps.update", "Update CLI")}
-
- onAction("uninstall", app.name)}
- >
-
- {tx("settings.cliApps.uninstall", "Uninstall CLI")}
-
-
-
-
onAction("uninstall", app.name)}
- >
-
-
- >
- ) : app.install_supported ? (
-
onAction("install", app.name)}
- >
-
-
- ) : (
-
-
-
- )}
-
-
- );
-}
-
-function McpAppsCatalogRow({
- preset,
- values,
- actionKey,
- oauthFlow,
- oauthPopupBlocked,
- oauthCallbackUrl,
- oauthCompleting,
- oauthCallbackError,
- showBrandLogos,
- onFieldChange,
- onAction,
- onOAuthConnect,
- onOAuthCancel,
- onOAuthOpen,
- onOAuthCallbackUrlChange,
- onOAuthComplete,
- onToolsChange,
-}: {
- preset: McpPresetInfo;
- values: Record;
- actionKey: string | null;
- oauthFlow: McpOAuthFlowPayload | null;
- oauthPopupBlocked: boolean;
- oauthCallbackUrl: string;
- oauthCompleting: boolean;
- oauthCallbackError: string | null;
- showBrandLogos: boolean;
- onFieldChange: (presetName: string, fieldName: string, value: string) => void;
- onAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void;
- onOAuthConnect: (name: string) => void;
- onOAuthCancel: () => void;
- onOAuthOpen: () => void;
- onOAuthCallbackUrlChange: (value: string) => void;
- onOAuthComplete: () => void;
- onToolsChange: (name: string, enabledTools: string[]) => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const [setupOpen, setSetupOpen] = useState(false);
- const [toolsOpen, setToolsOpen] = useState(false);
- const enableBusy = actionKey === `enable:${preset.name}`;
- const removeBusy = actionKey === `remove:${preset.name}`;
- const testBusy = actionKey === `test:${preset.name}`;
- const toolsBusy = actionKey === `tools:${preset.name}`;
- const oauthBusy = actionKey === `oauth:${preset.name}`;
- const anotherOAuthBusy = Boolean(actionKey?.startsWith("oauth:")) && !oauthBusy;
- const busy = enableBusy || removeBusy || testBusy || toolsBusy || oauthBusy;
- const isOAuth = preset.auth === "oauth";
- const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
- const hasFields = preset.required_fields.length > 0;
- const needsSetupInput = missingFields.length > 0;
- const readyInstalled = preset.installed && preset.configured;
- const statusLabel = mcpPresetStatusLabel(preset.status, tx);
- const canEnable =
- preset.install_supported &&
- (missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
- const toolNames = preset.tool_names ?? [];
- const enabledTools = preset.enabled_tools ?? ["*"];
- const allowAllTools = enabledTools.includes("*");
- const enabledSet = new Set(allowAllTools ? toolNames : enabledTools);
- const description = preset.description || preset.note || preset.requires || preset.name;
- const manualCallback =
- oauthFlow?.completion_input === "callback_url" && Boolean(oauthFlow.authorization_url);
- const callbackInputId = `mcp-oauth-callback-${preset.name}`;
- const callbackHelpId = `${callbackInputId}-help`;
- const callbackErrorId = `${callbackInputId}-error`;
-
- useEffect(() => {
- if (preset.configured || !preset.install_supported) setSetupOpen(false);
- }, [preset.configured, preset.install_supported]);
-
- const enableOrOpenSetup = () => {
- if (isOAuth) {
- onOAuthConnect(preset.name);
- return;
- }
- if (needsSetupInput || (preset.installed && !preset.configured && hasFields)) {
- setSetupOpen(true);
- return;
- }
- onAction("enable", preset.name, values);
- };
- const submitSetup = () => {
- if (!canEnable) return;
- onAction("enable", preset.name, values);
- };
- const setTools = (next: string[]) => onToolsChange(preset.name, next);
- const toggleTool = (toolName: string) => {
- const next = new Set(allowAllTools ? toolNames : enabledTools);
- if (next.has(toolName)) next.delete(toolName);
- else next.add(toolName);
- const nextValues = Array.from(next);
- setTools(nextValues.length === toolNames.length ? ["*"] : nextValues);
- };
-
- return (
-
-
-
-
-
-
{preset.display_name}
-
{tx("settings.apps.mcpLabel", "MCP")}
-
-
{description}
-
-
- {readyInstalled ? (
- <>
-
-
-
-
-
-
-
- onAction("test", preset.name)}>
-
- {tx("settings.mcp.test", "Test")}
-
- {toolNames.length ? (
- setToolsOpen((open) => !open)}>
-
- {tx("settings.mcp.toolScope", "Tools")}
-
- ) : null}
- onAction("remove", preset.name)}
- >
-
- {tx("settings.mcp.remove", "Remove")}
-
-
-
-
onAction("remove", preset.name)}
- >
-
-
- >
- ) : oauthFlow ? (
- <>
-
-
- >
- ) : isOAuth && preset.install_supported ? (
-
onOAuthConnect(preset.name)}
- />
- ) : preset.installed && !preset.configured ? (
- {
- if (hasFields) setSetupOpen(true);
- else onAction("enable", preset.name, values);
- }}
- />
- ) : preset.install_supported ? (
-
- ) : (
-
- )}
-
-
-
- {manualCallback ? (
- {
- event.preventDefault();
- onOAuthComplete();
- }}
- >
-
-
-
-
- {t("settings.oauth.pasteCallbackToContinue")}
-
-
- {tx(
- "settings.mcp.manualCallbackHelp",
- "After approving access, the localhost page will not load. Copy its full URL from the address bar and paste it here.",
- )}
-
-
-
-
-
- {t("settings.oauth.callbackUrl")}
-
-
onOAuthCallbackUrlChange(event.target.value)}
- placeholder={t("settings.oauth.callbackUrlPlaceholder")}
- autoComplete="off"
- spellCheck={false}
- required
- aria-invalid={Boolean(oauthCallbackError)}
- aria-describedby={
- oauthCallbackError
- ? `${callbackHelpId} ${callbackErrorId}`
- : callbackHelpId
- }
- className="min-h-[88px] w-full resize-y break-all font-mono text-[12px] leading-5"
- />
- {oauthCallbackError ? (
-
- {oauthCallbackError}
-
- ) : null}
-
-
-
- {tx("settings.mcp.continueSignIn", "Continue sign-in")}
-
-
-
- {oauthCompleting ? (
-
- ) : null}
- {t("settings.oauth.finishSignIn")}
-
-
-
- ) : oauthFlow && oauthPopupBlocked && oauthFlow.authorization_url ? (
-
-
-
- {mcpOAuthStatusText(
- oauthFlow.status,
- oauthPopupBlocked,
- tx,
- oauthFlow.completion_input,
- )}
-
-
-
-
- {tx("settings.mcp.continueSignIn", "Continue sign-in")}
-
-
-
-
- ) : null}
-
- {setupOpen && preset.install_supported && hasFields ? (
-
-
-
-
- {t("settings.mcp.connectTitle", {
- name: preset.display_name,
- defaultValue: "Connect {{name}}",
- })}
-
-
- {tx("settings.mcp.connectHint", "Add the key from your account settings.")}
-
-
-
setSetupOpen(false)}
- className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold text-muted-foreground"
- >
- {tx("settings.actions.cancel", "Cancel")}
-
-
-
- {preset.required_fields.map((field) => (
-
-
- {field.label}
- {field.configured ? (
-
- {tx("settings.mcp.configured", "configured")}
-
- ) : null}
-
- onFieldChange(preset.name, field.name, event.target.value)}
- placeholder={
- field.configured
- ? tx("settings.mcp.keepExisting", "Leave blank to keep existing")
- : field.placeholder
- }
- className="h-9 rounded-full bg-background/80 text-[12.5px]"
- />
-
- ))}
-
-
-
- {enableBusy ? (
-
- ) : (
-
- )}
- {preset.installed
- ? tx("settings.mcp.updateSetup", "Update setup")
- : tx("settings.mcp.saveAndEnable", "Save and enable")}
-
-
-
- ) : null}
-
- {toolsOpen && readyInstalled && toolNames.length ? (
-
-
-
- {tx("settings.mcp.toolScope", "Tools")}
-
-
- setTools(["*"])}
- className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold"
- >
- {tx("settings.mcp.allTools", "All")}
-
- setTools([])}
- className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold"
- >
- {tx("settings.mcp.noTools", "None")}
-
-
-
-
- {toolNames.map((toolName) => {
- const selected = enabledSet.has(toolName);
- return (
- toggleTool(toolName)}
- className={cn(
- "max-w-full rounded-full border px-2.5 py-1 font-mono text-[11px] transition-colors",
- selected
- ? "border-blue-500/25 bg-blue-500/10 text-blue-700 dark:text-blue-300"
- : "border-border/55 bg-muted/30 text-muted-foreground hover:bg-muted/60",
- )}
- >
- {toolName}
-
- );
- })}
-
-
- ) : null}
-
- );
-}
-
-function AppsTypeBadge({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-const AppsActionButton = forwardRef void;
- children?: ReactNode;
-}>(function AppsActionButton({
- ariaLabel,
- visibleLabel,
- busy,
- disabled,
- tone = "default",
- onClick,
- children,
-}, ref) {
- return (
-
- {busy ? : children}
- {visibleLabel ? {visibleLabel} : null}
-
- );
-});
-
-function appsTitle(item: AppsCatalogItem): string {
- return item.kind === "cli" ? item.app.display_name : item.preset.display_name;
-}
-
-function appsReady(item: AppsCatalogItem): boolean {
- return item.kind === "cli" ? item.app.installed : item.preset.installed && item.preset.configured;
-}
-
-function appsSearchText(item: AppsCatalogItem): string {
- if (item.kind === "cli") {
- const app = item.app;
- return [
- app.display_name,
- app.name,
- app.category,
- app.description,
- app.requires,
- app.entry_point,
- app.source,
- ]
- .join(" ")
- .toLowerCase();
- }
- const preset = item.preset;
- return [
- preset.display_name,
- preset.name,
- preset.category,
- preset.description,
- preset.requires,
- preset.note,
- preset.transport,
- preset.source ?? "",
- ]
- .join(" ")
- .toLowerCase();
-}
-
-function McpCustomServerPanel({
- form,
- configImport,
- actionKey,
- onFormChange,
- onConfigImportChange,
- onSave,
- onImportConfig,
-}: {
- form: CustomMcpForm;
- configImport: string;
- actionKey: string | null;
- onFormChange: Dispatch>;
- onConfigImportChange: (value: string) => void;
- onSave: () => void;
- onImportConfig: () => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const oauthHelpId = useId();
- const headersInputId = useId();
- const headersHelpId = useId();
- const [activeMode, setActiveMode] = useState<"custom" | "import" | null>(null);
- const [advancedOpen, setAdvancedOpen] = useState(false);
- const customBusy = actionKey?.startsWith("custom:") ?? false;
- const importBusy = actionKey === "import" || actionKey === "import-cursor";
- const remote = form.transport !== "stdio";
- const canSave = Boolean(form.name.trim()) && (remote ? Boolean(form.url.trim()) : Boolean(form.command.trim()));
- const update = (key: K, value: CustomMcpForm[K]) => {
- onFormChange((prev) => ({ ...prev, [key]: value }));
- };
- const transports: Array<{ value: CustomMcpTransport; label: string }> = [
- { value: "stdio", label: "stdio" },
- { value: "streamableHttp", label: "HTTP" },
- { value: "sse", label: "SSE" },
- ];
- const authenticationOptions: Array<{ value: CustomMcpAuth; label: string }> = [
- { value: "none", label: tx("settings.mcp.authNone", "None") },
- { value: "oauth", label: "OAuth" },
- { value: "headers", label: tx("settings.mcp.authHeaders", "Headers") },
- ];
-
- return (
-
-
-
-
-
-
-
-
- {tx("settings.mcp.moreOptions", "Add MCP server")}
-
-
- {tx(
- "settings.mcp.moreOptionsSubtitle",
- "Connect a custom MCP server or import an existing configuration.",
- )}
-
-
-
-
- setActiveMode((mode) => (mode === "custom" ? null : "custom"))}
- className="h-8 rounded-full px-3 text-[12px] font-semibold"
- >
-
- {tx("settings.mcp.customAction", "Custom")}
-
- setActiveMode((mode) => (mode === "import" ? null : "import"))}
- className="h-8 rounded-full px-3 text-[12px] font-semibold"
- >
-
- {tx("settings.mcp.importAction", "Import")}
-
-
-
-
- {activeMode === "custom" ? (
-
-
-
- {remote ? (
-
-
-
- {tx("settings.mcp.authentication", "Authentication")}
-
- update("auth", value as CustomMcpAuth)}
- className="w-full sm:w-auto"
- itemClassName="min-w-0 flex-1 sm:flex-none"
- />
-
- {form.auth === "oauth" ? (
-
- {tx(
- "settings.mcp.oauthAfterSave",
- "Save the server, then select Connect to sign in.",
- )}
-
- ) : null}
-
- ) : null}
-
- {remote && form.auth === "headers" ? (
-
-
- {tx("settings.mcp.headers", "Headers JSON")}
-
-
- ) : null}
-
-
setAdvancedOpen((open) => !open)}
- className="mt-2 h-8 rounded-full px-2 text-[12px] font-medium text-muted-foreground hover:text-foreground"
- >
-
- {advancedOpen
- ? tx("settings.mcp.hideAdvanced", "Hide advanced")
- : tx("settings.mcp.advancedOptions", "Advanced options")}
-
-
- {advancedOpen ? (
-
- {!remote ? (
-
-
- {tx("settings.mcp.args", "Args JSON")}
-
- update("args", event.target.value)}
- placeholder={'["-y", "docs-mcp"]'}
- className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
- />
-
- ) : null}
-
-
- {tx("settings.mcp.env", "Env JSON")}
-
- update("env", event.target.value)}
- placeholder={'{"API_KEY":"..."}'}
- className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
- />
-
-
-
- {tx("settings.mcp.timeout", "Tool timeout")}
-
- update("toolTimeout", event.target.value)}
- inputMode="numeric"
- className="h-9 rounded-full bg-background/80 text-[12.5px]"
- />
-
-
- ) : null}
-
-
-
- {customBusy ? : }
- {tx("settings.mcp.saveCustom", "Save MCP")}
-
-
-
- ) : null}
-
- {activeMode === "import" ? (
-
-
-
-
- {tx("settings.mcp.configImport", "Import mcp.json")}
-
- onConfigImportChange(event.target.value)}
- placeholder={'{"mcpServers":{"docs":{"command":"npx","args":["-y","docs-mcp"]}}}'}
- className="min-h-[84px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
- />
-
-
- {importBusy ? : }
- {tx("settings.mcp.importConfig", "Import")}
-
-
-
- ) : null}
-
- );
-}
-
-function mcpOAuthStatusText(
- status: McpOAuthFlowPayload["status"],
- popupBlocked: boolean,
- tx: (key: string, fallback: string) => string,
- completionInput?: McpOAuthFlowPayload["completion_input"],
-): string {
- switch (status) {
- case "starting":
- return tx("settings.mcp.preparingSignIn", "Preparing secure sign-in...");
- case "authorization_required":
- if (completionInput === "callback_url") {
- return tx(
- "settings.mcp.manualCallbackRequired",
- "Finish signing in, then paste the callback URL into nanobot.",
- );
- }
- return popupBlocked
- ? tx("settings.mcp.openSignInToContinue", "Open the sign-in page to continue.")
- : tx("settings.mcp.finishSignInInBrowser", "Finish signing in in the browser window.");
- case "connecting":
- return tx("settings.mcp.finishingConnection", "Finishing connection...");
- case "authorized":
- return tx("settings.mcp.activatingTools", "Activating tools...");
- case "connected":
- return tx("settings.mcp.connected", "Connected.");
- case "failed":
- return tx("settings.mcp.connectionFailed", "Connection failed.");
- case "cancelled":
- return tx("settings.mcp.connectionCancelled", "Connection cancelled.");
- }
-}
-
-function mcpPresetStatusLabel(
- status: string,
- tx: (key: string, fallback: string) => string,
-): string {
- switch (status) {
- case "configured":
- return tx("settings.mcp.statusConfigured", "Configured");
- case "missing_credentials":
- return tx("settings.mcp.statusMissingCredentials", "Needs key");
- case "missing_dependency":
- return tx("settings.mcp.statusMissingDependency", "Needs dependency");
- case "coming_soon":
- return tx("settings.mcp.statusComingSoon", "Coming soon");
- default:
- return tx("settings.mcp.statusNotInstalled", "Not enabled");
- }
-}
-
-function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; showBrandLogos: boolean }) {
- const bg = preset.brand_color || "hsl(var(--muted))";
- const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
- const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
- const initials = preset.display_name
- .split(/\s+/)
- .filter(Boolean)
- .slice(0, 2)
- .map((part) => part[0]?.toUpperCase())
- .join("") || preset.name.slice(0, 2).toUpperCase();
-
- if (showBrandLogos && logoUrl) {
- return (
-
-
-
- );
- }
- return (
-
- {initials}
-
- );
-}
-
-function CliAppReadyPanel({
- app,
- showBrandLogos,
- onBackToChat,
-}: {
- app: CliAppInfo;
- showBrandLogos: boolean;
- onBackToChat: () => void;
-}) {
- const { t } = useTranslation();
- const [copied, setCopied] = useState(false);
- const prompt = t("settings.cliApps.readyPrompt", {
- name: app.name,
- defaultValue: "Use @{{name}} to inspect what this CLI can do.",
- });
- const copyPrompt = () => {
- if (!navigator.clipboard) return;
- void navigator.clipboard.writeText(prompt).then(() => {
- setCopied(true);
- window.setTimeout(() => setCopied(false), 1400);
- });
- };
-
- return (
-
-
-
-
-
-
- {app.display_name}
-
-
-
- {t("settings.cliApps.readyStatus", { defaultValue: "Ready" })}
-
-
-
- @{app.name}
- ·
- {app.entry_point || app.name}
- ·
- {app.category}
-
-
-
-
- {copied ? : null}
- {copied
- ? t("settings.cliApps.readyCopied", { defaultValue: "Copied" })
- : t("settings.cliApps.readyTry", { name: app.name, defaultValue: "Try @{{name}}" })}
-
-
- {t("settings.cliApps.openChat", { defaultValue: "Open chat" })}
-
-
-
-
-
- );
-}
-
-function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
- const logoUrls = useMemo(
- () => (isGenericRepositoryLogoUrl(app.logo_url) ? [] : logoFallbackUrls(app.logo_url)),
- [app.logo_url],
- );
- const { logoUrl, logoLoaded, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
- const initials = app.display_name
- .split(/\s+/)
- .filter(Boolean)
- .slice(0, 2)
- .map((part) => part[0]?.toUpperCase())
- .join("") || app.name.slice(0, 2).toUpperCase();
-
- const showRemoteLogo = showBrandLogos && Boolean(logoUrl);
-
- return (
-
-
- {initials}
-
- {showRemoteLogo ? (
-
- ) : null}
-
- );
-}
-
-function RuntimeSettings({
- form,
- settings,
- onRestart,
- isRestarting,
- requiresRestartPending,
- apiService,
- apiServiceLoading,
- apiServiceAction,
- apiServiceError,
- langfuseFeature,
- capabilitiesLoading,
- capabilityAction,
- capabilityError,
- onApiServiceAction,
- onInstallCapability,
-}: {
- form: AgentSettingsDraft;
- settings: SettingsPayload;
- onRestart?: () => void;
- isRestarting?: boolean;
- requiresRestartPending: boolean;
- apiService: ApiServicePayload | null;
- apiServiceLoading: boolean;
- apiServiceAction: "start" | "stop" | null;
- apiServiceError: string | null;
- langfuseFeature?: NanobotFeatureInfo;
- capabilitiesLoading: boolean;
- capabilityAction: string | null;
- capabilityError: string | null;
- onApiServiceAction: (
- action: "start" | "stop",
- values?: { host: string; port: number; timeout: number; apiKey?: string },
- ) => void;
- onInstallCapability: (name: string) => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const runtimeSurface = settings.surface ?? settings.runtime_surface;
- const runtimeHost = getRuntimeHost(runtimeSurface, settings.runtime_capabilities);
- const openLogs = runtimeHost.openLogs;
- const exportDiagnostics = runtimeHost.exportDiagnostics;
- const isNativeHost = isNativeRuntime(runtimeSurface);
- const restartActionLabel = isNativeHost
- ? tx("app.system.restartEngine", "Restart engine")
- : t("app.system.restart");
- const restartingActionLabel = isNativeHost
- ? tx("app.system.restartingEngine", "Restarting engine...")
- : t("app.system.restarting");
- const [diagnosticsPath, setDiagnosticsPath] = useState(null);
- const [hostActionMessage, setHostActionMessage] = useState<{
- target: "logs" | "diagnostics";
- message: string;
- } | null>(null);
- const [hostActionBusy, setHostActionBusy] =
- useState<"logs" | "diagnostics" | null>(null);
- const apiDefaults = apiService ?? {
- installed: false,
- running: false,
- managed: false,
- host: settings.api?.host ?? "127.0.0.1",
- port: settings.api?.port ?? 8900,
- timeout: settings.api?.timeout ?? 120,
- api_key_hint: settings.api?.api_key_hint,
- endpoint: `http://127.0.0.1:${settings.api?.port ?? 8900}/v1`,
- command: "nanobot serve",
- };
- const [apiHost, setApiHost] = useState(apiDefaults.host);
- const [apiPort, setApiPort] = useState(apiDefaults.port);
- const [apiKey, setApiKey] = useState("");
- const [apiKeyVisible, setApiKeyVisible] = useState(false);
- useEffect(() => {
- if (!apiService) return;
- setApiHost(apiService.host);
- setApiPort(apiService.port);
- setApiKey("");
- setApiKeyVisible(false);
- }, [apiService]);
- const apiNetworkAccess = !isLoopbackHost(apiHost);
- const apiMissingNetworkKey = apiNetworkAccess && !apiKey.trim() && !apiDefaults.api_key_hint;
- const engineState = isRestarting
- ? tx("settings.values.restartingEngine", "Restarting")
- : settings.apply_state?.status === "pending"
- ? tx("settings.values.pending", "Pending")
- : tx("settings.values.ready", "Ready");
- const runHostAction = async (
- target: "logs" | "diagnostics",
- action: (() => Promise) | undefined,
- successMessage: (result: string | void) => string,
- failureMessage: string,
- ) => {
- if (!action) {
- setHostActionMessage({
- target,
- message: tx(
- "settings.status.hostApiUnavailable",
- "Host actions are only available inside the native app.",
- ),
- });
- return;
- }
- setHostActionBusy(target);
- setHostActionMessage(null);
- try {
- const result = await action();
- setHostActionMessage({ target, message: successMessage(result) });
- } catch {
- setHostActionMessage({ target, message: failureMessage });
- } finally {
- setHostActionBusy(null);
- }
- };
- return (
-
- {isNativeHost ? (
-
- {tx("settings.sections.nativeHost", "Native host")}
-
-
- {settings.runtime_capabilities?.can_open_logs ? (
-
-
- void runHostAction(
- "logs",
- openLogs,
- () => tx("settings.status.logsOpened", "Opened logs folder."),
- tx("settings.status.logsOpenFailed", "Could not open logs folder."),
- )
- }
- disabled={hostActionBusy !== null}
- className="rounded-full"
- >
- {hostActionBusy === "logs"
- ? tx("settings.actions.opening", "Opening...")
- : tx("settings.actions.open", "Open")}
-
-
- ) : null}
- {settings.runtime_capabilities?.can_export_diagnostics ? (
-
-
- void runHostAction(
- "diagnostics",
- exportDiagnostics ? async () => {
- const path = await exportDiagnostics();
- setDiagnosticsPath(path);
- return path;
- } : undefined,
- (path) =>
- t("settings.status.diagnosticsExported", {
- path: String(path ?? ""),
- defaultValue: "Diagnostics exported to {{path}}.",
- }),
- tx("settings.status.diagnosticsExportFailed", "Could not export diagnostics."),
- )
- }
- disabled={hostActionBusy !== null}
- className="rounded-full"
- >
- {hostActionBusy === "diagnostics"
- ? tx("settings.actions.exporting", "Exporting...")
- : tx("settings.actions.export", "Export")}
-
-
- ) : null}
-
-
- ) : null}
-
-
- {tx("settings.api.title", "API server")}
-
-
-
-
- {apiServiceLoading
- ? tx("settings.values.checking", "Checking")
- : apiDefaults.running
- ? tx("settings.values.running", "Running")
- : tx("settings.values.off", "Off")}
-
-
- onApiServiceAction(
- apiDefaults.running ? "stop" : "start",
- apiDefaults.running
- ? undefined
- : {
- host: apiHost,
- port: apiPort,
- timeout: apiDefaults.timeout,
- apiKey: apiKey.trim() || undefined,
- },
- )
- }
- className="rounded-full"
- >
- {apiServiceAction ? (
-
- ) : apiDefaults.running ? (
-
- ) : (
-
- )}
- {apiServiceAction === "start"
- ? tx("settings.api.starting", "Starting...")
- : apiServiceAction === "stop"
- ? tx("settings.api.stopping", "Stopping...")
- : apiDefaults.running
- ? tx("settings.api.stop", "Stop")
- : tx("settings.api.start", "Start API server")}
-
-
-
- {!apiDefaults.running ? (
- <>
-
- setApiHost(value === "network" ? "0.0.0.0" : "127.0.0.1")}
- />
-
-
-
-
- {apiNetworkAccess ? (
-
-
- setApiKey(event.target.value)}
- placeholder={apiDefaults.api_key_hint ?? tx("settings.api.apiKeyPlaceholder", "Enter an API key")}
- className="h-9 rounded-full pr-10 text-[13px]"
- />
- setApiKeyVisible((visible) => !visible)}
- aria-label={apiKeyVisible ? tx("settings.byok.hideApiKey", "Hide API key") : tx("settings.byok.showApiKey", "Show API key")}
- className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full"
- >
- {apiKeyVisible ? : }
-
-
-
- ) : null}
- >
- ) : null}
-
-
-
-
- {tx("settings.observability.title", "Observability")}
-
-
- {capabilitiesLoading ? (
-
- ) : langfuseFeature?.installed ? (
-
- {settings.observability?.configured
- ? tx("settings.values.ready", "Ready")
- : tx("settings.values.needsSetup", "Needs setup")}
-
- ) : (
- onInstallCapability("langfuse")}
- className="rounded-full"
- >
- {capabilityAction === "enable:langfuse" ? (
-
- ) : null}
- {capabilityAction === "enable:langfuse"
- ? tx("settings.capabilities.installing", "Installing support...")
- : tx("settings.observability.enable", "Enable tracing support")}
-
- )}
-
-
- {capabilityError ? {capabilityError}
: null}
-
-
-
- {t("settings.sections.system")}
-
- {!isNativeHost ? (
-
- ) : null}
-
-
-
- {onRestart ? (
-
-
- {isRestarting ? (
-
- ) : (
-
- )}
- {isRestarting ? restartingActionLabel : restartActionLabel}
-
-
- ) : null}
-
-
-
- );
-}
-
-function AdvancedSettings({
- form,
- dirty,
- saving,
- requiresRestartPending,
- isNativeHostSurface,
- onChangeForm,
- onSave,
- onRestart,
- isRestarting,
-}: {
- form: NetworkSafetySettingsUpdate;
- dirty: boolean;
- saving: boolean;
- requiresRestartPending: boolean;
- isNativeHostSurface: boolean;
- onChangeForm: Dispatch>;
- onSave: () => void;
- onRestart?: () => void;
- isRestarting?: boolean;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- return (
-
-
-
- {isNativeHostSurface
- ? tx("settings.sections.hostSafety", "App safety")
- : tx("settings.sections.webuiSafety", "Web safety")}
-
-
-
-
- onChangeForm((prev) => ({ ...prev, webuiAllowLocalServiceAccess }))
- }
- ariaLabel={tx("settings.rows.localServiceAccess", "Local Service Access")}
- label={form.webuiAllowLocalServiceAccess ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
- />
-
-
-
- onChangeForm((prev) => ({
- ...prev,
- webuiDefaultAccessMode: webuiDefaultAccessMode as WebuiDefaultAccessMode,
- }))
- }
- />
-
-
-
-
-
-
- {tx(
- "settings.help.securityManagedControls",
- "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
- )}
-
-
- );
-}
-
-function ProviderPicker({
- providers,
- value,
- emptyLabel,
- showProviderLogos = false,
- onChange,
-}: {
- providers: Array<{ name: string; label: string }>;
- value: string;
- emptyLabel: string;
- showProviderLogos?: boolean;
- onChange: (provider: string) => void;
-}) {
- const selectedProvider = providers.find((provider) => provider.name === value) ?? null;
- const disabled = providers.length === 0;
-
- return (
-
-
-
-
- {selectedProvider && showProviderLogos ? (
-
- ) : null}
- {selectedProvider?.label ?? emptyLabel}
-
-
-
-
-
- {providers.map((provider) => {
- const selected = provider.name === value;
- return (
- onChange(provider.name)}
- className={cn(
- "flex cursor-default items-center justify-between gap-2 text-[13px]",
- selected && "bg-muted/80 text-foreground focus:bg-muted",
- )}
- >
-
- {showProviderLogos ? (
-
- ) : null}
- {provider.label}
-
- {selected ? : null}
-
- );
- })}
-
-
- );
-}
-
-function ModelIdPicker({
- token,
- settings,
- provider,
- models,
- value,
- showProviderLogos,
- emptyLabel,
- searchPlaceholder,
- emptyMessage,
- onChange,
-}: {
- token: string;
- settings: SettingsPayload;
- provider: string;
- models?: string[];
- value: string;
- showProviderLogos: boolean;
- emptyLabel?: string;
- searchPlaceholder?: string;
- emptyMessage?: string;
- onChange: (model: string) => void;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const tokenRef = useRef(token);
- tokenRef.current = token;
- const [open, setOpen] = useState(false);
- const [query, setQuery] = useState("");
- const [payload, setPayload] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const effectiveProvider =
- provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
- const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
- const hasStaticModels = models !== undefined;
- const providerRow = settingsProviderRow(settings, effectiveProvider);
- const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
- const providerRequiresConfiguration =
- !hasStaticModels && hasConcreteProvider && !providerConfigured;
- const providerHasBuiltinModels = providerRow?.model_catalog === "builtin";
- const providerUsesManualModelIds =
- !hasStaticModels &&
- hasConcreteProvider &&
- providerConfigured &&
- providerRow?.auth_type === "oauth" &&
- !providerHasBuiltinModels;
- const canFetchModels =
- !hasStaticModels &&
- hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
- const normalizedQuery = query.trim().toLowerCase();
- const providerModels: ProviderModelsPayload["models"] = useMemo(
- () => hasStaticModels
- ? (models?.map((id) => ({ id })) ?? [])
- : (payload?.models ?? []),
- [hasStaticModels, models, payload?.models],
- );
- const visibleModels = useMemo(
- () => providerModels
- .filter((model) => {
- if (!normalizedQuery) return true;
- return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""]
- .some((field) => field.toLowerCase().includes(normalizedQuery));
- })
- .slice(0, 80),
- [normalizedQuery, providerModels],
- );
- const isCatalog = payload?.catalog_kind === "catalog";
- const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
- const hasDeferredSearchQuery =
- normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH;
- const shouldFetchModels =
- canFetchModels && (!defersModelList || hasDeferredSearchQuery);
- const waitingForModelSearch =
- open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
- const hasModelList = hasStaticModels || payload?.status === "available";
- const showModels = Boolean(
- hasModelList && (hasStaticModels || (payload && (!isCatalog || normalizedQuery))),
- );
- const customCandidate = query.trim();
- const allowCustomModel = !providerRequiresConfiguration;
- const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
- const showCustomModel = Boolean(
- allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value,
- );
- const providerModelCount = payload?.model_count ?? providerModels.length;
- const modelUnconfigured = !value.trim() || !providerConfigured;
-
- useEffect(() => {
- if (!open) return;
- setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
- }, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
-
- useEffect(() => {
- if (!open || !shouldFetchModels) {
- setPayload(null);
- setError(null);
- setLoading(false);
- return;
- }
- let cancelled = false;
- setPayload(null);
- setError(null);
- setLoading(true);
- fetchProviderModels(tokenRef.current, effectiveProvider)
- .then((nextPayload) => {
- if (!cancelled) setPayload(nextPayload);
- })
- .catch((err) => {
- if (!cancelled) setError((err as Error).message);
- })
- .finally(() => {
- if (!cancelled) setLoading(false);
- });
- return () => {
- cancelled = true;
- };
- }, [effectiveProvider, open, shouldFetchModels]);
-
- const selectModel = (model: string) => {
- onChange(model);
- setOpen(false);
- };
- const navigationValues = useMemo(
- () => [
- ...(showModels ? visibleModels.map((model) => model.id) : []),
- ...(showCustomModel ? [customCandidate] : []),
- ],
- [customCandidate, showCustomModel, showModels, visibleModels],
- );
- const navigation = useComboboxNavigation({
- open,
- values: navigationValues,
- selectedValue: value,
- onSelect: selectModel,
- onClose: () => setOpen(false),
- });
-
- const renderModelRow = (
- model: ProviderModelsPayload["models"][number],
- options: { selected?: boolean } = {},
- ) => (
-
-
-
-
-
- {model.label ?? model.id}
-
- {model.description || (model.label && model.label !== model.id) ? (
-
- {[model.label && model.label !== model.id ? model.id : null, model.description]
- .filter(Boolean)
- .join(" · ")}
-
- ) : null}
-
-
-
- {model.context_window ? {formatContextWindow(model.context_window)} : null}
- {options.selected ? : null}
-
-
- );
-
- return (
-
-
-
-
-
-
- {value || emptyLabel || tx("settings.models.selectModel", "Select model")}
-
-
-
-
-
-
-
-
-
- setQuery(event.target.value)}
- {...navigation.inputProps}
- placeholder={
- searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
- }
- aria-label={
- searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
- }
- className="h-8 rounded-full pl-8 pr-3 text-[12px]"
- />
-
-
-
- {providerRequiresConfiguration ? (
-
- {tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
-
- ) : hasStaticModels && !providerModels.length ? (
-
- {emptyMessage || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
-
- ) : providerUsesManualModelIds ? (
-
- {tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
-
- ) : !canFetchModels ? (
-
- {tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
-
- ) : waitingForModelSearch ? (
-
- {tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
-
- ) : loading ? (
-
-
- {tx("settings.models.loadingModels", "Loading models...")}
-
- ) : error || payload?.status === "error" ? (
-
- {payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
-
- ) : payload?.status === "not_configured" ? (
-
- {tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
-
- ) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
-
- {payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
-
- ) : isCatalog && !normalizedQuery ? (
-
- {tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
- {providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
-
- ) : null}
-
- {navigationValues.length ? (
-
- {showModels
- ? visibleModels.map((model) =>
- renderModelRow(model, { selected: model.id === value }),
- )
- : null}
- {showCustomModel ? (
- <>
- {showModels && visibleModels.length ? (
-
- ) : null}
-
-
-
-
-
- {tx("settings.models.useCustomModel", "Use")}{" "}
- “{customCandidate}”
-
-
- >
- ) : null}
-
- ) : showModels ? (
-
- {tx("settings.models.noModelResults", "No matching models.")}
-
- ) : null}
-
-
-
- );
-}
-
-function formatContextWindow(tokens: number): string {
- if (tokens >= 1_000_000) {
- const value = tokens / 1_000_000;
- return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
- }
- if (tokens >= 1_000) {
- const value = tokens / 1_000;
- return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;
- }
- return String(tokens);
-}
-
-function formatModelContextWindow(tokens: number): string {
- if (tokens === 65_536) return "64K";
- if (tokens === 262_144) return "256K";
- if (tokens === 1_048_576) return "1M";
- return formatContextWindow(tokens);
-}
-
-function ProviderPickerIcon({
- provider,
- showBrandLogos,
- unconfigured = false,
-}: {
- provider: string;
- showBrandLogos: boolean;
- unconfigured?: boolean;
-}) {
- const brand = providerBrand(provider);
- const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
- const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
-
- if (unconfigured) {
- return (
-
-
-
- );
- }
-
- if (showBrandLogos && logoUrl) {
- return (
-
-
-
- );
- }
-
- if (showBrandLogos && brand) {
- return (
-
- {brand.initials}
-
- );
- }
-
- return (
-
-
-
- );
-}
-
-function orderUnconfiguredProviders(
- providers: SettingsPayload["providers"],
-): SettingsPayload["providers"] {
- return providers
- .map((provider, index) => ({ provider, index }))
- .sort((left, right) => {
- const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider);
- return rank || left.index - right.index;
- })
- .map(({ provider }) => provider);
-}
-
-function uniqueProviders(
- providers: SettingsPayload["providers"],
-): SettingsPayload["providers"] {
- const seen = new Set();
- return providers.filter((provider) => {
- if (seen.has(provider.name)) return false;
- seen.add(provider.name);
- return true;
- });
-}
-
-function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
- const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
- if (localRank !== undefined) return localRank;
- if ((provider.api_key_required ?? true) === false) return 100;
- return 200;
-}
-
-function optionRowsWithCurrent(
- options: Array<{ name: string; label: string }>,
- value: string,
-): Array<{ name: string; label: string }> {
- if (!value || options.some((option) => option.name === value)) return options;
- return [{ name: value, label: value }, ...options];
-}
-
-function modelPresetProviderKey(
- preset: SettingsPayload["model_presets"][number],
- settings: SettingsPayload,
- options: { draftProvider?: string } = {},
-): string {
- const provider = options.draftProvider ?? preset.provider;
- if (provider === "auto") {
- return (
- preset.resolved_provider ||
- settings.agent.resolved_provider ||
- settings.agent.provider ||
- preset.provider
- );
- }
- return provider;
-}
-
-const PROVIDER_ICONS: Record = {
- custom: Hexagon,
- openrouter: Sparkles,
- skywork: Sparkles,
- aihubmix: Triangle,
- anthropic: Brain,
- openai: Bot,
- deepseek: Waves,
- zhipu: Grid3X3,
- dashscope: Cloud,
- modelscope: Layers,
- moonshot: Moon,
- minimax: Zap,
- minimax_anthropic: Brain,
- groq: Cpu,
- huggingface: Layers,
- gemini: Gem,
- mistral: Orbit,
- siliconflow: Layers,
- volcengine: Cloud,
- volcengine_coding_plan: Cloud,
- byteplus: Cloud,
- byteplus_coding_plan: Cloud,
- qianfan: Database,
- ant_ling: Sparkles,
- azure_openai: Cloud,
- bedrock: Database,
- bocha: Search,
- brave: Search,
- duckduckgo: Search,
- exa: Search,
- jina: Search,
- kagi: Search,
- olostep: Search,
- searxng: Search,
- tavily: Search,
- vllm: Cpu,
- ollama: Cpu,
- lm_studio: Cpu,
- atomic_chat: Cpu,
- ovms: Cpu,
- nvidia: Zap,
-};
-
-function ProviderIcon({
- provider,
- showBrandLogos,
-}: {
- provider: string;
- showBrandLogos: boolean;
-}) {
- const brand = providerBrand(provider);
- const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
- const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
-
- if (showBrandLogos && logoUrl) {
- return (
-
-
-
- );
- }
- if (showBrandLogos && brand) {
- return (
-
- {brand.initials}
-
- );
- }
- return (
-
-
-
- );
-}
-
-function OverviewRowIcon({
- icon: Icon,
-}: {
- icon: LucideIcon;
-}) {
- return (
-
-
-
- );
-}
-
-function OverviewValueLogo({
- provider,
- showBrandLogos,
-}: {
- provider: string | null | undefined;
- showBrandLogos: boolean;
-}) {
- const brand = provider ? providerBrand(provider) : null;
- const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
-
- if (!provider || !showBrandLogos || !brand) return null;
-
- if (logoUrl) {
- return (
-
-
-
- );
- }
-
- return (
-
- {brand.initials}
-
- );
-}
-
-function OverviewListRow({
- icon: Icon,
- valueLogoProvider,
- title,
- value,
- caption,
- showBrandLogos = false,
- onClick,
-}: {
- icon: LucideIcon;
- valueLogoProvider?: string | null;
- title: string;
- value: string;
- caption: string;
- showBrandLogos?: boolean;
- onClick: () => void;
-}) {
- return (
-
-
-
- {title}
- {caption}
-
-
-
-
- {value}
-
-
-
-
- );
-}
-
-function SettingsSectionTitle({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-function SettingsGroup({ children }: { children: ReactNode }) {
- return (
-
- );
-}
-
-function SettingsRow({
- title,
- description,
- children,
-}: {
- title: string;
- description?: string;
- children?: ReactNode;
-}) {
- return (
-
-
-
{title}
- {description ? (
-
- {description}
-
- ) : null}
-
- {children ?
{children}
: null}
-
- );
-}
-
-function ReadOnlyRow({
- title,
- value,
- description,
-}: {
- title: string;
- value: string;
- description?: string;
-}) {
- return (
-
-
- {value}
-
-
- );
-}
-
-function RestartSettingsFooter({
- dirty,
- saving,
- pendingRestart,
- disabled = false,
- message,
- dirtyMessage,
- pendingMessage,
- onSave,
- onRestart,
- onReset,
- isRestarting,
-}: {
- dirty: boolean;
- saving: boolean;
- pendingRestart: boolean;
- disabled?: boolean;
- message?: string;
- dirtyMessage?: string;
- pendingMessage?: string;
- onSave: () => void;
- onRestart?: () => void;
- onReset?: () => void;
- isRestarting?: boolean;
-}) {
- const { t } = useTranslation();
- const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
- const isNativeHost = isNativeRuntime();
- const restartLabel = isNativeHost
- ? tx("app.system.restartEngine", "Restart engine")
- : t("app.system.restart");
- const restartingLabel = isNativeHost
- ? tx("app.system.restartingEngine", "Restarting engine...")
- : t("app.system.restarting");
- const statusMessage =
- message ??
- (pendingRestart && !dirty
- ? pendingMessage ?? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
- : dirty
- ? dirtyMessage ?? t("settings.status.unsaved")
- : undefined);
- const statusTone = disabled ? "danger" : dirty || pendingRestart ? "accent" : undefined;
-
- return (
-
-
- {statusMessage}
-
-
- {pendingRestart && !dirty && onRestart ? (
-
- {isRestarting ? (
-
- ) : (
-
- )}
- {isRestarting ? restartingLabel : restartLabel}
-
- ) : null}
- {onReset ? (
-
- {t("settings.actions.cancel")}
-
- ) : null}
-
- {saving ? t("settings.actions.saving") : t("settings.actions.save")}
-
-
-
- );
-}
-
-function SettingsStatusMessage({
- children,
- tone,
-}: {
- children?: ReactNode;
- tone?: "accent" | "danger";
-}) {
- if (!children) return null;
- return (
-
- {tone ? (
-
- ) : null}
- {children}
-
- );
-}
-
-function StatusPill({
- children,
- tone = "neutral",
-}: {
- children: ReactNode;
- tone?: "neutral" | "success" | "warning";
-}) {
- return (
-
- {children}
-
- );
-}
-
-function NumberInput({
- value,
- min,
- max,
- onChange,
- suffix,
-}: {
- value: number;
- min: number;
- max: number;
- onChange: (value: number) => void;
- suffix?: string;
-}) {
- return (
-
- {
- const parsed = Number(event.target.value);
- if (Number.isFinite(parsed)) onChange(parsed);
- }}
- className="h-8 w-24 max-w-full rounded-full text-[13px]"
- />
- {suffix ? {suffix} : null}
-
);
}
diff --git a/webui/src/components/settings/capabilities/ImageGenerationSettings.tsx b/webui/src/components/settings/capabilities/ImageGenerationSettings.tsx
new file mode 100644
index 000000000..24032e110
--- /dev/null
+++ b/webui/src/components/settings/capabilities/ImageGenerationSettings.tsx
@@ -0,0 +1,213 @@
+import type { Dispatch, SetStateAction } from "react";
+import { useTranslation } from "react-i18next";
+
+import { ModelIdPicker, ProviderPicker, optionRowsWithCurrent } from "@/components/settings/shared/ModelControls";
+import {
+ NumberInput,
+ ReadOnlyRow,
+ RestartSettingsFooter,
+ SettingsGroup,
+ SettingsRow,
+ SettingsSectionTitle,
+ StatusPill,
+} from "@/components/settings/shared/SettingsControls";
+import { ToggleButton } from "@/components/settings/ToggleButton";
+import { Button } from "@/components/ui/button";
+import type { ImageGenerationSettingsUpdate, SettingsPayload } from "@/lib/types";
+
+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"];
+
+export const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
+ enabled: false,
+ provider: "openrouter",
+ model: "openai/gpt-5.4-image-2",
+ defaultAspectRatio: "1:1",
+ defaultImageSize: "1K",
+ maxImagesPerTurn: 4,
+};
+
+export 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,
+ };
+}
+
+export function ImageGenerationSettings({
+ token,
+ settings,
+ form,
+ dirty,
+ saving,
+ onChangeForm,
+ onSave,
+ onOpenProviders,
+ showBrandLogos,
+ onRestart,
+ isRestarting,
+ requiresRestartPending,
+}: {
+ token: string;
+ settings: SettingsPayload;
+ form: ImageGenerationSettingsUpdate;
+ dirty: boolean;
+ saving: boolean;
+ onChangeForm: Dispatch>;
+ onSave: () => void;
+ onOpenProviders: () => void;
+ showBrandLogos: boolean;
+ onRestart?: () => void;
+ isRestarting?: boolean;
+ requiresRestartPending: boolean;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const selectedProvider =
+ settings.image_generation.providers.find((provider) => provider.name === form.provider) ??
+ settings.image_generation.providers[0];
+ const providerConfigured = !!selectedProvider?.configured;
+ const missingCredential = form.enabled && !providerConfigured;
+ const aspectOptions = optionRowsWithCurrent(
+ IMAGE_ASPECT_RATIO_OPTIONS.map((value) => ({ name: value, label: value })),
+ form.defaultAspectRatio,
+ );
+ const sizeOptions = optionRowsWithCurrent(
+ IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })),
+ form.defaultImageSize,
+ );
+ const selectProvider = (provider: string) => {
+ const nextProvider = settings.image_generation.providers.find((row) => row.name === provider);
+ onChangeForm((prev) => ({
+ ...prev,
+ provider,
+ model: nextProvider?.default_model || nextProvider?.models?.[0] || prev.model,
+ }));
+ };
+
+ return (
+
+
+ {tx("settings.sections.imageGeneration", "Image generation")}
+
+
+ onChangeForm((prev) => ({ ...prev, enabled }))}
+ ariaLabel={tx("settings.rows.imageGeneration", "Image generation")}
+ label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
+ />
+
+
+
+
+
+
+
+ {providerConfigured
+ ? tx("settings.values.configured", "Configured")
+ : tx("settings.values.notConfigured", "Not configured")}
+
+ {!providerConfigured ? (
+
+ {tx("settings.image.configureProvider", "Configure provider")}
+
+ ) : null}
+
+
+
+
+ {selectedProvider?.api_base || selectedProvider?.default_api_base || selectedProvider?.name || tx("settings.values.notAvailable", "Not available")}
+
+
+
+
+
+
+ {tx("settings.sections.imageDefaults", "Defaults")}
+
+
+ onChangeForm((prev) => ({ ...prev, model }))}
+ />
+
+
+
+ onChangeForm((prev) => ({ ...prev, defaultAspectRatio }))
+ }
+ />
+
+
+
+ onChangeForm((prev) => ({ ...prev, defaultImageSize }))
+ }
+ />
+
+
+
+ onChangeForm((prev) => ({ ...prev, maxImagesPerTurn }))
+ }
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/webui/src/components/settings/capabilities/SecuritySettings.tsx b/webui/src/components/settings/capabilities/SecuritySettings.tsx
new file mode 100644
index 000000000..242483c05
--- /dev/null
+++ b/webui/src/components/settings/capabilities/SecuritySettings.tsx
@@ -0,0 +1,131 @@
+import type { Dispatch, SetStateAction } from "react";
+import { useTranslation } from "react-i18next";
+
+import {
+ RestartSettingsFooter,
+ SettingsGroup,
+ SettingsRow,
+ SettingsSectionTitle,
+} from "@/components/settings/shared/SettingsControls";
+import { ToggleButton } from "@/components/settings/ToggleButton";
+import { SegmentedControl } from "@/components/ui/segmented-control";
+import type {
+ NetworkSafetySettingsUpdate,
+ SettingsPayload,
+ WebuiDefaultAccessMode,
+} from "@/lib/types";
+
+export const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
+ webuiAllowLocalServiceAccess: true,
+ webuiDefaultAccessMode: "default",
+};
+
+export 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,
+ ),
+ };
+}
+
+export function visibleWebuiDefaultAccessMode(mode: string | null | undefined): WebuiDefaultAccessMode {
+ return mode === "full" ? "full" : "default";
+}
+
+export function AdvancedSettings({
+ form,
+ dirty,
+ saving,
+ requiresRestartPending,
+ isNativeHostSurface,
+ onChangeForm,
+ onSave,
+ onRestart,
+ isRestarting,
+}: {
+ form: NetworkSafetySettingsUpdate;
+ dirty: boolean;
+ saving: boolean;
+ requiresRestartPending: boolean;
+ isNativeHostSurface: boolean;
+ onChangeForm: Dispatch>;
+ onSave: () => void;
+ onRestart?: () => void;
+ isRestarting?: boolean;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ return (
+
+
+
+ {isNativeHostSurface
+ ? tx("settings.sections.hostSafety", "App safety")
+ : tx("settings.sections.webuiSafety", "Web safety")}
+
+
+
+
+ onChangeForm((prev) => ({ ...prev, webuiAllowLocalServiceAccess }))
+ }
+ ariaLabel={tx("settings.rows.localServiceAccess", "Local Service Access")}
+ label={form.webuiAllowLocalServiceAccess ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
+ />
+
+
+
+ onChangeForm((prev) => ({
+ ...prev,
+ webuiDefaultAccessMode: webuiDefaultAccessMode as WebuiDefaultAccessMode,
+ }))
+ }
+ />
+
+
+
+
+
+
+ {tx(
+ "settings.help.securityManagedControls",
+ "Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
+ )}
+
+
+ );
+}
diff --git a/webui/src/components/settings/capabilities/TranscriptionSettings.tsx b/webui/src/components/settings/capabilities/TranscriptionSettings.tsx
new file mode 100644
index 000000000..23856e3ba
--- /dev/null
+++ b/webui/src/components/settings/capabilities/TranscriptionSettings.tsx
@@ -0,0 +1,176 @@
+import type { Dispatch, SetStateAction } from "react";
+import { useTranslation } from "react-i18next";
+
+import { ProviderPicker } from "@/components/settings/shared/ModelControls";
+import {
+ NumberInput,
+ RestartSettingsFooter,
+ SettingsGroup,
+ SettingsRow,
+ SettingsSectionTitle,
+ StatusPill,
+} from "@/components/settings/shared/SettingsControls";
+import { ToggleButton } from "@/components/settings/ToggleButton";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import type { SettingsPayload, TranscriptionSettingsUpdate } from "@/lib/types";
+
+export const DEFAULT_TRANSCRIPTION_FORM: TranscriptionSettingsUpdate = {
+ enabled: true,
+ provider: "groq",
+ model: "",
+ language: "",
+ maxDurationSec: 120,
+ maxUploadMb: 25,
+};
+
+export 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: [],
+};
+
+export 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,
+ };
+}
+
+export function TranscriptionSettings({
+ settings,
+ form,
+ dirty,
+ saving,
+ onChangeForm,
+ onSave,
+ onOpenProviders,
+ showBrandLogos,
+ onRestart,
+ isRestarting,
+ requiresRestartPending,
+}: {
+ settings: SettingsPayload;
+ form: TranscriptionSettingsUpdate;
+ dirty: boolean;
+ saving: boolean;
+ onChangeForm: Dispatch>;
+ onSave: () => void;
+ onOpenProviders: () => void;
+ showBrandLogos: boolean;
+ onRestart?: () => void;
+ isRestarting?: boolean;
+ requiresRestartPending: boolean;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
+ const selectedProvider =
+ transcription.providers.find((provider) => provider.name === form.provider) ??
+ transcription.providers[0];
+ const providerConfigured = !!selectedProvider?.configured;
+
+ return (
+
+ {tx("settings.sections.voiceInput", "Voice input")}
+
+
+ onChangeForm((prev) => ({ ...prev, enabled }))}
+ ariaLabel={tx("settings.rows.transcription", "Transcription")}
+ label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
+ />
+
+
+ onChangeForm((prev) => ({ ...prev, provider }))}
+ />
+
+
+
+
+ {providerConfigured
+ ? tx("settings.values.configured", "Configured")
+ : tx("settings.values.notConfigured", "Not configured")}
+
+ {!providerConfigured ? (
+
+ {tx("settings.voice.configureProvider", "Configure provider")}
+
+ ) : null}
+
+
+
+ onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
+ className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
+ />
+
+
+ onChangeForm((prev) => ({ ...prev, language: event.target.value }))}
+ placeholder={tx("settings.voice.languageAuto", "Auto")}
+ className="h-8 w-[min(180px,60vw)] rounded-full text-[13px]"
+ />
+
+
+
+ onChangeForm((prev) => ({ ...prev, maxDurationSec }))}
+ />
+ onChangeForm((prev) => ({ ...prev, maxUploadMb }))}
+ />
+
+
+
+
+
+ );
+}
diff --git a/webui/src/components/settings/capabilities/WebSettings.tsx b/webui/src/components/settings/capabilities/WebSettings.tsx
new file mode 100644
index 000000000..0e2965315
--- /dev/null
+++ b/webui/src/components/settings/capabilities/WebSettings.tsx
@@ -0,0 +1,293 @@
+import type { Dispatch, SetStateAction } from "react";
+import { Eye, EyeOff, Pencil } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { ProviderPicker } from "@/components/settings/shared/ModelControls";
+import {
+ CapabilityInstallNotice,
+ NumberInput,
+ RestartSettingsFooter,
+ SettingsGroup,
+ SettingsRow,
+ SettingsSectionTitle,
+ StatusPill,
+} from "@/components/settings/shared/SettingsControls";
+import { ToggleButton } from "@/components/settings/ToggleButton";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import type {
+ NanobotFeatureInfo,
+ SettingsPayload,
+ WebSearchSettingsUpdate,
+} from "@/lib/types";
+
+export const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
+ provider: "duckduckgo",
+ apiKey: "",
+ baseUrl: "",
+ maxResults: 5,
+ timeout: 30,
+ useJinaReader: true,
+};
+
+export 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];
+
+export function webSearchProviderAcceptsApiKey(provider?: WebSearchProviderOption): boolean {
+ return provider?.credential === "api_key" || provider?.credential === "optional_api_key";
+}
+
+export function webSearchProviderRequiresApiKey(provider?: WebSearchProviderOption): boolean {
+ return provider?.credential === "api_key";
+}
+
+export function WebSettings({
+ settings,
+ form,
+ keyVisible,
+ keyEditing,
+ saving,
+ onChangeForm,
+ onChangeProvider,
+ onToggleKey,
+ onToggleKeyEditing,
+ onReset,
+ onSave,
+ showBrandLogos,
+ onRestart,
+ isRestarting,
+ requiresRestartPending,
+ olostepFeature,
+ olostepInstalling,
+ capabilityError,
+}: {
+ settings: SettingsPayload;
+ form: WebSearchSettingsUpdate;
+ keyVisible: boolean;
+ keyEditing: boolean;
+ saving: boolean;
+ onChangeForm: Dispatch>;
+ onChangeProvider: (provider: string) => void;
+ onToggleKey: () => void;
+ onToggleKeyEditing: () => void;
+ onReset: () => void;
+ onSave: () => void;
+ showBrandLogos: boolean;
+ onRestart?: () => void;
+ isRestarting?: boolean;
+ requiresRestartPending: boolean;
+ olostepFeature?: NanobotFeatureInfo;
+ olostepInstalling: boolean;
+ capabilityError: string | null;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const selectedProvider =
+ settings.web_search.providers.find((provider) => provider.name === form.provider) ??
+ settings.web_search.providers[0];
+ const hasExistingSecret =
+ webSearchProviderAcceptsApiKey(selectedProvider) &&
+ form.provider === settings.web_search.provider &&
+ !!settings.web_search.api_key_hint;
+ const showKeyInput = webSearchProviderAcceptsApiKey(selectedProvider) && (!hasExistingSecret || keyEditing);
+ const apiKey = form.apiKey?.trim() ?? "";
+ const baseUrl = form.baseUrl?.trim() ?? "";
+ const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader;
+ const dirty =
+ form.provider !== settings.web_search.provider ||
+ apiKey.length > 0 ||
+ baseUrl !== (settings.web_search.base_url ?? "") ||
+ form.maxResults !== settings.web_search.max_results ||
+ form.timeout !== settings.web_search.timeout ||
+ effectiveJinaReader !== settings.web.fetch.use_jina_reader;
+ const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader;
+ const missingCredential =
+ webSearchProviderRequiresApiKey(selectedProvider)
+ ? !apiKey && !hasExistingSecret
+ : selectedProvider?.credential === "base_url"
+ ? !baseUrl
+ : false;
+
+ return (
+
+
+ {tx("settings.sections.webSearch", "Web search")}
+ {form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
+
+
+
+ ) : null}
+ {capabilityError ? (
+ {capabilityError}
+ ) : null}
+
+
+
+
+
+ {selectedProvider?.credential === "none" ? (
+
+ {t("settings.byok.webSearch.noCredentialRequired")}
+
+ ) : null}
+
+ {webSearchProviderAcceptsApiKey(selectedProvider) ? (
+
+
+ {showKeyInput ? (
+ <>
+
+ onChangeForm((prev) => ({ ...prev, apiKey: event.target.value }))
+ }
+ placeholder={
+ hasExistingSecret
+ ? t("settings.byok.apiKeyConfiguredPlaceholder")
+ : t("settings.byok.apiKeyPlaceholder")
+ }
+ className="h-9 rounded-full pr-11 text-[13px]"
+ />
+
+ {keyVisible ? (
+
+ ) : (
+
+ )}
+
+ >
+ ) : (
+ <>
+
+ {settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")}
+
+
+
+
+ >
+ )}
+
+
+ ) : null}
+
+ {selectedProvider?.credential === "base_url" ? (
+
+
+ onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value }))
+ }
+ placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")}
+ className="h-9 w-[280px] rounded-full text-[13px]"
+ />
+
+ ) : null}
+
+
+
+
+ {tx("settings.sections.webBehavior", "Behavior")}
+
+
+ onChangeForm((prev) => ({ ...prev, maxResults }))}
+ />
+
+
+ onChangeForm((prev) => ({ ...prev, timeout }))}
+ suffix="s"
+ />
+
+
+ onChangeForm((prev) => ({ ...prev, useJinaReader }))}
+ ariaLabel={tx("settings.rows.jinaReader", "Jina reader")}
+ label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
+ />
+
+
+
+
+
+ );
+}
diff --git a/webui/src/components/settings/capabilities/useCapabilitySettingsActions.ts b/webui/src/components/settings/capabilities/useCapabilitySettingsActions.ts
new file mode 100644
index 000000000..0d9f04774
--- /dev/null
+++ b/webui/src/components/settings/capabilities/useCapabilitySettingsActions.ts
@@ -0,0 +1,224 @@
+import { useCallback, type Dispatch, type SetStateAction } from "react";
+import type { TFunction } from "i18next";
+
+import {
+ webSearchProviderAcceptsApiKey,
+ webSearchProviderRequiresApiKey,
+} from "@/components/settings/capabilities/WebSettings";
+import type { CapabilitySettingsState } from "@/components/settings/capabilities/useCapabilitySettingsState";
+import type {
+ ApplySettingsPayload,
+ MaybeRestartHostEngine,
+ PendingRestartSections,
+} from "@/components/settings/contracts";
+import {
+ updateImageGenerationSettings,
+ updateNetworkSafetySettings,
+ updateTranscriptionSettings,
+ updateWebSearchSettings,
+} from "@/lib/api";
+import type { NanobotClient } from "@/lib/nanobot-client";
+import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
+
+interface CapabilitySettingsActionsOptions {
+ state: CapabilitySettingsState;
+ settings: SettingsPayload | null;
+ client: NanobotClient;
+ t: TFunction;
+ applyPayload: ApplySettingsPayload;
+ maybeRestartHostEngine: MaybeRestartHostEngine;
+ setPendingRestartSections: Dispatch>;
+ setError: Dispatch>;
+ installCapabilities: (names: string[]) => Promise;
+ imageGenerationDirty: boolean;
+ transcriptionDirty: boolean;
+ networkSafetyDirty: boolean;
+}
+
+export function useCapabilitySettingsActions({
+ state,
+ settings,
+ client,
+ t,
+ applyPayload,
+ maybeRestartHostEngine,
+ setPendingRestartSections,
+ setError,
+ installCapabilities,
+ imageGenerationDirty,
+ transcriptionDirty,
+ networkSafetyDirty,
+}: CapabilitySettingsActionsOptions) {
+ const {
+ imageGenerationForm,
+ imageGenerationSaving,
+ networkSafetyForm,
+ networkSafetySaving,
+ setImageGenerationSaving,
+ setNetworkSafetySaving,
+ setTranscriptionSaving,
+ setWebSearchForm,
+ setWebSearchKeyEditing,
+ setWebSearchKeyVisible,
+ setWebSearchSaving,
+ transcriptionForm,
+ transcriptionSaving,
+ webSearchForm,
+ webSearchKeyEditing,
+ webSearchSaving,
+ } = state;
+
+ 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 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 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]);
+
+ return {
+ handleWebSearchProviderChange,
+ resetWebSearchDraft,
+ saveImageGenerationSettings,
+ saveNetworkSafetySettings,
+ saveTranscriptionSettings,
+ saveWebSearch,
+ };
+}
diff --git a/webui/src/components/settings/capabilities/useCapabilitySettingsState.ts b/webui/src/components/settings/capabilities/useCapabilitySettingsState.ts
new file mode 100644
index 000000000..0710a19f7
--- /dev/null
+++ b/webui/src/components/settings/capabilities/useCapabilitySettingsState.ts
@@ -0,0 +1,73 @@
+import { useState } from "react";
+
+import {
+ DEFAULT_IMAGE_GENERATION_FORM,
+ imageGenerationFormFromPayload,
+} from "@/components/settings/capabilities/ImageGenerationSettings";
+import {
+ DEFAULT_NETWORK_SAFETY_FORM,
+ networkSafetyFormFromPayload,
+} from "@/components/settings/capabilities/SecuritySettings";
+import {
+ DEFAULT_TRANSCRIPTION_FORM,
+ transcriptionFormFromPayload,
+} from "@/components/settings/capabilities/TranscriptionSettings";
+import {
+ DEFAULT_WEB_SEARCH_FORM,
+ webSearchFormFromPayload,
+} from "@/components/settings/capabilities/WebSettings";
+import type {
+ ImageGenerationSettingsUpdate,
+ NetworkSafetySettingsUpdate,
+ SettingsPayload,
+ TranscriptionSettingsUpdate,
+ WebSearchSettingsUpdate,
+} from "@/lib/types";
+
+export function useCapabilitySettingsState(initialSettings: SettingsPayload | null) {
+ const [webSearchSaving, setWebSearchSaving] = useState(false);
+ const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
+ const [transcriptionSaving, setTranscriptionSaving] = useState(false);
+ const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
+ 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,
+ );
+ const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
+ const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
+
+ return {
+ imageGenerationForm,
+ imageGenerationSaving,
+ networkSafetyForm,
+ networkSafetySaving,
+ setImageGenerationForm,
+ setImageGenerationSaving,
+ setNetworkSafetyForm,
+ setNetworkSafetySaving,
+ setTranscriptionForm,
+ setTranscriptionSaving,
+ setWebSearchForm,
+ setWebSearchKeyEditing,
+ setWebSearchKeyVisible,
+ setWebSearchSaving,
+ transcriptionForm,
+ transcriptionSaving,
+ webSearchForm,
+ webSearchKeyEditing,
+ webSearchKeyVisible,
+ webSearchSaving,
+ };
+}
+
+export type CapabilitySettingsState = ReturnType;
diff --git a/webui/src/components/settings/contracts.ts b/webui/src/components/settings/contracts.ts
new file mode 100644
index 000000000..b5380545b
--- /dev/null
+++ b/webui/src/components/settings/contracts.ts
@@ -0,0 +1,32 @@
+import type { SettingsPayload } from "@/lib/types";
+
+export type SettingsSectionKey =
+ | "overview"
+ | "appearance"
+ | "models"
+ | "image"
+ | "voice"
+ | "browser"
+ | "channels"
+ | "apps"
+ | "automations"
+ | "skills"
+ | "runtime"
+ | "advanced";
+
+export type PendingRestartSection = "runtime" | "browser" | "image";
+export type PendingRestartSections = Record;
+
+export type RestartAwarePayload = {
+ requires_restart?: boolean;
+ surface?: SettingsPayload["surface"];
+ runtime_surface?: SettingsPayload["runtime_surface"];
+ runtime_capabilities?: SettingsPayload["runtime_capabilities"];
+};
+
+export type ApplySettingsPayload = (
+ payload: SettingsPayload,
+ options?: { preserveAgentForm?: boolean },
+) => void;
+
+export type MaybeRestartHostEngine = (payload: RestartAwarePayload) => Promise;
diff --git a/webui/src/components/settings/models/ModelsSettings.tsx b/webui/src/components/settings/models/ModelsSettings.tsx
new file mode 100644
index 000000000..1ccb5c8e0
--- /dev/null
+++ b/webui/src/components/settings/models/ModelsSettings.tsx
@@ -0,0 +1,923 @@
+import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
+import {
+ ChevronDown,
+ ChevronRight,
+ GripVertical,
+ ListOrdered,
+ Loader2,
+ Plus,
+ Trash2,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import {
+ ModelIdPicker,
+ ProviderPicker,
+ ProviderPickerIcon,
+ formatContextWindow,
+ formatModelContextWindow,
+ normalizeContextWindowTokens,
+ settingsProviderConfigured,
+} from "@/components/settings/shared/ModelControls";
+import {
+ SettingsGroup,
+ SettingsRow,
+ SettingsSectionTitle,
+ SettingsStatusMessage,
+ StatusPill,
+} from "@/components/settings/shared/SettingsControls";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { SegmentedControl } from "@/components/ui/segmented-control";
+import { cn } from "@/lib/utils";
+import type { SettingsPayload } from "@/lib/types";
+
+export interface AgentSettingsDraft {
+ model: string;
+ provider: string;
+ modelPreset: string;
+ presetLabel: string;
+ maxTokens: number;
+ contextWindowTokens: number;
+ temperature: number;
+ reasoningEffort: string;
+ timezone: string;
+ toolHintMaxLength: number;
+}
+
+const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const;
+
+function modelPresetValue(payload: SettingsPayload): string {
+ return (
+ payload.model_call_order?.[0] ??
+ payload.model_presets.find((preset) => !preset.is_default)?.name ??
+ ""
+ );
+}
+
+export const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
+ model: "",
+ provider: "",
+ modelPreset: "",
+ presetLabel: "",
+ maxTokens: 8192,
+ contextWindowTokens: 200_000,
+ temperature: 0.1,
+ reasoningEffort: "",
+ timezone: "UTC",
+ toolHintMaxLength: 40,
+};
+
+export 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,
+ };
+}
+
+export function ModelPresetDeleteDialog({
+ preset,
+ deleting,
+ onOpenChange,
+ onConfirm,
+}: {
+ preset: SettingsPayload["model_presets"][number] | null;
+ deleting: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string, values?: Record) =>
+ t(key, { defaultValue: fallback, ...(values ?? {}) });
+ return (
+
+
+
+
+ {tx("settings.models.deletePresetTitle", "Delete model preset?")}
+
+
+ {tx(
+ "settings.models.deletePresetHelp",
+ "This removes the preset “{{name}}”. Provider credentials are not affected.",
+ { name: preset?.label ?? "" },
+ )}
+
+
+
+ onOpenChange(false)}
+ >
+ {tx("settings.actions.cancel", "Cancel")}
+
+
+ {deleting ? (
+
+ ) : null}
+ {deleting
+ ? tx("settings.actions.deleting", "Deleting...")
+ : tx("settings.actions.delete", "Delete")}
+
+
+
+
+ );
+}
+
+export function ModelsSettings({
+ token,
+ form,
+ setForm,
+ settings,
+ dirty,
+ creating,
+ creatingSaving,
+ callOrder,
+ saving,
+ orderSaving,
+ migrationSaving,
+ showBrandLogos,
+ providerSaving,
+ onChangeCallOrder,
+ onProviderOAuthLogin,
+ onSave,
+ onMigrate,
+ onBeginCreate,
+ onCancelCreate,
+ onSelectConfiguration,
+ onDeleteConfiguration,
+}: {
+ token: string;
+ form: AgentSettingsDraft;
+ setForm: Dispatch>;
+ settings: SettingsPayload;
+ dirty: boolean;
+ creating: boolean;
+ creatingSaving: boolean;
+ callOrder: string[];
+ saving: boolean;
+ orderSaving: boolean;
+ migrationSaving: boolean;
+ showBrandLogos: boolean;
+ providerSaving: string | null;
+ onChangeCallOrder: (order: string[]) => void;
+ onProviderOAuthLogin: (provider: string) => void;
+ onSave: () => void;
+ onMigrate: () => void;
+ onBeginCreate: () => void;
+ onCancelCreate: () => void;
+ onSelectConfiguration: () => void;
+ onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string, values?: Record) =>
+ t(key, { defaultValue: fallback, ...(values ?? {}) });
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [editorRowKey, setEditorRowKey] = useState(null);
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState(null);
+ const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState(null);
+ const namedPresets = settings.model_presets.filter((preset) => !preset.is_default);
+ const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
+ const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
+ const callOrderOccurrences = new Map();
+ const presetRows = [
+ ...callOrder.map((name, orderIndex) => {
+ const occurrence = callOrderOccurrences.get(name) ?? 0;
+ callOrderOccurrences.set(name, occurrence + 1);
+ return {
+ key: `ordered:${name}:${occurrence}`,
+ name,
+ orderIndex,
+ preset: namedPresetsByName.get(name),
+ };
+ }),
+ ...unorderedPresets.map((preset) => ({
+ key: `disabled:${preset.name}`,
+ name: preset.name,
+ orderIndex: -1,
+ preset,
+ })),
+ ];
+ const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null;
+ const activeEditorRowKey =
+ editorRowKey ??
+ presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
+ null;
+ useEffect(() => {
+ setAdvancedOpen(false);
+ }, [editorOpen, selectedPreset?.name]);
+
+ const configuredProviders = settings.providers.filter((provider) => provider.configured);
+ const selectedProvider = settings.providers.find((provider) => provider.name === form.provider);
+ const selectableProviders = uniqueProviders([
+ ...configuredProviders,
+ ...(selectedProvider ? [selectedProvider] : []),
+ ]);
+ const showAutoProvider = selectedPreset?.provider === "auto" || form.provider === "auto";
+ const providerOptions = showAutoProvider
+ ? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
+ : selectableProviders;
+ const providerValue = providerOptions.some((provider) => provider.name === form.provider)
+ ? form.provider
+ : "";
+ const selectedProviderNeedsSignIn =
+ selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
+ const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
+ const selectedProviderConfigured = settingsProviderConfigured(
+ settings,
+ form.provider,
+ selectedPreset?.resolved_provider,
+ );
+ const modelFieldsMissing =
+ !form.model.trim() ||
+ !form.provider.trim() ||
+ !form.presetLabel.trim() ||
+ form.maxTokens <= 0 ||
+ form.temperature < 0 ||
+ form.temperature > 2;
+ const selectedPresetReferenced = Boolean(
+ selectedPreset && callOrder.includes(selectedPreset.name),
+ );
+ const callOrderBusy = orderSaving || saving;
+ const selectPreset = (
+ preset: SettingsPayload["model_presets"][number],
+ rowKey: string,
+ ) => {
+ const toggleCurrentPreset =
+ !creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
+ onSelectConfiguration();
+ if (toggleCurrentPreset) {
+ setEditorOpen((open) => !open);
+ return;
+ }
+ setForm((prev) => ({
+ ...prev,
+ modelPreset: preset.name,
+ model: preset.model,
+ provider: preset.provider,
+ presetLabel: preset.label,
+ maxTokens: preset.max_tokens,
+ contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
+ temperature: preset.temperature,
+ reasoningEffort: preset.reasoning_effort ?? "",
+ }));
+ setEditorRowKey(rowKey);
+ setEditorOpen(true);
+ };
+
+ const moveCallOrderItem = (index: number, offset: -1 | 1) => {
+ if (callOrderBusy) return;
+ const nextIndex = index + offset;
+ if (nextIndex < 0 || nextIndex >= callOrder.length) return;
+ const next = [...callOrder];
+ [next[index], next[nextIndex]] = [next[nextIndex], next[index]];
+ onChangeCallOrder(next);
+ };
+
+ const removeCallOrderItem = (index: number) => {
+ if (callOrderBusy || callOrder.length <= 1) return;
+ onChangeCallOrder(callOrder.filter((_, itemIndex) => itemIndex !== index));
+ };
+
+ const dropCallOrderItem = (targetIndex: number) => {
+ if (
+ callOrderBusy ||
+ draggedCallOrderIndex === null ||
+ draggedCallOrderIndex === targetIndex
+ ) {
+ setDraggedCallOrderIndex(null);
+ setDragOverCallOrderIndex(null);
+ return;
+ }
+ const next = [...callOrder];
+ const moved = next.splice(draggedCallOrderIndex, 1)[0];
+ if (!moved) {
+ setDraggedCallOrderIndex(null);
+ setDragOverCallOrderIndex(null);
+ return;
+ }
+ next.splice(targetIndex, 0, moved);
+ setDraggedCallOrderIndex(null);
+ setDragOverCallOrderIndex(null);
+ onChangeCallOrder(next);
+ };
+
+ const renderPresetEditor = () => (
+
+ {creating ? (
+
+
+ {tx("settings.models.newPreset", "New model preset")}
+
+
+ ) : null}
+
+
+ setForm((prev) => ({ ...prev, presetLabel: event.target.value }))
+ }
+ className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
+ />
+
+
+
+ setForm((prev) => ({
+ ...prev,
+ provider,
+ model: provider === prev.provider ? prev.model : "",
+ }))
+ }
+ />
+
+ {selectedProviderNeedsSignIn ? (
+
+ selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
+ disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
+ className="rounded-full"
+ >
+ {selectedProviderSigningIn ? (
+
+ ) : null}
+ {selectedProviderSigningIn
+ ? tx("settings.oauth.signingIn", "Signing in...")
+ : tx("settings.oauth.signIn", "Sign in")}
+
+
+ ) : null}
+
+ setForm((prev) => ({ ...prev, model }))}
+ />
+
+
setAdvancedOpen((value) => !value)}
+ className="flex min-h-[62px] w-full items-center justify-between gap-4 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
+ >
+
+
+ {tx("settings.models.advancedOptions", "Advanced options")}
+
+
+ {tx(
+ "settings.models.advancedSummary",
+ "Context {{context}} · Max {{max}} tokens",
+ {
+ context: formatModelContextWindow(form.contextWindowTokens),
+ max: formatContextWindow(form.maxTokens),
+ },
+ )}
+
+
+
+
+ {advancedOpen ? (
+
+ setForm((prev) => ({ ...prev, ...value }))}
+ />
+
+ ) : null}
+
+ {creating ? (
+
{
+ setEditorOpen(false);
+ onCancelCreate();
+ }}
+ >
+ {tx("settings.actions.cancel", "Cancel")}
+
+ ) : selectedPreset ? (
+
+ onDeleteConfiguration(selectedPreset)}
+ >
+
+ {tx("settings.actions.delete", "Delete")}
+
+ {selectedPresetReferenced ? (
+
+ {tx(
+ "settings.models.removeBeforeDelete",
+ "Remove this preset from the call order before deleting it.",
+ )}
+
+ ) : null}
+
+ ) : null}
+
+
+ {saving || creatingSaving
+ ? tx("settings.actions.saving", "Saving...")
+ : tx("settings.actions.savePreset", "Save preset")}
+
+
+
+
+ );
+
+ return (
+
+
+
+ {tx("settings.models.presets", "Model presets")}
+
+
+ {!settings.model_call_order_editable ? (
+
+
+
+
+
+
+
+ {tx("settings.models.convertTitle", "Convert the current model setup")}
+
+
+ {tx(
+ "settings.models.convertHelp",
+ "Turn the existing primary and fallback models into presets so their order can be managed here.",
+ )}
+
+
+
+
+ {migrationSaving ? (
+
+ ) : null}
+ {migrationSaving
+ ? tx("settings.models.converting", "Converting...")
+ : tx("settings.models.convertAction", "Convert to presets")}
+
+
+ ) : (
+ <>
+
+ {presetRows.map(({ key, name, orderIndex, preset }) => {
+ const ordered = orderIndex >= 0;
+ const provider = preset
+ ? modelPresetProviderKey(preset, settings)
+ : settings.agent.resolved_provider ?? settings.agent.provider;
+ const presetConfigured = preset
+ ? settingsProviderConfigured(
+ settings,
+ preset.provider,
+ preset.resolved_provider,
+ )
+ : true;
+ const isDropTarget =
+ ordered &&
+ dragOverCallOrderIndex === orderIndex &&
+ draggedCallOrderIndex !== orderIndex;
+ const dropAfterTarget =
+ isDropTarget &&
+ draggedCallOrderIndex !== null &&
+ draggedCallOrderIndex < orderIndex;
+ const isSelected =
+ editorOpen &&
+ !creating &&
+ activeEditorRowKey === key &&
+ selectedPreset?.name === name;
+ const presetRow = (
+
{
+ if (!ordered || callOrderBusy) {
+ event.preventDefault();
+ return;
+ }
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("text/plain", name);
+ setDraggedCallOrderIndex(orderIndex);
+ setDragOverCallOrderIndex(orderIndex);
+ }}
+ onDragEnd={() => {
+ setDraggedCallOrderIndex(null);
+ setDragOverCallOrderIndex(null);
+ }}
+ onDragEnter={(event) => {
+ if (ordered && draggedCallOrderIndex !== null) {
+ event.preventDefault();
+ setDragOverCallOrderIndex(orderIndex);
+ }
+ }}
+ onDragOver={(event) => {
+ if (!ordered || draggedCallOrderIndex === null) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ }}
+ onDrop={(event) => {
+ if (!ordered) return;
+ event.preventDefault();
+ dropCallOrderItem(orderIndex);
+ }}
+ onKeyDown={(event) => {
+ if (event.currentTarget !== event.target) return;
+ if (ordered && event.key === "ArrowUp") {
+ event.preventDefault();
+ moveCallOrderItem(orderIndex, -1);
+ } else if (ordered && event.key === "ArrowDown") {
+ event.preventDefault();
+ moveCallOrderItem(orderIndex, 1);
+ } else if ((event.key === "Enter" || event.key === " ") && preset) {
+ event.preventDefault();
+ selectPreset(preset, key);
+ }
+ }}
+ className={cn(
+ "group relative flex min-h-[76px] select-none items-center gap-3 px-4 py-3 outline-none transition-[background-color,opacity] duration-150 sm:px-5",
+ ordered &&
+ (callOrderBusy
+ ? "cursor-wait"
+ : "cursor-grab active:cursor-grabbing"),
+ "hover:bg-muted/25",
+ isDropTarget &&
+ !dropAfterTarget &&
+ "before:absolute before:inset-x-4 before:top-0 before:z-10 before:h-0.5 before:rounded-full before:bg-foreground sm:before:inset-x-5",
+ isDropTarget &&
+ dropAfterTarget &&
+ "after:absolute after:inset-x-4 after:bottom-0 after:z-10 after:h-0.5 after:rounded-full after:bg-foreground sm:after:inset-x-5",
+ ordered && draggedCallOrderIndex === orderIndex && "opacity-35",
+ isSelected && "bg-muted/45 hover:bg-muted/45",
+ "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
+ )}
+ >
+ {ordered ? (
+
+ ) : (
+
+ )}
+
preset && selectPreset(preset, key)}
+ className="flex min-w-0 flex-1 items-center gap-3 rounded-[12px] text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ >
+ {ordered ? (
+
+ {orderIndex + 1}
+
+ ) : (
+
+ )}
+
+
+
+
+ {preset?.label ?? name}
+
+ {orderIndex === 0 ? (
+
+ {tx("settings.models.primary", "Primary")}
+
+ ) : !ordered ? (
+
+ {tx("settings.models.disabled", "Disabled")}
+
+ ) : null}
+ {!presetConfigured ? (
+
+ {tx(
+ "settings.models.providerSetupRequired",
+ "Provider setup required",
+ )}
+
+ ) : null}
+
+
+ {preset?.model ?? name}
+
+
+
+
+
{
+ if (ordered) {
+ removeCallOrderItem(orderIndex);
+ } else if (preset) {
+ onChangeCallOrder([...callOrder, preset.name]);
+ }
+ }}
+ className={cn(
+ "relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40",
+ ordered ? "bg-foreground" : "bg-muted-foreground/25",
+ )}
+ >
+
+
+
+ );
+ return (
+
+ {presetRow}
+ {isSelected ? renderPresetEditor() : null}
+
+ );
+ })}
+
+
+ {!creating ? (
+
{
+ setEditorRowKey(null);
+ setEditorOpen(true);
+ onBeginCreate();
+ }}
+ >
+
+ {tx("settings.models.newPreset", "New model preset")}
+
+ ) : (
+
+ )}
+ {orderSaving ? (
+
+
+
+ {tx("settings.actions.saving", "Saving...")}
+
+
+ ) : null}
+
+ {creating && editorOpen ? renderPresetEditor() : null}
+ >
+ )}
+
+
+
+ );
+}
+
+function ModelAdvancedFields({
+ maxTokens,
+ contextWindowTokens,
+ temperature,
+ reasoningEffort,
+ onChange,
+}: {
+ maxTokens: number;
+ contextWindowTokens: number;
+ temperature: number;
+ reasoningEffort: string;
+ onChange: (
+ value: Partial<
+ Pick<
+ AgentSettingsDraft,
+ "maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
+ >
+ >,
+ ) => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const contextWindowOptions = Array.from(
+ new Set([...CONTEXT_WINDOW_TOKEN_OPTIONS, contextWindowTokens]),
+ ).sort((left, right) => left - right);
+ return (
+
+ );
+}
+
+function uniqueProviders(
+ providers: SettingsPayload["providers"],
+): SettingsPayload["providers"] {
+ const seen = new Set();
+ return providers.filter((provider) => {
+ if (seen.has(provider.name)) return false;
+ seen.add(provider.name);
+ return true;
+ });
+}
+
+function modelPresetProviderKey(
+ preset: SettingsPayload["model_presets"][number],
+ settings: SettingsPayload,
+ options: { draftProvider?: string } = {},
+): string {
+ const provider = options.draftProvider ?? preset.provider;
+ if (provider === "auto") {
+ return (
+ preset.resolved_provider ||
+ settings.agent.resolved_provider ||
+ settings.agent.provider ||
+ preset.provider
+ );
+ }
+ return provider;
+}
diff --git a/webui/src/components/settings/models/ProviderSettings.tsx b/webui/src/components/settings/models/ProviderSettings.tsx
new file mode 100644
index 000000000..fb83f707d
--- /dev/null
+++ b/webui/src/components/settings/models/ProviderSettings.tsx
@@ -0,0 +1,1368 @@
+import { useMemo, useState, type ReactNode } from "react";
+import {
+ ChevronDown,
+ Clipboard,
+ Eye,
+ EyeOff,
+ ExternalLink,
+ Globe2,
+ Hexagon,
+ Loader2,
+ Pencil,
+ Plus,
+ RotateCcw,
+ Zap,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { PROVIDER_ICONS } from "@/components/settings/shared/ModelControls";
+import {
+ CapabilityInstallNotice,
+ SettingsGroup,
+ SettingsSectionTitle,
+} from "@/components/settings/shared/SettingsControls";
+import { ToggleButton } from "@/components/settings/ToggleButton";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { useLogoFallback } from "@/hooks/useLogoFallback";
+import { providerBrand } from "@/lib/provider-brand";
+import { cn } from "@/lib/utils";
+import type {
+ NanobotFeaturesPayload,
+ ProviderOAuthAuthorizationRequired,
+ SettingsPayload,
+} from "@/lib/types";
+
+type ProviderApiType = "auto" | "chat_completions" | "responses";
+type ProviderAdvancedField = NonNullable<
+ SettingsPayload["providers"][number]["advanced_fields"]
+>[number];
+export type ProviderForm = {
+ displayName: string;
+ apiKey: string;
+ apiBase: string;
+ apiType: ProviderApiType;
+ proxy: string;
+ extraHeaders: string;
+ extraBody: string;
+ extraQuery: string;
+ thinkingStyle: string;
+ region: string;
+ profile: string;
+};
+export type CustomProviderDraft = ProviderForm & { name: string };
+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,
+ }],
+};
+export const CUSTOM_PROVIDER_CREATION_KEY = "__custom_provider__";
+const CUSTOM_PROVIDER_ADVANCED_FIELDS: ProviderAdvancedField[] = [
+ "extra_headers",
+ "extra_body",
+ "extra_query",
+ "proxy",
+ "thinking_style",
+];
+
+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 }
+ : {}),
+ };
+}
+
+export 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 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,
+ ]),
+);
+
+export 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")}
+
+
+
+
+ {inputLabel}
+
+ {expectsCallbackUrl ? (
+ onAuthorizationResponseChange(event.target.value)}
+ placeholder={t("settings.oauth.callbackUrlPlaceholder")}
+ aria-label={inputLabel}
+ autoComplete="off"
+ spellCheck={false}
+ className="min-h-[88px] resize-none break-all font-mono text-[12px] leading-5"
+ />
+ ) : (
+ onAuthorizationResponseChange(event.target.value)}
+ placeholder={inputLabel}
+ aria-label={inputLabel}
+ autoComplete="off"
+ spellCheck={false}
+ />
+ )}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ {expectsCallbackUrl
+ ? t("settings.oauth.openChatGPT")
+ : t("settings.oauth.signIn")}
+
+
+ {completing ? t("settings.oauth.signingIn") : t("settings.oauth.finishSignIn")}
+
+
+
+
+
+ );
+}
+
+function ProviderRequestOptions({
+ providerName,
+ form,
+ onChange,
+}: {
+ providerName: string;
+ form: ProviderForm;
+ onChange: (value: Partial) => void;
+}) {
+ const { t } = useTranslation();
+ const options = PROVIDER_REQUEST_OPTIONS[providerName] ?? [];
+ if (options.length === 0) return null;
+ const extraBody = parseProviderExtraBody(form.extraBody) ?? {};
+
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+
+ return (
+
+ {options.map((option, index) => {
+ const title = tx(option.titleKey, option.title);
+ const Icon = option.kind === "priority" ? Zap : Globe2;
+ const checked = providerRequestOptionEnabled(option, extraBody);
+ return (
+
0 && "border-t border-border/45",
+ )}
+ >
+
+
+
+
+
+
{title}
+
+ {tx(option.helpKey, option.help)}
+
+
+
+
onChange(
+ updateProviderRequestOption(option, enabled, form),
+ )}
+ ariaLabel={title}
+ label={checked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
+ />
+
+ );
+ })}
+
+ );
+}
+
+function ProviderAdvancedOptions({
+ fields,
+ form,
+ onChange,
+ footer,
+}: {
+ fields: ProviderAdvancedField[];
+ form: ProviderForm;
+ onChange: (value: Partial) => void;
+ footer?: ReactNode;
+}) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const enabled = new Set(fields);
+ if (enabled.size === 0) return null;
+
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const thinkingStyleOptions = [
+ { value: "", label: tx("settings.values.default", "Default") },
+ { value: "thinking_type", label: "thinking_type" },
+ { value: "enable_thinking", label: "enable_thinking" },
+ { value: "reasoning_split", label: "reasoning_split" },
+ ];
+
+ return (
+
+
setOpen((value) => !value)}
+ className="flex min-h-[48px] w-full items-center justify-between gap-4 px-1 py-2.5 text-left transition-colors hover:text-foreground"
+ >
+
+ {tx("settings.providers.advancedOptions", "Advanced options")}
+
+
+
+ {open ? (
+
+
+ {enabled.has("api_type") ? (
+
+
+ {tx("settings.providers.apiType", "API type")}
+
+
+
+
+
+ {OPENAI_API_TYPE_OPTIONS.find(
+ (option) => option.value === form.apiType,
+ )?.label ?? form.apiType}
+
+
+
+
+
+ {OPENAI_API_TYPE_OPTIONS.map((option) => (
+ onChange({ apiType: option.value })}
+ >
+ {option.label}
+
+ ))}
+
+
+
+ ) : null}
+ {enabled.has("thinking_style") ? (
+
+
+ {tx("settings.providers.thinkingStyle", "Thinking style")}
+
+
+
+
+
+ {thinkingStyleOptions.find(
+ (option) => option.value === form.thinkingStyle,
+ )?.label ?? form.thinkingStyle}
+
+
+
+
+
+ {thinkingStyleOptions.map((option) => (
+ onChange({ thinkingStyle: option.value })}
+ className="font-mono text-[12px]"
+ >
+ {option.label}
+
+ ))}
+
+
+
+ ) : null}
+ {enabled.has("proxy") ? (
+
+
+ {tx("settings.providers.proxy", "Network proxy")}
+
+ onChange({ proxy: event.target.value })}
+ placeholder="http://127.0.0.1:7890"
+ autoCapitalize="none"
+ autoComplete="off"
+ autoCorrect="off"
+ spellCheck={false}
+ className="h-9 rounded-full font-mono text-[12px]"
+ />
+
+ ) : null}
+ {enabled.has("region") ? (
+
+
+ {tx("settings.providers.region", "Region")}
+
+ onChange({ region: event.target.value })}
+ placeholder="us-east-1"
+ autoCapitalize="none"
+ autoComplete="off"
+ autoCorrect="off"
+ spellCheck={false}
+ className="h-9 rounded-full font-mono text-[12px]"
+ />
+
+ ) : null}
+ {enabled.has("profile") ? (
+
+
+ {tx("settings.providers.profile", "Profile")}
+
+ onChange({ profile: event.target.value })}
+ placeholder="default"
+ autoCapitalize="none"
+ autoComplete="off"
+ autoCorrect="off"
+ spellCheck={false}
+ className="h-9 rounded-full font-mono text-[12px]"
+ />
+
+ ) : null}
+ {enabled.has("extra_headers") ? (
+
+
+ {tx("settings.providers.extraHeaders", "Extra headers")}
+
+ onChange({ extraHeaders: event.target.value })}
+ placeholder={'{"X-Header":"value"}'}
+ spellCheck={false}
+ className="min-h-[88px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
+ />
+
+ ) : null}
+ {enabled.has("extra_query") ? (
+
+
+ {tx("settings.providers.extraQuery", "Extra query")}
+
+ onChange({ extraQuery: event.target.value })}
+ placeholder={'{"api-version":"2024-02-01"}'}
+ spellCheck={false}
+ className="min-h-[88px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
+ />
+
+ ) : null}
+ {enabled.has("extra_body") ? (
+
+
+ {tx("settings.providers.extraBody", "Extra body")}
+
+ onChange({ extraBody: event.target.value })}
+ placeholder={'{"service_tier":"priority"}'}
+ spellCheck={false}
+ className="min-h-[96px] resize-y rounded-[14px] bg-background font-mono text-[12px]"
+ />
+
+ ) : null}
+
+
+ ) : null}
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
+ );
+}
+
+export function ProvidersSettings({
+ settings,
+ nanobotFeatures,
+ featureAction,
+ capabilityError,
+ expandedProvider,
+ providerForms,
+ visibleProviderKeys,
+ editingProviderKeys,
+ providerSaving,
+ showBrandLogos,
+ remoteBrowserAccess,
+ onToggleProvider,
+ onToggleProviderKey,
+ onToggleProviderKeyEditing,
+ onChangeProviderForm,
+ onSaveProvider,
+ onCreateCustomProvider,
+ onProviderOAuthLogin,
+ onProviderOAuthLogout,
+ imageProviderRestartPending,
+ onRestart,
+ isRestarting,
+}: {
+ settings: SettingsPayload;
+ nanobotFeatures: NanobotFeaturesPayload | null;
+ featureAction: string | null;
+ capabilityError: string | null;
+ expandedProvider: string | null;
+ providerForms: Record;
+ visibleProviderKeys: Record;
+ editingProviderKeys: Record;
+ providerSaving: string | null;
+ showBrandLogos: boolean;
+ remoteBrowserAccess: boolean;
+ onToggleProvider: (provider: string) => void;
+ onToggleProviderKey: (provider: string) => void;
+ onToggleProviderKeyEditing: (provider: string) => void;
+ onChangeProviderForm: (provider: string, value: Partial) => void;
+ onSaveProvider: (provider: string) => void;
+ onCreateCustomProvider: (draft: CustomProviderDraft) => Promise;
+ onProviderOAuthLogin: (provider: string) => void;
+ onProviderOAuthLogout: (provider: string) => void;
+ imageProviderRestartPending: boolean;
+ onRestart?: () => void;
+ isRestarting?: boolean;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const [creatingCustomProvider, setCreatingCustomProvider] = useState(false);
+ const [customProviderKeyVisible, setCustomProviderKeyVisible] = useState(false);
+ const [customProviderDraft, setCustomProviderDraft] = useState(
+ emptyCustomProviderDraft,
+ );
+ const configuredProviders = settings.providers.filter((provider) => provider.configured);
+ const unconfiguredProviders = useMemo(
+ () =>
+ orderUnconfiguredProviders(
+ settings.providers.filter(
+ (provider) => !provider.configured && provider.name !== "custom",
+ ),
+ ),
+ [settings.providers],
+ );
+ const selectedUnconfiguredProvider =
+ unconfiguredProviders.find((provider) => provider.name === expandedProvider) ?? null;
+ const customProviderSaving = providerSaving === CUSTOM_PROVIDER_CREATION_KEY;
+ const toggleProvider = (providerName: string) => {
+ setCreatingCustomProvider(false);
+ onToggleProvider(providerName);
+ };
+ const beginCustomProviderCreation = () => {
+ if (expandedProvider) onToggleProvider(expandedProvider);
+ setCustomProviderDraft(emptyCustomProviderDraft());
+ setCustomProviderKeyVisible(false);
+ setCreatingCustomProvider(true);
+ };
+ const cancelCustomProviderCreation = () => {
+ setCreatingCustomProvider(false);
+ setCustomProviderDraft(emptyCustomProviderDraft());
+ setCustomProviderKeyVisible(false);
+ };
+ const saveCustomProvider = async () => {
+ if (customProviderSaving) return;
+ if (await onCreateCustomProvider(customProviderDraft)) {
+ cancelCustomProviderCreation();
+ }
+ };
+ const renderProviderRow = (provider: SettingsPayload["providers"][number]) => {
+ const expanded = expandedProvider === provider.name;
+ const form = providerForms[provider.name] ?? providerFormFromRow(provider);
+ const saving = providerSaving === provider.name;
+ const isOauthProvider = provider.auth_type === "oauth";
+ const supportsOauthAdvancedSettings =
+ isOauthProvider && OAUTH_PROXY_PROVIDERS.has(provider.name);
+ const keyVisible = !!visibleProviderKeys[provider.name];
+ const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
+ const apiKeyRequired = provider.api_key_required ?? true;
+ const apiKey = form.apiKey.trim();
+ const apiBase = form.apiBase.trim();
+ const advancedFields = provider.advanced_fields ?? [];
+ const oauthSettingsDirty = isOauthProvider && (
+ form.proxy.trim() !== (provider.proxy ?? "").trim()
+ || form.extraBody.trim() !== providerJsonValue(provider.extra_body).trim()
+ );
+ const oauthSettingsSaving = saving && oauthSettingsDirty;
+ const oauthActionBusy = saving && !oauthSettingsSaving;
+ const missingRequiredApiKey = !isOauthProvider && apiKeyRequired && !provider.configured && !apiKey;
+ const hasOptionalProviderSetting = Boolean(
+ apiKey
+ || apiBase
+ || form.proxy.trim()
+ || form.extraHeaders.trim()
+ || form.extraBody.trim()
+ || form.extraQuery.trim()
+ || form.thinkingStyle.trim()
+ || form.region.trim()
+ || form.profile.trim(),
+ );
+ const missingOptionalCredential =
+ !isOauthProvider
+ && !apiKeyRequired
+ && !provider.configured
+ && !hasOptionalProviderSetting;
+ const supportName = provider.name === "bedrock"
+ ? "bedrock"
+ : provider.name === "azure_openai"
+ ? "azure"
+ : null;
+ const supportFeature = supportName
+ ? (nanobotFeatures?.features ?? []).find((feature) => feature.name === supportName)
+ : null;
+ return (
+
+
toggleProvider(provider.name)}
+ className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
+ >
+
+
+
+
+ {provider.label}
+
+ {provider.api_base ? (
+
+ {provider.api_base}
+
+ ) : null}
+
+
+
+
+
+ {expanded ? (
+
+ {supportFeature && !supportFeature.installed ? (
+
+ ) : null}
+ {supportName && capabilityError ? (
+
{capabilityError}
+ ) : null}
+ {isOauthProvider ? (
+ <>
+
+
+
+ {tx("settings.oauth.authentication", "OAuth authentication")}
+
+
+ {provider.configured
+ ? t("settings.oauth.signedInAs", {
+ account: provider.oauth_account || provider.label,
+ defaultValue: "Signed in as {{account}}",
+ })
+ : provider.name === "openai_codex" && remoteBrowserAccess
+ ? tx(
+ "settings.oauth.codexRemoteSignInHelp",
+ "Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
+ )
+ : provider.name === "xai_grok" && remoteBrowserAccess
+ ? tx(
+ "settings.oauth.remoteSignInHelp",
+ "Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
+ )
+ : tx("settings.oauth.signInHelp", "Sign in from this device; no API key is stored in config.")}
+
+
+
+ {provider.configured ? (
+ onProviderOAuthLogout(provider.name)}
+ disabled={saving}
+ className="rounded-full"
+ >
+ {tx("settings.oauth.signOut", "Sign out")}
+
+ ) : null}
+ onProviderOAuthLogin(provider.name)}
+ disabled={saving || oauthSettingsDirty || !provider.oauth_login_supported}
+ title={
+ oauthSettingsDirty
+ ? tx(
+ "settings.providers.saveAdvancedBeforeSignIn",
+ "Save advanced changes before signing in.",
+ )
+ : undefined
+ }
+ className="rounded-full"
+ >
+ {oauthActionBusy ? (
+
+ ) : null}
+ {oauthActionBusy
+ ? tx("settings.oauth.signingIn", "Signing in...")
+ : provider.configured
+ ? tx("settings.oauth.signInAgain", "Sign in again")
+ : tx("settings.oauth.signIn", "Sign in")}
+
+
+
+
onChangeProviderForm(provider.name, value)}
+ />
+ {supportsOauthAdvancedSettings ? (
+ onChangeProviderForm(provider.name, value)}
+ footer={
+ <>
+ toggleProvider(provider.name)}
+ disabled={saving}
+ className="rounded-full"
+ >
+ {t("settings.actions.cancel")}
+
+ onSaveProvider(provider.name)}
+ disabled={saving || !oauthSettingsDirty}
+ className="rounded-full"
+ >
+ {oauthSettingsSaving ? (
+
+ ) : null}
+ {oauthSettingsSaving
+ ? t("settings.actions.saving")
+ : tx("settings.providers.saveProvider", "Save provider")}
+
+ >
+ }
+ />
+ ) : null}
+ >
+ ) : (
+ <>
+ {provider.is_custom ? (
+
+
+ {tx("settings.providers.customProviderName", "Provider name")}
+
+
+ onChangeProviderForm(provider.name, { displayName: event.target.value })
+ }
+ className="h-9 rounded-full text-[13px]"
+ />
+
+ ) : null}
+
+
+ {t("settings.byok.apiKey")}
+
+
+ {editingKey ? (
+ <>
+
+ onChangeProviderForm(provider.name, { apiKey: event.target.value })
+ }
+ placeholder={
+ provider.configured
+ ? t("settings.byok.apiKeyConfiguredPlaceholder")
+ : t("settings.byok.apiKeyPlaceholder")
+ }
+ className="h-9 rounded-full pr-11 text-[13px]"
+ />
+
onToggleProviderKey(provider.name)}
+ aria-label={
+ keyVisible
+ ? t("settings.byok.hideApiKey")
+ : t("settings.byok.showApiKey")
+ }
+ className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
+ >
+ {keyVisible ? (
+
+ ) : (
+
+ )}
+
+ >
+ ) : (
+ <>
+
+ {provider.api_key_hint ?? t("settings.byok.configuredKeyHint")}
+
+
onToggleProviderKeyEditing(provider.name)}
+ aria-label={t("settings.actions.edit")}
+ className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
+ >
+
+
+ >
+ )}
+
+
+
+
+ {t("settings.byok.apiBase")}
+
+
+ onChangeProviderForm(provider.name, { apiBase: event.target.value })
+ }
+ placeholder={provider.default_api_base ?? t("settings.byok.apiBasePlaceholder")}
+ className="h-9 rounded-full text-[13px]"
+ />
+
+ onChangeProviderForm(provider.name, value)}
+ />
+ onChangeProviderForm(provider.name, value)}
+ />
+
+ toggleProvider(provider.name)}
+ className="rounded-full"
+ >
+ {t("settings.actions.cancel")}
+
+ onSaveProvider(provider.name)}
+ disabled={
+ saving
+ || missingRequiredApiKey
+ || missingOptionalCredential
+ || (provider.is_custom && !form.displayName.trim())
+ }
+ className="rounded-full"
+ >
+ {saving
+ ? t("settings.actions.saving")
+ : tx("settings.providers.saveProvider", "Save provider")}
+
+
+ >
+ )}
+
+ ) : null}
+
+ );
+ };
+ const customProviderForm = creatingCustomProvider ? (
+
+
+
+
+
+ {tx("settings.providers.customProvider", "Custom provider")}
+
+
+
+
+
+
+
+ {tx("settings.providers.customProviderName", "Provider name")}
+
+
+ setCustomProviderDraft((current) => ({
+ ...current,
+ name: event.target.value,
+ }))
+ }
+ placeholder={tx(
+ "settings.providers.customProviderNamePlaceholder",
+ "My model provider",
+ )}
+ className="h-9 rounded-full text-[13px]"
+ />
+
+
+
+ {t("settings.byok.apiBase")}
+
+
+ setCustomProviderDraft((current) => ({
+ ...current,
+ apiBase: event.target.value,
+ }))
+ }
+ placeholder="https://api.example.com/v1"
+ autoCapitalize="none"
+ autoComplete="off"
+ autoCorrect="off"
+ spellCheck={false}
+ className="h-9 rounded-full text-[13px]"
+ />
+
+
+
+ {t("settings.byok.apiKey")}
+
+
+
+ setCustomProviderDraft((current) => ({
+ ...current,
+ apiKey: event.target.value,
+ }))
+ }
+ placeholder={t("settings.byok.apiKeyPlaceholder")}
+ autoCapitalize="none"
+ autoComplete="off"
+ autoCorrect="off"
+ spellCheck={false}
+ className="h-9 rounded-full pr-11 text-[13px]"
+ />
+ setCustomProviderKeyVisible((visible) => !visible)}
+ aria-label={
+ customProviderKeyVisible
+ ? t("settings.byok.hideApiKey")
+ : t("settings.byok.showApiKey")
+ }
+ className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
+ >
+ {customProviderKeyVisible ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ setCustomProviderDraft((current) => ({ ...current, ...value }))
+ }
+ />
+
+
+ {t("settings.actions.cancel")}
+
+
+ {customProviderSaving
+ ? t("settings.actions.saving")
+ : tx("settings.providers.saveProvider", "Save provider")}
+
+
+
+
+ ) : null;
+ return (
+
+ {imageProviderRestartPending && onRestart ? (
+
+
+ {tx("settings.status.imageProviderRestart", "Provider support changed. Restart when ready.")}
+
+
+
+ {isRestarting ? (
+
+ ) : (
+
+ )}
+ {isRestarting ? t("app.system.restarting") : t("app.system.restart")}
+
+
+
+ ) : null}
+
+
+ {tx("settings.providers.title", "Model providers")}
+
+
+ {configuredProviders.map(renderProviderRow)}
+ {selectedUnconfiguredProvider
+ ? renderProviderRow(selectedUnconfiguredProvider)
+ : null}
+ {customProviderForm}
+ {!expandedProvider && !creatingCustomProvider ? (
+
+
+
+
+
+
+
+
+ {tx(
+ "settings.providers.addOwnProvider",
+ "Add your own model provider",
+ )}
+
+
+
+
+
+
+
+
+
+ {tx("settings.providers.customProvider", "Custom provider")}
+
+
+ {unconfiguredProviders.length > 0 ? : null}
+ {unconfiguredProviders.map((provider) => (
+ {
+ setCreatingCustomProvider(false);
+ if (expandedProvider !== provider.name) {
+ onToggleProvider(provider.name);
+ }
+ }}
+ className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
+ >
+
+
+ {provider.label}
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+ );
+}
+
+function orderUnconfiguredProviders(
+ providers: SettingsPayload["providers"],
+): SettingsPayload["providers"] {
+ return providers
+ .map((provider, index) => ({ provider, index }))
+ .sort((left, right) => {
+ const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider);
+ return rank || left.index - right.index;
+ })
+ .map(({ provider }) => provider);
+}
+
+function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
+ const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
+ if (localRank !== undefined) return localRank;
+ if ((provider.api_key_required ?? true) === false) return 100;
+ return 200;
+}
+
+function ProviderIcon({
+ provider,
+ showBrandLogos,
+}: {
+ provider: string;
+ showBrandLogos: boolean;
+}) {
+ const brand = providerBrand(provider);
+ const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
+ const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
+
+ if (showBrandLogos && logoUrl) {
+ return (
+
+
+
+ );
+ }
+ if (showBrandLogos && brand) {
+ return (
+
+ {brand.initials}
+
+ );
+ }
+ return (
+
+
+
+ );
+}
diff --git a/webui/src/components/settings/models/useModelSettingsActions.ts b/webui/src/components/settings/models/useModelSettingsActions.ts
new file mode 100644
index 000000000..35d55c372
--- /dev/null
+++ b/webui/src/components/settings/models/useModelSettingsActions.ts
@@ -0,0 +1,562 @@
+import { useCallback, type Dispatch, type SetStateAction } from "react";
+import type { TFunction } from "i18next";
+
+import type {
+ ApplySettingsPayload,
+ MaybeRestartHostEngine,
+ PendingRestartSections,
+} from "@/components/settings/contracts";
+import { agentDraftFromPayload } from "@/components/settings/models/ModelsSettings";
+import {
+ CUSTOM_PROVIDER_CREATION_KEY,
+ providerFormFromRow,
+ type CustomProviderDraft,
+} from "@/components/settings/models/ProviderSettings";
+import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
+import { normalizeContextWindowTokens } from "@/components/settings/shared/ModelControls";
+import {
+ completeProviderOAuth,
+ createModelConfiguration,
+ createProviderSettings,
+ deleteModelConfiguration,
+ loginProviderOAuth,
+ logoutProviderOAuth,
+ migrateModelConfigurations,
+ updateModelCallOrder,
+ updateModelConfiguration,
+ updateProviderSettings,
+} from "@/lib/api";
+import type { NanobotClient } from "@/lib/nanobot-client";
+import type {
+ ProviderOAuthAuthorizationRequired,
+ ProviderOAuthCompletionResult,
+ ProviderOAuthLoginResult,
+ ProviderOAuthPending,
+ ProviderSettingsUpdate,
+ SettingsPayload,
+} from "@/lib/types";
+
+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";
+}
+
+interface ModelSettingsActionsOptions {
+ state: ModelSettingsState;
+ settings: SettingsPayload | null;
+ client: NanobotClient;
+ t: TFunction;
+ applyPayload: ApplySettingsPayload;
+ maybeRestartHostEngine: MaybeRestartHostEngine;
+ setPendingRestartSections: Dispatch>;
+ setError: Dispatch>;
+ onModelNameChange: (modelName: string | null) => void;
+ remoteBrowserAccess: boolean;
+ closeProviderOAuthFlow: () => void;
+ installCapabilities: (names: string[]) => Promise;
+ modelDirty: boolean;
+ configuredModelProviderOptions: Array<{ name: string; label: string }>;
+}
+
+export function useModelSettingsActions({
+ state,
+ settings,
+ client,
+ t,
+ applyPayload,
+ maybeRestartHostEngine,
+ setPendingRestartSections,
+ setError,
+ onModelNameChange,
+ remoteBrowserAccess,
+ closeProviderOAuthFlow,
+ installCapabilities,
+ modelDirty,
+ configuredModelProviderOptions,
+}: ModelSettingsActionsOptions) {
+ const {
+ expandedProvider,
+ form,
+ modelCallOrder,
+ modelCallOrderSaving,
+ modelConfigurationSaving,
+ modelMigrationSaving,
+ modelPresetBeforeCreateRef,
+ modelPresetCreating,
+ modelPresetPendingDelete,
+ providerForms,
+ providerOAuthCompleting,
+ providerOAuthFlowRef,
+ providerOAuthResponse,
+ providerSaving,
+ saving,
+ setEditingProviderKeys,
+ setExpandedProvider,
+ setForm,
+ setModelCallOrder,
+ setModelCallOrderSaving,
+ setModelConfigurationSaving,
+ setModelMigrationSaving,
+ setModelPresetCreating,
+ setModelPresetPendingDelete,
+ setProviderForms,
+ setProviderOAuthCompleting,
+ setProviderOAuthDialogError,
+ setProviderOAuthFlow,
+ setProviderOAuthResponse,
+ setProviderSaving,
+ setSaving,
+ setVisibleProviderKeys,
+ visibleProviderKeys,
+ } = state;
+
+ 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 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 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 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 };
+ });
+ };
+
+ return {
+ beginModelPresetCreation,
+ cancelModelPresetCreation,
+ changeModelCallOrder,
+ completeProviderOAuthResponse,
+ createCustomProvider,
+ handleDeleteModelConfiguration,
+ handleMigrateModelConfigurations,
+ handleToggleProvider,
+ resetProviderDraft,
+ runProviderOAuth,
+ saveModelSettings,
+ saveProvider,
+ toggleProviderKeyEditing,
+ toggleProviderKeyVisibility,
+ };
+}
diff --git a/webui/src/components/settings/models/useModelSettingsEffects.ts b/webui/src/components/settings/models/useModelSettingsEffects.ts
new file mode 100644
index 000000000..46f3d2847
--- /dev/null
+++ b/webui/src/components/settings/models/useModelSettingsEffects.ts
@@ -0,0 +1,97 @@
+import { useEffect, type Dispatch, type SetStateAction } from "react";
+
+import type { ApplySettingsPayload } from "@/components/settings/contracts";
+import { providerFormFromRow } from "@/components/settings/models/ProviderSettings";
+import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
+import { completeProviderOAuth } from "@/lib/api";
+import type { NanobotClient } from "@/lib/nanobot-client";
+import type {
+ ProviderOAuthCompletionResult,
+ ProviderOAuthPending,
+ SettingsPayload,
+} from "@/lib/types";
+
+function isProviderOAuthPending(
+ payload: ProviderOAuthCompletionResult,
+): payload is ProviderOAuthPending {
+ return (payload as ProviderOAuthPending).status === "pending";
+}
+
+interface ProviderOAuthPollingOptions {
+ state: ModelSettingsState;
+ client: NanobotClient;
+ applyPayload: ApplySettingsPayload;
+ setError: Dispatch>;
+ closeProviderOAuthFlow: () => void;
+}
+
+export function useProviderOAuthPolling({
+ state,
+ client,
+ applyPayload,
+ setError,
+ closeProviderOAuthFlow,
+}: ProviderOAuthPollingOptions) {
+ const {
+ providerOAuthFlow,
+ providerOAuthFlowRef,
+ setExpandedProvider,
+ } = state;
+
+ 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]);
+}
+
+export function useProviderFormsSync(
+ state: ModelSettingsState,
+ settings: SettingsPayload | null,
+) {
+ const { setProviderForms } = state;
+
+ 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]);
+}
diff --git a/webui/src/components/settings/models/useModelSettingsState.ts b/webui/src/components/settings/models/useModelSettingsState.ts
new file mode 100644
index 000000000..ec758618d
--- /dev/null
+++ b/webui/src/components/settings/models/useModelSettingsState.ts
@@ -0,0 +1,78 @@
+import { useRef, useState } from "react";
+
+import {
+ DEFAULT_AGENT_SETTINGS_DRAFT,
+ agentDraftFromPayload,
+ type AgentSettingsDraft,
+} from "@/components/settings/models/ModelsSettings";
+import type { ProviderForm } from "@/components/settings/models/ProviderSettings";
+import type { ProviderOAuthAuthorizationRequired, SettingsPayload } from "@/lib/types";
+
+export function useModelSettingsState(initialSettings: SettingsPayload | null) {
+ 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 [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 [expandedProvider, setExpandedProvider] = useState(null);
+ const [providerForms, setProviderForms] = useState>({});
+ const [visibleProviderKeys, setVisibleProviderKeys] = useState>({});
+ const [editingProviderKeys, setEditingProviderKeys] = useState>({});
+ const [form, setForm] = useState(() =>
+ initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
+ );
+ const [modelCallOrder, setModelCallOrder] = useState(
+ () => initialSettings?.model_call_order ?? [],
+ );
+
+ return {
+ editingProviderKeys,
+ expandedProvider,
+ form,
+ modelCallOrder,
+ modelCallOrderSaving,
+ modelConfigurationSaving,
+ modelMigrationSaving,
+ modelPresetBeforeCreateRef,
+ modelPresetCreating,
+ modelPresetPendingDelete,
+ providerForms,
+ providerOAuthCompleting,
+ providerOAuthDialogError,
+ providerOAuthFlow,
+ providerOAuthFlowRef,
+ providerOAuthResponse,
+ providerSaving,
+ saving,
+ setEditingProviderKeys,
+ setExpandedProvider,
+ setForm,
+ setModelCallOrder,
+ setModelCallOrderSaving,
+ setModelConfigurationSaving,
+ setModelMigrationSaving,
+ setModelPresetCreating,
+ setModelPresetPendingDelete,
+ setProviderForms,
+ setProviderOAuthCompleting,
+ setProviderOAuthDialogError,
+ setProviderOAuthFlow,
+ setProviderOAuthResponse,
+ setProviderSaving,
+ setSaving,
+ setVisibleProviderKeys,
+ visibleProviderKeys,
+ };
+}
+
+export type ModelSettingsState = ReturnType;
diff --git a/webui/src/components/settings/overview/OverviewSettings.tsx b/webui/src/components/settings/overview/OverviewSettings.tsx
new file mode 100644
index 000000000..040e1cf8f
--- /dev/null
+++ b/webui/src/components/settings/overview/OverviewSettings.tsx
@@ -0,0 +1,526 @@
+import { useState, type Dispatch, type SetStateAction } from "react";
+import {
+ ArrowUpCircle,
+ Bot,
+ Check,
+ ChevronRight,
+ ExternalLink,
+ Globe2,
+ HardDrive,
+ ImageIcon,
+ Loader2,
+ Mic,
+ Server,
+ type LucideIcon,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { LanguageSwitcher } from "@/components/LanguageSwitcher";
+import { DEFAULT_TRANSCRIPTION_SETTINGS } from "@/components/settings/capabilities/TranscriptionSettings";
+import type { SettingsSectionKey } from "@/components/settings/contracts";
+import { settingsProviderConfigured } from "@/components/settings/shared/ModelControls";
+import {
+ SettingsGroup,
+ SettingsRow,
+ SettingsSectionTitle,
+} from "@/components/settings/shared/SettingsControls";
+import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
+import { ToggleButton } from "@/components/settings/ToggleButton";
+import { Button } from "@/components/ui/button";
+import { SegmentedControl } from "@/components/ui/segmented-control";
+import { useLogoFallback } from "@/hooks/useLogoFallback";
+import { checkVersion } from "@/lib/api";
+import type {
+ FileEditDisplayMode,
+ LocalActivityMode,
+ LocalDensity,
+ LocalPreferences,
+} from "@/lib/local-preferences";
+import { providerBrand, providerDisplayLabel } from "@/lib/provider-brand";
+import type { SettingsPayload } from "@/lib/types";
+import { cn } from "@/lib/utils";
+import { shortWorkspacePath } from "@/lib/workspace";
+import { useClient } from "@/providers/ClientProvider";
+
+export 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"}
+
+
+
+
void handleCheck()}
+ disabled={checking}
+ className="rounded-full"
+ >
+ {checking ? (
+
+ ) : (
+
+ )}
+ {checking
+ ? tx("settings.about.checking", "Checking...")
+ : tx("settings.about.checkForUpdates", "Check for updates")}
+
+ {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}
+
+
+ );
+}
+
+export 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")}
+
+
+
+
+ {t("settings.values.light")}
+
+
+ {t("settings.values.dark")}
+
+
+
+
+
+
+
+
+
+
+
+ {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 OverviewRowIcon({
+ icon: Icon,
+}: {
+ icon: LucideIcon;
+}) {
+ return (
+
+
+
+ );
+}
+
+function OverviewValueLogo({
+ provider,
+ showBrandLogos,
+}: {
+ provider: string | null | undefined;
+ showBrandLogos: boolean;
+}) {
+ const brand = provider ? providerBrand(provider) : null;
+ const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
+
+ if (!provider || !showBrandLogos || !brand) return null;
+
+ if (logoUrl) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {brand.initials}
+
+ );
+}
+
+function OverviewListRow({
+ icon: Icon,
+ valueLogoProvider,
+ title,
+ value,
+ caption,
+ showBrandLogos = false,
+ onClick,
+}: {
+ icon: LucideIcon;
+ valueLogoProvider?: string | null;
+ title: string;
+ value: string;
+ caption: string;
+ showBrandLogos?: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+ {title}
+ {caption}
+
+
+
+
+ {value}
+
+
+
+
+ );
+}
diff --git a/webui/src/components/settings/shared/ModelControls.tsx b/webui/src/components/settings/shared/ModelControls.tsx
new file mode 100644
index 000000000..68bd7f8a7
--- /dev/null
+++ b/webui/src/components/settings/shared/ModelControls.tsx
@@ -0,0 +1,619 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ Bot,
+ Brain,
+ Check,
+ ChevronDown,
+ CircleAlert,
+ Cloud,
+ Cpu,
+ Database,
+ Gem,
+ Grid3X3,
+ Hexagon,
+ Layers,
+ Loader2,
+ Moon,
+ Orbit,
+ Pencil,
+ Search,
+ Sparkles,
+ Triangle,
+ Waves,
+ Zap,
+ type LucideIcon,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+import { ComboboxOption, useComboboxNavigation } from "@/components/ui/combobox";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { useLogoFallback } from "@/hooks/useLogoFallback";
+import { fetchProviderModels } from "@/lib/api";
+import { providerBrand } from "@/lib/provider-brand";
+import type { ProviderModelsPayload, SettingsPayload } from "@/lib/types";
+import { cn } from "@/lib/utils";
+
+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",
+]);
+const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
+
+export 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;
+}
+
+export 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;
+}
+
+export function ProviderPicker({
+ providers,
+ value,
+ emptyLabel,
+ showProviderLogos = false,
+ onChange,
+}: {
+ providers: Array<{ name: string; label: string }>;
+ value: string;
+ emptyLabel: string;
+ showProviderLogos?: boolean;
+ onChange: (provider: string) => void;
+}) {
+ const selectedProvider = providers.find((provider) => provider.name === value) ?? null;
+ const disabled = providers.length === 0;
+
+ return (
+
+
+
+
+ {selectedProvider && showProviderLogos ? (
+
+ ) : null}
+ {selectedProvider?.label ?? emptyLabel}
+
+
+
+
+
+ {providers.map((provider) => {
+ const selected = provider.name === value;
+ return (
+ onChange(provider.name)}
+ className={cn(
+ "flex cursor-default items-center justify-between gap-2 text-[13px]",
+ selected && "bg-muted/80 text-foreground focus:bg-muted",
+ )}
+ >
+
+ {showProviderLogos ? (
+
+ ) : null}
+ {provider.label}
+
+ {selected ? : null}
+
+ );
+ })}
+
+
+ );
+}
+
+export function ModelIdPicker({
+ token,
+ settings,
+ provider,
+ models,
+ value,
+ showProviderLogos,
+ emptyLabel,
+ searchPlaceholder,
+ emptyMessage,
+ onChange,
+}: {
+ token: string;
+ settings: SettingsPayload;
+ provider: string;
+ models?: string[];
+ value: string;
+ showProviderLogos: boolean;
+ emptyLabel?: string;
+ searchPlaceholder?: string;
+ emptyMessage?: string;
+ onChange: (model: string) => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const tokenRef = useRef(token);
+ tokenRef.current = token;
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [payload, setPayload] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const effectiveProvider =
+ provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
+ const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
+ const hasStaticModels = models !== undefined;
+ const providerRow = settingsProviderRow(settings, effectiveProvider);
+ const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
+ const providerRequiresConfiguration =
+ !hasStaticModels && hasConcreteProvider && !providerConfigured;
+ const providerHasBuiltinModels = providerRow?.model_catalog === "builtin";
+ const providerUsesManualModelIds =
+ !hasStaticModels &&
+ hasConcreteProvider &&
+ providerConfigured &&
+ providerRow?.auth_type === "oauth" &&
+ !providerHasBuiltinModels;
+ const canFetchModels =
+ !hasStaticModels &&
+ hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
+ const normalizedQuery = query.trim().toLowerCase();
+ const providerModels: ProviderModelsPayload["models"] = useMemo(
+ () => hasStaticModels
+ ? (models?.map((id) => ({ id })) ?? [])
+ : (payload?.models ?? []),
+ [hasStaticModels, models, payload?.models],
+ );
+ const visibleModels = useMemo(
+ () => providerModels
+ .filter((model) => {
+ if (!normalizedQuery) return true;
+ return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""]
+ .some((field) => field.toLowerCase().includes(normalizedQuery));
+ })
+ .slice(0, 80),
+ [normalizedQuery, providerModels],
+ );
+ const isCatalog = payload?.catalog_kind === "catalog";
+ const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
+ const hasDeferredSearchQuery =
+ normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH;
+ const shouldFetchModels =
+ canFetchModels && (!defersModelList || hasDeferredSearchQuery);
+ const waitingForModelSearch =
+ open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
+ const hasModelList = hasStaticModels || payload?.status === "available";
+ const showModels = Boolean(
+ hasModelList && (hasStaticModels || (payload && (!isCatalog || normalizedQuery))),
+ );
+ const customCandidate = query.trim();
+ const allowCustomModel = !providerRequiresConfiguration;
+ const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
+ const showCustomModel = Boolean(
+ allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value,
+ );
+ const providerModelCount = payload?.model_count ?? providerModels.length;
+ const modelUnconfigured = !value.trim() || !providerConfigured;
+
+ useEffect(() => {
+ if (!open) return;
+ setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
+ }, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
+
+ useEffect(() => {
+ if (!open || !shouldFetchModels) {
+ setPayload(null);
+ setError(null);
+ setLoading(false);
+ return;
+ }
+ let cancelled = false;
+ setPayload(null);
+ setError(null);
+ setLoading(true);
+ fetchProviderModels(tokenRef.current, effectiveProvider)
+ .then((nextPayload) => {
+ if (!cancelled) setPayload(nextPayload);
+ })
+ .catch((err) => {
+ if (!cancelled) setError((err as Error).message);
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [effectiveProvider, open, shouldFetchModels]);
+
+ const selectModel = (model: string) => {
+ onChange(model);
+ setOpen(false);
+ };
+ const navigationValues = useMemo(
+ () => [
+ ...(showModels ? visibleModels.map((model) => model.id) : []),
+ ...(showCustomModel ? [customCandidate] : []),
+ ],
+ [customCandidate, showCustomModel, showModels, visibleModels],
+ );
+ const navigation = useComboboxNavigation({
+ open,
+ values: navigationValues,
+ selectedValue: value,
+ onSelect: selectModel,
+ onClose: () => setOpen(false),
+ });
+
+ const renderModelRow = (
+ model: ProviderModelsPayload["models"][number],
+ options: { selected?: boolean } = {},
+ ) => (
+
+
+
+
+
+ {model.label ?? model.id}
+
+ {model.description || (model.label && model.label !== model.id) ? (
+
+ {[model.label && model.label !== model.id ? model.id : null, model.description]
+ .filter(Boolean)
+ .join(" · ")}
+
+ ) : null}
+
+
+
+ {model.context_window ? {formatContextWindow(model.context_window)} : null}
+ {options.selected ? : null}
+
+
+ );
+
+ return (
+
+
+
+
+
+
+ {value || emptyLabel || tx("settings.models.selectModel", "Select model")}
+
+
+
+
+
+
+
+
+
+ setQuery(event.target.value)}
+ {...navigation.inputProps}
+ placeholder={
+ searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
+ }
+ aria-label={
+ searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
+ }
+ className="h-8 rounded-full pl-8 pr-3 text-[12px]"
+ />
+
+
+
+ {providerRequiresConfiguration ? (
+
+ {tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
+
+ ) : hasStaticModels && !providerModels.length ? (
+
+ {emptyMessage || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
+
+ ) : providerUsesManualModelIds ? (
+
+ {tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
+
+ ) : !canFetchModels ? (
+
+ {tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
+
+ ) : waitingForModelSearch ? (
+
+ {tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
+
+ ) : loading ? (
+
+
+ {tx("settings.models.loadingModels", "Loading models...")}
+
+ ) : error || payload?.status === "error" ? (
+
+ {payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
+
+ ) : payload?.status === "not_configured" ? (
+
+ {tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
+
+ ) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
+
+ {payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
+
+ ) : isCatalog && !normalizedQuery ? (
+
+ {tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
+ {providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
+
+ ) : null}
+
+ {navigationValues.length ? (
+
+ {showModels
+ ? visibleModels.map((model) =>
+ renderModelRow(model, { selected: model.id === value }),
+ )
+ : null}
+ {showCustomModel ? (
+ <>
+ {showModels && visibleModels.length ? (
+
+ ) : null}
+
+
+
+
+
+ {tx("settings.models.useCustomModel", "Use")}{" "}
+ “{customCandidate}”
+
+
+ >
+ ) : null}
+
+ ) : showModels ? (
+
+ {tx("settings.models.noModelResults", "No matching models.")}
+
+ ) : null}
+
+
+
+ );
+}
+
+export function formatContextWindow(tokens: number): string {
+ if (tokens >= 1_000_000) {
+ const value = tokens / 1_000_000;
+ return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
+ }
+ if (tokens >= 1_000) {
+ const value = tokens / 1_000;
+ return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;
+ }
+ return String(tokens);
+}
+
+export function formatModelContextWindow(tokens: number): string {
+ if (tokens === 65_536) return "64K";
+ if (tokens === 262_144) return "256K";
+ if (tokens === 1_048_576) return "1M";
+ return formatContextWindow(tokens);
+}
+
+export function ProviderPickerIcon({
+ provider,
+ showBrandLogos,
+ unconfigured = false,
+}: {
+ provider: string;
+ showBrandLogos: boolean;
+ unconfigured?: boolean;
+}) {
+ const brand = providerBrand(provider);
+ const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
+ const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
+
+ if (unconfigured) {
+ return (
+
+
+
+ );
+ }
+
+ if (showBrandLogos && logoUrl) {
+ return (
+
+
+
+ );
+ }
+
+ if (showBrandLogos && brand) {
+ return (
+
+ {brand.initials}
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+export function optionRowsWithCurrent(
+ options: Array<{ name: string; label: string }>,
+ value: string,
+): Array<{ name: string; label: string }> {
+ if (!value || options.some((option) => option.name === value)) return options;
+ return [{ name: value, label: value }, ...options];
+}
+
+export const PROVIDER_ICONS: Record = {
+ custom: Hexagon,
+ openrouter: Sparkles,
+ skywork: Sparkles,
+ aihubmix: Triangle,
+ anthropic: Brain,
+ openai: Bot,
+ deepseek: Waves,
+ zhipu: Grid3X3,
+ dashscope: Cloud,
+ modelscope: Layers,
+ moonshot: Moon,
+ minimax: Zap,
+ minimax_anthropic: Brain,
+ groq: Cpu,
+ huggingface: Layers,
+ gemini: Gem,
+ mistral: Orbit,
+ siliconflow: Layers,
+ volcengine: Cloud,
+ volcengine_coding_plan: Cloud,
+ byteplus: Cloud,
+ byteplus_coding_plan: Cloud,
+ qianfan: Database,
+ ant_ling: Sparkles,
+ azure_openai: Cloud,
+ bedrock: Database,
+ bocha: Search,
+ brave: Search,
+ duckduckgo: Search,
+ exa: Search,
+ jina: Search,
+ kagi: Search,
+ olostep: Search,
+ searxng: Search,
+ tavily: Search,
+ vllm: Cpu,
+ ollama: Cpu,
+ lm_studio: Cpu,
+ atomic_chat: Cpu,
+ ovms: Cpu,
+ nvidia: Zap,
+};
diff --git a/webui/src/components/settings/shared/SettingsControls.tsx b/webui/src/components/settings/shared/SettingsControls.tsx
new file mode 100644
index 000000000..383c31442
--- /dev/null
+++ b/webui/src/components/settings/shared/SettingsControls.tsx
@@ -0,0 +1,410 @@
+import type { ReactNode } from "react";
+import { CircleAlert, Loader2, RotateCcw, X } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { isNativeRuntime } from "@/lib/runtime";
+import type { NanobotFeatureInfo } from "@/lib/types";
+import { cn } from "@/lib/utils";
+
+export 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",
+);
+
+export function CapabilityInstallNotice({
+ title,
+ description,
+ installing = false,
+}: {
+ title: string;
+ description: string;
+ installing?: boolean;
+}) {
+ return (
+
+ {installing ? (
+
+ ) : (
+
+ )}
+
+
{title}
+
{description}
+
+
+ );
+}
+
+export function NanobotFeatureInstallDialog({
+ feature,
+ installing,
+ onOpenChange,
+ onConfirm,
+}: {
+ feature: NanobotFeatureInfo | null;
+ installing: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: (feature: NanobotFeatureInfo) => void | Promise;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string, values?: Record) =>
+ t(key, { defaultValue: fallback, ...(values ?? {}) });
+ const name = feature?.display_name || feature?.name || "";
+ return (
+
+
+
+
+ {tx("settings.nanobotFeatures.installConfirmTitle", "Install support for {{name}}?", { name })}
+
+
+ {tx(
+ "settings.nanobotFeatures.installConfirmDescription",
+ "nanobot will add what {{name}} needs, then turn it on. Continue?",
+ { name },
+ )}
+
+
+
+ onOpenChange(false)}
+ disabled={installing}
+ className="h-11 w-full min-w-0 rounded-full bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
+ >
+ {tx("settings.automations.cancel", "Cancel")}
+
+ feature && void onConfirm(feature)}
+ disabled={!feature || installing}
+ className="h-11 w-full min-w-0 !whitespace-normal rounded-full px-5 text-center text-[15px] font-semibold"
+ >
+ {installing ? : null}
+ {tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")}
+
+
+
+
+ );
+}
+
+export function DismissibleStatusMessage({
+ message,
+ isError,
+ onDismiss,
+}: {
+ message: string;
+ isError: boolean;
+ onDismiss: () => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ return (
+
+ {message}
+
+
+
+
+ );
+}
+
+export function RestartRequiredNotice({
+ message,
+ onRestart,
+ isRestarting,
+}: {
+ message: string;
+ onRestart?: () => void;
+ isRestarting?: boolean;
+}) {
+ const { t } = useTranslation();
+ return (
+
+ {message}
+ {onRestart ? (
+
+ {isRestarting ? (
+
+ ) : (
+
+ )}
+ {isRestarting ? t("app.system.restarting") : t("app.system.restart")}
+
+ ) : null}
+
+ );
+}
+
+export function SettingsSectionTitle({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function SettingsGroup({ children }: { children: ReactNode }) {
+ return (
+
+ );
+}
+
+export function SettingsRow({
+ title,
+ description,
+ children,
+}: {
+ title: string;
+ description?: string;
+ children?: ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {children ?
{children}
: null}
+
+ );
+}
+
+export function ReadOnlyRow({
+ title,
+ value,
+ description,
+}: {
+ title: string;
+ value: string;
+ description?: string;
+}) {
+ return (
+
+
+ {value}
+
+
+ );
+}
+
+export function RestartSettingsFooter({
+ dirty,
+ saving,
+ pendingRestart,
+ disabled = false,
+ message,
+ dirtyMessage,
+ pendingMessage,
+ onSave,
+ onRestart,
+ onReset,
+ isRestarting,
+}: {
+ dirty: boolean;
+ saving: boolean;
+ pendingRestart: boolean;
+ disabled?: boolean;
+ message?: string;
+ dirtyMessage?: string;
+ pendingMessage?: string;
+ onSave: () => void;
+ onRestart?: () => void;
+ onReset?: () => void;
+ isRestarting?: boolean;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const isNativeHost = isNativeRuntime();
+ const restartLabel = isNativeHost
+ ? tx("app.system.restartEngine", "Restart engine")
+ : t("app.system.restart");
+ const restartingLabel = isNativeHost
+ ? tx("app.system.restartingEngine", "Restarting engine...")
+ : t("app.system.restarting");
+ const statusMessage =
+ message ??
+ (pendingRestart && !dirty
+ ? pendingMessage ?? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
+ : dirty
+ ? dirtyMessage ?? t("settings.status.unsaved")
+ : undefined);
+ const statusTone = disabled ? "danger" : dirty || pendingRestart ? "accent" : undefined;
+
+ return (
+
+
+ {statusMessage}
+
+
+ {pendingRestart && !dirty && onRestart ? (
+
+ {isRestarting ? (
+
+ ) : (
+
+ )}
+ {isRestarting ? restartingLabel : restartLabel}
+
+ ) : null}
+ {onReset ? (
+
+ {t("settings.actions.cancel")}
+
+ ) : null}
+
+ {saving ? t("settings.actions.saving") : t("settings.actions.save")}
+
+
+
+ );
+}
+
+export function SettingsStatusMessage({
+ children,
+ tone,
+}: {
+ children?: ReactNode;
+ tone?: "accent" | "danger";
+}) {
+ if (!children) return null;
+ return (
+
+ {tone ? (
+
+ ) : null}
+ {children}
+
+ );
+}
+
+export function StatusPill({
+ children,
+ tone = "neutral",
+}: {
+ children: ReactNode;
+ tone?: "neutral" | "success" | "warning";
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function NumberInput({
+ value,
+ min,
+ max,
+ onChange,
+ suffix,
+}: {
+ value: number;
+ min: number;
+ max: number;
+ onChange: (value: number) => void;
+ suffix?: string;
+}) {
+ return (
+
+ {
+ const parsed = Number(event.target.value);
+ if (Number.isFinite(parsed)) onChange(parsed);
+ }}
+ className="h-8 w-24 max-w-full rounded-full text-[13px]"
+ />
+ {suffix ? {suffix} : null}
+
+ );
+}
diff --git a/webui/src/components/settings/system/AppsSettings.tsx b/webui/src/components/settings/system/AppsSettings.tsx
new file mode 100644
index 000000000..3f6aed783
--- /dev/null
+++ b/webui/src/components/settings/system/AppsSettings.tsx
@@ -0,0 +1,1531 @@
+import {
+ forwardRef,
+ useEffect,
+ useId,
+ useMemo,
+ useState,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction,
+} from "react";
+import {
+ Check,
+ ChevronDown,
+ ChevronRight,
+ Clipboard,
+ Database,
+ ExternalLink,
+ Loader2,
+ PlayCircle,
+ Plus,
+ RotateCcw,
+ Search,
+ Server,
+ SlidersHorizontal,
+ Trash2,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import {
+ DismissibleStatusMessage,
+ RestartRequiredNotice,
+ SETTINGS_SEARCH_INPUT_CLASS,
+ SettingsSectionTitle,
+} from "@/components/settings/shared/SettingsControls";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { SegmentedControl } from "@/components/ui/segmented-control";
+import { Textarea } from "@/components/ui/textarea";
+import { useLogoFallback } from "@/hooks/useLogoFallback";
+import { isGenericRepositoryLogoUrl, logoFallbackUrls } from "@/lib/provider-brand";
+import type {
+ CliAppInfo,
+ CliAppsPayload,
+ McpOAuthFlowPayload,
+ McpPresetInfo,
+ McpPresetsPayload,
+} from "@/lib/types";
+import { cn } from "@/lib/utils";
+
+export type AppsKindFilter = "ready" | "cli" | "mcp";
+type AppsCatalogItem =
+ | { id: string; kind: "cli"; app: CliAppInfo }
+ | { id: string; kind: "mcp"; preset: McpPresetInfo };
+type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
+type CustomMcpAuth = "none" | "oauth" | "headers";
+export const CLI_APPS_REFRESH_RETRY_MS = 2_000;
+export const CLI_APPS_REFRESH_MAX_RETRIES = 30;
+
+export interface CustomMcpForm {
+ name: string;
+ transport: CustomMcpTransport;
+ auth: CustomMcpAuth;
+ command: string;
+ args: string;
+ url: string;
+ env: string;
+ headers: string;
+ toolTimeout: string;
+}
+
+export const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
+ name: "",
+ transport: "stdio",
+ auth: "none",
+ command: "",
+ args: "",
+ url: "",
+ env: "",
+ headers: "",
+ toolTimeout: "30",
+};
+
+export function AppsCatalogSettings({
+ cliApps,
+ mcpPresets,
+ cliAppsLoading,
+ mcpPresetsLoading,
+ query,
+ filter,
+ cliActionKey,
+ mcpActionKey,
+ mcpOAuthFlow,
+ mcpOAuthPopupBlocked,
+ mcpOAuthCallbackUrl,
+ mcpOAuthCompleting,
+ mcpOAuthCallbackError,
+ cliMessage,
+ cliError,
+ cliFocusName,
+ mcpMessage,
+ mcpError,
+ mcpFieldValues,
+ customMcpForm,
+ mcpConfigImport,
+ showBrandLogos,
+ requiresRestartPending,
+ onQueryChange,
+ onFilterChange,
+ onCliAction,
+ onMcpAction,
+ onMcpOAuthConnect,
+ onMcpOAuthCancel,
+ onMcpOAuthOpen,
+ onMcpOAuthCallbackUrlChange,
+ onMcpOAuthComplete,
+ onDismissStatus,
+ onBackToChat,
+ onMcpFieldChange,
+ onCustomMcpFormChange,
+ onMcpConfigImportChange,
+ onSaveCustomMcp,
+ onImportMcpConfig,
+ onMcpToolsChange,
+ onRestart,
+ isRestarting,
+}: {
+ cliApps: CliAppsPayload | null;
+ mcpPresets: McpPresetsPayload | null;
+ cliAppsLoading: boolean;
+ mcpPresetsLoading: boolean;
+ query: string;
+ filter: AppsKindFilter;
+ cliActionKey: string | null;
+ mcpActionKey: string | null;
+ mcpOAuthFlow: McpOAuthFlowPayload | null;
+ mcpOAuthPopupBlocked: boolean;
+ mcpOAuthCallbackUrl: string;
+ mcpOAuthCompleting: boolean;
+ mcpOAuthCallbackError: string | null;
+ cliMessage: string | null;
+ cliError: string | null;
+ cliFocusName: string | null;
+ mcpMessage: string | null;
+ mcpError: string | null;
+ mcpFieldValues: Record>;
+ customMcpForm: CustomMcpForm;
+ mcpConfigImport: string;
+ showBrandLogos: boolean;
+ requiresRestartPending: boolean;
+ onQueryChange: (value: string) => void;
+ onFilterChange: (value: AppsKindFilter) => void;
+ onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
+ onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void;
+ onMcpOAuthConnect: (name: string) => void;
+ onMcpOAuthCancel: () => void;
+ onMcpOAuthOpen: () => void;
+ onMcpOAuthCallbackUrlChange: (value: string) => void;
+ onMcpOAuthComplete: () => void;
+ onDismissStatus: () => void;
+ onBackToChat: () => void;
+ onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void;
+ onCustomMcpFormChange: Dispatch>;
+ onMcpConfigImportChange: (value: string) => void;
+ onSaveCustomMcp: () => void;
+ onImportMcpConfig: () => void;
+ onMcpToolsChange: (name: string, enabledTools: string[]) => void;
+ onRestart?: () => void;
+ isRestarting?: boolean;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const filterOptions = [
+ { value: "ready", label: tx("settings.apps.filterAll", "Ready") },
+ { value: "cli", label: tx("settings.apps.filterCli", "Apps") },
+ { value: "mcp", label: tx("settings.apps.filterMcp", "MCP") },
+ ];
+ const normalizedQuery = query.trim().toLowerCase();
+ const items: AppsCatalogItem[] = [
+ ...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })),
+ ...(mcpPresets?.presets ?? []).map((preset) => ({
+ id: `mcp:${preset.name}`,
+ kind: "mcp" as const,
+ preset,
+ })),
+ ]
+ .filter((item) => {
+ if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery);
+ return filter === "ready" ? appsReady(item) : item.kind === filter;
+ })
+ .sort((left, right) => {
+ const rank = Number(!appsReady(left)) - Number(!appsReady(right));
+ return rank || appsTitle(left).localeCompare(appsTitle(right));
+ });
+ const focusedApp = cliFocusName
+ ? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed)
+ : null;
+ const loading =
+ (cliAppsLoading || mcpPresetsLoading) &&
+ !cliApps &&
+ !mcpPresets;
+ const cliAppCount = cliApps?.apps.length ?? 0;
+ const emptyTitle = normalizedQuery
+ ? tx("settings.apps.empty", "No tools match your search.")
+ : filter === "cli"
+ ? tx("settings.apps.emptyApps", "No apps available.")
+ : filter === "mcp"
+ ? tx("settings.apps.emptyIntegrations", "No MCP tools available.")
+ : tx("settings.apps.emptyReady", "No tools are ready yet.");
+ const emptyBrowseTarget: AppsKindFilter | null = normalizedQuery
+ ? null
+ : filter === "cli"
+ ? "mcp"
+ : filter === "mcp"
+ ? (cliAppCount ? "cli" : null)
+ : cliAppCount
+ ? "cli"
+ : "mcp";
+ const statusMessage =
+ cliError ||
+ mcpError ||
+ (!focusedApp ? cliMessage || mcpMessage : null);
+ const statusIsError = Boolean(cliError || mcpError);
+ const oauthStatusAnnouncement = mcpOAuthFlow
+ ? mcpOAuthStatusText(
+ mcpOAuthFlow.status,
+ mcpOAuthPopupBlocked,
+ tx,
+ mcpOAuthFlow.completion_input,
+ )
+ : "";
+ return (
+
+
{oauthStatusAnnouncement}
+
+
+
+
+ onQueryChange(event.target.value)}
+ placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")}
+ className={cn(
+ "h-12 rounded-[14px] pl-11 text-[15px]",
+ SETTINGS_SEARCH_INPUT_CLASS,
+ )}
+ />
+
+
onFilterChange(value as AppsKindFilter)}
+ />
+
+
+
+ {statusMessage ? (
+
+ ) : null}
+
+ {focusedApp ? (
+
+ ) : null}
+
+ {requiresRestartPending ? (
+
+ ) : null}
+
+
+
+
+ {filter === "mcp"
+ ? tx("settings.apps.mcpTools", "MCP tools")
+ : tx("settings.apps.featured", "Tools")}
+
+
+ {items.length}
+
+
+ {loading ? (
+
+
+ {tx("settings.apps.loading", "Loading Apps...")}
+
+ ) : items.length ? (
+
+ {items.map((item) =>
+ item.kind === "cli" ? (
+
+ ) : (
+
+ ),
+ )}
+
+ ) : (
+
+
{emptyTitle}
+ {normalizedQuery ? (
+
onQueryChange("")}
+ >
+ {tx("settings.apps.clearSearch", "Clear search")}
+
+ ) : emptyBrowseTarget ? (
+
onFilterChange(emptyBrowseTarget)}
+ >
+ {emptyBrowseTarget === "cli"
+ ? tx("settings.apps.browseApps", "Browse apps")
+ : tx("settings.apps.browseIntegrations", "Browse MCP tools")}
+
+ ) : (
+
+ {tx(
+ "settings.apps.emptyIntegrationsHint",
+ "Add a custom MCP server below.",
+ )}
+
+ )}
+
+ )}
+
+
+ {filter === "mcp" ? (
+
+ ) : null}
+
+ );
+}
+
+function CliAppsCatalogRow({
+ app,
+ actionKey,
+ showBrandLogos,
+ onAction,
+}: {
+ app: CliAppInfo;
+ actionKey: string | null;
+ showBrandLogos: boolean;
+ onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const installBusy = actionKey === `install:${app.name}`;
+ const updateBusy = actionKey === `update:${app.name}`;
+ const uninstallBusy = actionKey === `uninstall:${app.name}`;
+ const testBusy = actionKey === `test:${app.name}`;
+ const busy = installBusy || updateBusy || uninstallBusy || testBusy;
+ const description = app.description || app.requires || app.entry_point || app.name;
+
+ return (
+
+
+
+
+
{app.display_name}
+
{tx("settings.apps.cliLabel", "App")}
+
+
{description}
+
+
+ {app.installed ? (
+ <>
+
+
+
+
+
+
+
+ onAction("test", app.name)}>
+
+ {tx("settings.cliApps.test", "Test CLI")}
+
+ onAction("update", app.name)}>
+
+ {tx("settings.cliApps.update", "Update CLI")}
+
+ onAction("uninstall", app.name)}
+ >
+
+ {tx("settings.cliApps.uninstall", "Uninstall CLI")}
+
+
+
+
onAction("uninstall", app.name)}
+ >
+
+
+ >
+ ) : app.install_supported ? (
+
onAction("install", app.name)}
+ >
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+}
+
+function McpAppsCatalogRow({
+ preset,
+ values,
+ actionKey,
+ oauthFlow,
+ oauthPopupBlocked,
+ oauthCallbackUrl,
+ oauthCompleting,
+ oauthCallbackError,
+ showBrandLogos,
+ onFieldChange,
+ onAction,
+ onOAuthConnect,
+ onOAuthCancel,
+ onOAuthOpen,
+ onOAuthCallbackUrlChange,
+ onOAuthComplete,
+ onToolsChange,
+}: {
+ preset: McpPresetInfo;
+ values: Record;
+ actionKey: string | null;
+ oauthFlow: McpOAuthFlowPayload | null;
+ oauthPopupBlocked: boolean;
+ oauthCallbackUrl: string;
+ oauthCompleting: boolean;
+ oauthCallbackError: string | null;
+ showBrandLogos: boolean;
+ onFieldChange: (presetName: string, fieldName: string, value: string) => void;
+ onAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void;
+ onOAuthConnect: (name: string) => void;
+ onOAuthCancel: () => void;
+ onOAuthOpen: () => void;
+ onOAuthCallbackUrlChange: (value: string) => void;
+ onOAuthComplete: () => void;
+ onToolsChange: (name: string, enabledTools: string[]) => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const [setupOpen, setSetupOpen] = useState(false);
+ const [toolsOpen, setToolsOpen] = useState(false);
+ const enableBusy = actionKey === `enable:${preset.name}`;
+ const removeBusy = actionKey === `remove:${preset.name}`;
+ const testBusy = actionKey === `test:${preset.name}`;
+ const toolsBusy = actionKey === `tools:${preset.name}`;
+ const oauthBusy = actionKey === `oauth:${preset.name}`;
+ const anotherOAuthBusy = Boolean(actionKey?.startsWith("oauth:")) && !oauthBusy;
+ const busy = enableBusy || removeBusy || testBusy || toolsBusy || oauthBusy;
+ const isOAuth = preset.auth === "oauth";
+ const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
+ const hasFields = preset.required_fields.length > 0;
+ const needsSetupInput = missingFields.length > 0;
+ const readyInstalled = preset.installed && preset.configured;
+ const statusLabel = mcpPresetStatusLabel(preset.status, tx);
+ const canEnable =
+ preset.install_supported &&
+ (missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
+ const toolNames = preset.tool_names ?? [];
+ const enabledTools = preset.enabled_tools ?? ["*"];
+ const allowAllTools = enabledTools.includes("*");
+ const enabledSet = new Set(allowAllTools ? toolNames : enabledTools);
+ const description = preset.description || preset.note || preset.requires || preset.name;
+ const manualCallback =
+ oauthFlow?.completion_input === "callback_url" && Boolean(oauthFlow.authorization_url);
+ const callbackInputId = `mcp-oauth-callback-${preset.name}`;
+ const callbackHelpId = `${callbackInputId}-help`;
+ const callbackErrorId = `${callbackInputId}-error`;
+
+ useEffect(() => {
+ if (preset.configured || !preset.install_supported) setSetupOpen(false);
+ }, [preset.configured, preset.install_supported]);
+
+ const enableOrOpenSetup = () => {
+ if (isOAuth) {
+ onOAuthConnect(preset.name);
+ return;
+ }
+ if (needsSetupInput || (preset.installed && !preset.configured && hasFields)) {
+ setSetupOpen(true);
+ return;
+ }
+ onAction("enable", preset.name, values);
+ };
+ const submitSetup = () => {
+ if (!canEnable) return;
+ onAction("enable", preset.name, values);
+ };
+ const setTools = (next: string[]) => onToolsChange(preset.name, next);
+ const toggleTool = (toolName: string) => {
+ const next = new Set(allowAllTools ? toolNames : enabledTools);
+ if (next.has(toolName)) next.delete(toolName);
+ else next.add(toolName);
+ const nextValues = Array.from(next);
+ setTools(nextValues.length === toolNames.length ? ["*"] : nextValues);
+ };
+
+ return (
+
+
+
+
+
+
{preset.display_name}
+
{tx("settings.apps.mcpLabel", "MCP")}
+
+
{description}
+
+
+ {readyInstalled ? (
+ <>
+
+
+
+
+
+
+
+ onAction("test", preset.name)}>
+
+ {tx("settings.mcp.test", "Test")}
+
+ {toolNames.length ? (
+ setToolsOpen((open) => !open)}>
+
+ {tx("settings.mcp.toolScope", "Tools")}
+
+ ) : null}
+ onAction("remove", preset.name)}
+ >
+
+ {tx("settings.mcp.remove", "Remove")}
+
+
+
+
onAction("remove", preset.name)}
+ >
+
+
+ >
+ ) : oauthFlow ? (
+ <>
+
+
+ >
+ ) : isOAuth && preset.install_supported ? (
+
onOAuthConnect(preset.name)}
+ />
+ ) : preset.installed && !preset.configured ? (
+ {
+ if (hasFields) setSetupOpen(true);
+ else onAction("enable", preset.name, values);
+ }}
+ />
+ ) : preset.install_supported ? (
+
+ ) : (
+
+ )}
+
+
+
+ {manualCallback ? (
+ {
+ event.preventDefault();
+ onOAuthComplete();
+ }}
+ >
+
+
+
+
+ {t("settings.oauth.pasteCallbackToContinue")}
+
+
+ {tx(
+ "settings.mcp.manualCallbackHelp",
+ "After approving access, the localhost page will not load. Copy its full URL from the address bar and paste it here.",
+ )}
+
+
+
+
+
+ {t("settings.oauth.callbackUrl")}
+
+
onOAuthCallbackUrlChange(event.target.value)}
+ placeholder={t("settings.oauth.callbackUrlPlaceholder")}
+ autoComplete="off"
+ spellCheck={false}
+ required
+ aria-invalid={Boolean(oauthCallbackError)}
+ aria-describedby={
+ oauthCallbackError
+ ? `${callbackHelpId} ${callbackErrorId}`
+ : callbackHelpId
+ }
+ className="min-h-[88px] w-full resize-y break-all font-mono text-[12px] leading-5"
+ />
+ {oauthCallbackError ? (
+
+ {oauthCallbackError}
+
+ ) : null}
+
+
+
+ {tx("settings.mcp.continueSignIn", "Continue sign-in")}
+
+
+
+ {oauthCompleting ? (
+
+ ) : null}
+ {t("settings.oauth.finishSignIn")}
+
+
+
+ ) : oauthFlow && oauthPopupBlocked && oauthFlow.authorization_url ? (
+
+
+
+ {mcpOAuthStatusText(
+ oauthFlow.status,
+ oauthPopupBlocked,
+ tx,
+ oauthFlow.completion_input,
+ )}
+
+
+
+
+ {tx("settings.mcp.continueSignIn", "Continue sign-in")}
+
+
+
+
+ ) : null}
+
+ {setupOpen && preset.install_supported && hasFields ? (
+
+
+
+
+ {t("settings.mcp.connectTitle", {
+ name: preset.display_name,
+ defaultValue: "Connect {{name}}",
+ })}
+
+
+ {tx("settings.mcp.connectHint", "Add the key from your account settings.")}
+
+
+
setSetupOpen(false)}
+ className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold text-muted-foreground"
+ >
+ {tx("settings.actions.cancel", "Cancel")}
+
+
+
+ {preset.required_fields.map((field) => (
+
+
+ {field.label}
+ {field.configured ? (
+
+ {tx("settings.mcp.configured", "configured")}
+
+ ) : null}
+
+ onFieldChange(preset.name, field.name, event.target.value)}
+ placeholder={
+ field.configured
+ ? tx("settings.mcp.keepExisting", "Leave blank to keep existing")
+ : field.placeholder
+ }
+ className="h-9 rounded-full bg-background/80 text-[12.5px]"
+ />
+
+ ))}
+
+
+
+ {enableBusy ? (
+
+ ) : (
+
+ )}
+ {preset.installed
+ ? tx("settings.mcp.updateSetup", "Update setup")
+ : tx("settings.mcp.saveAndEnable", "Save and enable")}
+
+
+
+ ) : null}
+
+ {toolsOpen && readyInstalled && toolNames.length ? (
+
+
+
+ {tx("settings.mcp.toolScope", "Tools")}
+
+
+ setTools(["*"])}
+ className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold"
+ >
+ {tx("settings.mcp.allTools", "All")}
+
+ setTools([])}
+ className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold"
+ >
+ {tx("settings.mcp.noTools", "None")}
+
+
+
+
+ {toolNames.map((toolName) => {
+ const selected = enabledSet.has(toolName);
+ return (
+ toggleTool(toolName)}
+ className={cn(
+ "max-w-full rounded-full border px-2.5 py-1 font-mono text-[11px] transition-colors",
+ selected
+ ? "border-blue-500/25 bg-blue-500/10 text-blue-700 dark:text-blue-300"
+ : "border-border/55 bg-muted/30 text-muted-foreground hover:bg-muted/60",
+ )}
+ >
+ {toolName}
+
+ );
+ })}
+
+
+ ) : null}
+
+ );
+}
+
+function AppsTypeBadge({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export const AppsActionButton = forwardRef void;
+ children?: ReactNode;
+}>(function AppsActionButton({
+ ariaLabel,
+ visibleLabel,
+ busy,
+ disabled,
+ tone = "default",
+ onClick,
+ children,
+}, ref) {
+ return (
+
+ {busy ? : children}
+ {visibleLabel ? {visibleLabel} : null}
+
+ );
+});
+
+function appsTitle(item: AppsCatalogItem): string {
+ return item.kind === "cli" ? item.app.display_name : item.preset.display_name;
+}
+
+function appsReady(item: AppsCatalogItem): boolean {
+ return item.kind === "cli" ? item.app.installed : item.preset.installed && item.preset.configured;
+}
+
+function appsSearchText(item: AppsCatalogItem): string {
+ if (item.kind === "cli") {
+ const app = item.app;
+ return [
+ app.display_name,
+ app.name,
+ app.category,
+ app.description,
+ app.requires,
+ app.entry_point,
+ app.source,
+ ]
+ .join(" ")
+ .toLowerCase();
+ }
+ const preset = item.preset;
+ return [
+ preset.display_name,
+ preset.name,
+ preset.category,
+ preset.description,
+ preset.requires,
+ preset.note,
+ preset.transport,
+ preset.source ?? "",
+ ]
+ .join(" ")
+ .toLowerCase();
+}
+
+function McpCustomServerPanel({
+ form,
+ configImport,
+ actionKey,
+ onFormChange,
+ onConfigImportChange,
+ onSave,
+ onImportConfig,
+}: {
+ form: CustomMcpForm;
+ configImport: string;
+ actionKey: string | null;
+ onFormChange: Dispatch>;
+ onConfigImportChange: (value: string) => void;
+ onSave: () => void;
+ onImportConfig: () => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
+ const oauthHelpId = useId();
+ const headersInputId = useId();
+ const headersHelpId = useId();
+ const [activeMode, setActiveMode] = useState<"custom" | "import" | null>(null);
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const customBusy = actionKey?.startsWith("custom:") ?? false;
+ const importBusy = actionKey === "import" || actionKey === "import-cursor";
+ const remote = form.transport !== "stdio";
+ const canSave = Boolean(form.name.trim()) && (remote ? Boolean(form.url.trim()) : Boolean(form.command.trim()));
+ const update = (key: K, value: CustomMcpForm[K]) => {
+ onFormChange((prev) => ({ ...prev, [key]: value }));
+ };
+ const transports: Array<{ value: CustomMcpTransport; label: string }> = [
+ { value: "stdio", label: "stdio" },
+ { value: "streamableHttp", label: "HTTP" },
+ { value: "sse", label: "SSE" },
+ ];
+ const authenticationOptions: Array<{ value: CustomMcpAuth; label: string }> = [
+ { value: "none", label: tx("settings.mcp.authNone", "None") },
+ { value: "oauth", label: "OAuth" },
+ { value: "headers", label: tx("settings.mcp.authHeaders", "Headers") },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+ {tx("settings.mcp.moreOptions", "Add MCP server")}
+
+
+ {tx(
+ "settings.mcp.moreOptionsSubtitle",
+ "Connect a custom MCP server or import an existing configuration.",
+ )}
+
+
+
+
+ setActiveMode((mode) => (mode === "custom" ? null : "custom"))}
+ className="h-8 rounded-full px-3 text-[12px] font-semibold"
+ >
+
+ {tx("settings.mcp.customAction", "Custom")}
+
+ setActiveMode((mode) => (mode === "import" ? null : "import"))}
+ className="h-8 rounded-full px-3 text-[12px] font-semibold"
+ >
+
+ {tx("settings.mcp.importAction", "Import")}
+
+
+
+
+ {activeMode === "custom" ? (
+
+
+
+ {remote ? (
+
+
+
+ {tx("settings.mcp.authentication", "Authentication")}
+
+ update("auth", value as CustomMcpAuth)}
+ className="w-full sm:w-auto"
+ itemClassName="min-w-0 flex-1 sm:flex-none"
+ />
+
+ {form.auth === "oauth" ? (
+
+ {tx(
+ "settings.mcp.oauthAfterSave",
+ "Save the server, then select Connect to sign in.",
+ )}
+
+ ) : null}
+
+ ) : null}
+
+ {remote && form.auth === "headers" ? (
+
+
+ {tx("settings.mcp.headers", "Headers JSON")}
+
+
+ ) : null}
+
+
setAdvancedOpen((open) => !open)}
+ className="mt-2 h-8 rounded-full px-2 text-[12px] font-medium text-muted-foreground hover:text-foreground"
+ >
+
+ {advancedOpen
+ ? tx("settings.mcp.hideAdvanced", "Hide advanced")
+ : tx("settings.mcp.advancedOptions", "Advanced options")}
+
+
+ {advancedOpen ? (
+
+ {!remote ? (
+
+
+ {tx("settings.mcp.args", "Args JSON")}
+
+ update("args", event.target.value)}
+ placeholder={'["-y", "docs-mcp"]'}
+ className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
+ />
+
+ ) : null}
+
+
+ {tx("settings.mcp.env", "Env JSON")}
+
+ update("env", event.target.value)}
+ placeholder={'{"API_KEY":"..."}'}
+ className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
+ />
+
+
+
+ {tx("settings.mcp.timeout", "Tool timeout")}
+
+ update("toolTimeout", event.target.value)}
+ inputMode="numeric"
+ className="h-9 rounded-full bg-background/80 text-[12.5px]"
+ />
+
+
+ ) : null}
+
+
+
+ {customBusy ? : }
+ {tx("settings.mcp.saveCustom", "Save MCP")}
+
+
+
+ ) : null}
+
+ {activeMode === "import" ? (
+
+
+
+
+ {tx("settings.mcp.configImport", "Import mcp.json")}
+
+ onConfigImportChange(event.target.value)}
+ placeholder={'{"mcpServers":{"docs":{"command":"npx","args":["-y","docs-mcp"]}}}'}
+ className="min-h-[84px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
+ />
+
+
+ {importBusy ? : }
+ {tx("settings.mcp.importConfig", "Import")}
+
+
+
+ ) : null}
+
+ );
+}
+
+function mcpOAuthStatusText(
+ status: McpOAuthFlowPayload["status"],
+ popupBlocked: boolean,
+ tx: (key: string, fallback: string) => string,
+ completionInput?: McpOAuthFlowPayload["completion_input"],
+): string {
+ switch (status) {
+ case "starting":
+ return tx("settings.mcp.preparingSignIn", "Preparing secure sign-in...");
+ case "authorization_required":
+ if (completionInput === "callback_url") {
+ return tx(
+ "settings.mcp.manualCallbackRequired",
+ "Finish signing in, then paste the callback URL into nanobot.",
+ );
+ }
+ return popupBlocked
+ ? tx("settings.mcp.openSignInToContinue", "Open the sign-in page to continue.")
+ : tx("settings.mcp.finishSignInInBrowser", "Finish signing in in the browser window.");
+ case "connecting":
+ return tx("settings.mcp.finishingConnection", "Finishing connection...");
+ case "authorized":
+ return tx("settings.mcp.activatingTools", "Activating tools...");
+ case "connected":
+ return tx("settings.mcp.connected", "Connected.");
+ case "failed":
+ return tx("settings.mcp.connectionFailed", "Connection failed.");
+ case "cancelled":
+ return tx("settings.mcp.connectionCancelled", "Connection cancelled.");
+ }
+}
+
+function mcpPresetStatusLabel(
+ status: string,
+ tx: (key: string, fallback: string) => string,
+): string {
+ switch (status) {
+ case "configured":
+ return tx("settings.mcp.statusConfigured", "Configured");
+ case "missing_credentials":
+ return tx("settings.mcp.statusMissingCredentials", "Needs key");
+ case "missing_dependency":
+ return tx("settings.mcp.statusMissingDependency", "Needs dependency");
+ case "coming_soon":
+ return tx("settings.mcp.statusComingSoon", "Coming soon");
+ default:
+ return tx("settings.mcp.statusNotInstalled", "Not enabled");
+ }
+}
+
+function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; showBrandLogos: boolean }) {
+ const bg = preset.brand_color || "hsl(var(--muted))";
+ const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
+ const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
+ const initials = preset.display_name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join("") || preset.name.slice(0, 2).toUpperCase();
+
+ if (showBrandLogos && logoUrl) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {initials}
+
+ );
+}
+
+function CliAppReadyPanel({
+ app,
+ showBrandLogos,
+ onBackToChat,
+}: {
+ app: CliAppInfo;
+ showBrandLogos: boolean;
+ onBackToChat: () => void;
+}) {
+ const { t } = useTranslation();
+ const [copied, setCopied] = useState(false);
+ const prompt = t("settings.cliApps.readyPrompt", {
+ name: app.name,
+ defaultValue: "Use @{{name}} to inspect what this CLI can do.",
+ });
+ const copyPrompt = () => {
+ if (!navigator.clipboard) return;
+ void navigator.clipboard.writeText(prompt).then(() => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1400);
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ {app.display_name}
+
+
+
+ {t("settings.cliApps.readyStatus", { defaultValue: "Ready" })}
+
+
+
+ @{app.name}
+ ·
+ {app.entry_point || app.name}
+ ·
+ {app.category}
+
+
+
+
+ {copied ? : null}
+ {copied
+ ? t("settings.cliApps.readyCopied", { defaultValue: "Copied" })
+ : t("settings.cliApps.readyTry", { name: app.name, defaultValue: "Try @{{name}}" })}
+
+
+ {t("settings.cliApps.openChat", { defaultValue: "Open chat" })}
+
+
+
+
+
+ );
+}
+
+function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
+ const logoUrls = useMemo(
+ () => (isGenericRepositoryLogoUrl(app.logo_url) ? [] : logoFallbackUrls(app.logo_url)),
+ [app.logo_url],
+ );
+ const { logoUrl, logoLoaded, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
+ const initials = app.display_name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join("") || app.name.slice(0, 2).toUpperCase();
+
+ const showRemoteLogo = showBrandLogos && Boolean(logoUrl);
+
+ return (
+
+
+ {initials}
+
+ {showRemoteLogo ? (
+
+ ) : null}
+
+ );
+}
diff --git a/webui/src/components/settings/system/AutomationsSettings.tsx b/webui/src/components/settings/system/AutomationsSettings.tsx
new file mode 100644
index 000000000..af54c505e
--- /dev/null
+++ b/webui/src/components/settings/system/AutomationsSettings.tsx
@@ -0,0 +1,1527 @@
+import { useEffect, useMemo, useState } from "react";
+import type { FormEvent, ReactNode } from "react";
+import {
+ ArrowUpDown,
+ Check,
+ ChevronDown,
+ ChevronRight,
+ CircleAlert,
+ Clipboard,
+ ExternalLink,
+ Loader2,
+ PauseCircle,
+ Pencil,
+ PlayCircle,
+ Search,
+ Trash2,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { channelUiPresentation } from "@/channel-plugins/registry";
+import { SETTINGS_SEARCH_INPUT_CLASS } from "@/components/settings/shared/SettingsControls";
+import { AppsActionButton } from "@/components/settings/system/AppsSettings";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { SegmentedControl } from "@/components/ui/segmented-control";
+import { Textarea } from "@/components/ui/textarea";
+import { copyTextToClipboard } from "@/lib/clipboard";
+import { fmtDateTime, relativeTime } from "@/lib/format";
+import type { AutomationsPayload, AutomationUpdatePayload, SessionAutomationJob } from "@/lib/types";
+import { cn } from "@/lib/utils";
+
+export type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
+export type AutomationSort = "next" | "last" | "updated" | "name";
+export type AutomationAction = "enable" | "disable" | "delete" | "run";
+
+export function AutomationsSettings({
+ payload,
+ loading,
+ query,
+ filter,
+ sort,
+ actionKey,
+ error,
+ onQueryChange,
+ onFilterChange,
+ onSortChange,
+ onAction,
+ onRequestEdit,
+ onRequestDelete,
+ onBackToChat,
+}: {
+ payload: AutomationsPayload | null;
+ loading: boolean;
+ query: string;
+ filter: AutomationFilter;
+ sort: AutomationSort;
+ actionKey: string | null;
+ error: string | null;
+ onQueryChange: (value: string) => void;
+ onFilterChange: (value: AutomationFilter) => void;
+ onSortChange: (value: AutomationSort) => void;
+ onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise;
+ onRequestEdit: (job: SessionAutomationJob) => void;
+ onRequestDelete: (job: SessionAutomationJob) => void;
+ onBackToChat: () => void;
+}) {
+ const { t, i18n } = useTranslation();
+ const tx = (key: string, fallback: string, values?: Record) =>
+ t(key, { defaultValue: fallback, ...(values ?? {}) });
+ const jobs = payload?.jobs ?? [];
+ const locale = i18n.resolvedLanguage || i18n.language;
+ const [selectedJobId, setSelectedJobId] = useState(null);
+ const filtered = useMemo(() => {
+ const searchTokens = parseAutomationSearchQuery(query);
+ return sortAutomationJobs(jobs, sort)
+ .filter((job) => automationMatchesFilter(job, filter))
+ .filter((job) => !searchTokens.length || automationMatchesSearch(job, searchTokens));
+ }, [filter, jobs, query, sort]);
+ const activeCount = jobs.filter((job) => {
+ const key = automationStatusKey(job);
+ return key === "active" || key === "running";
+ }).length;
+ const pausedCount = jobs.filter((job) => automationStatusKey(job) === "paused").length;
+ const failedCount = jobs.filter(automationNeedsAttention).length;
+ const systemCount = jobs.filter((job) => job.protected).length;
+ const summaryOptions: Array<{ value: AutomationFilter; label: string; count: number }> = [
+ { value: "all", label: tx("settings.automations.filters.all", "All"), count: jobs.length },
+ { value: "active", label: tx("settings.automations.filters.active", "Active"), count: activeCount },
+ { value: "paused", label: tx("settings.automations.filters.paused", "Paused"), count: pausedCount },
+ { value: "failed", label: tx("settings.automations.filters.failed", "Needs attention"), count: failedCount },
+ { value: "system", label: tx("settings.automations.filters.system", "System"), count: systemCount },
+ ];
+ const sortLabel = {
+ next: tx("settings.automations.sort.next", "Next run"),
+ last: tx("settings.automations.sort.last", "Last run"),
+ updated: tx("settings.automations.sort.updated", "Updated"),
+ name: tx("settings.automations.sort.name", "Name"),
+ } satisfies Record;
+ const selectedJob = filtered.find((job) => job.id === selectedJobId) ?? filtered[0] ?? null;
+
+ useEffect(() => {
+ if (!filtered.length) {
+ if (selectedJobId !== null) setSelectedJobId(null);
+ return;
+ }
+ if (!selectedJobId || !filtered.some((job) => job.id === selectedJobId)) {
+ setSelectedJobId(filtered[0].id);
+ }
+ }, [filtered, selectedJobId]);
+
+ return (
+
+ {jobs.length ? (
+
+
+
+
+ {summaryOptions.map((option) => (
+ onFilterChange(option.value)}
+ className={cn(
+ "inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
+ filter === option.value && "bg-background text-foreground",
+ automationFilterToneClass(option.value, option.count, filter === option.value),
+ )}
+ >
+ {option.label}
+
+ {option.count}
+
+
+ ))}
+
+
+
+
+
+
+ onQueryChange(event.target.value)}
+ placeholder={tx(
+ "settings.automations.search",
+ "Search task, message, linked chat, or schedule",
+ )}
+ className={cn(
+ "h-9 w-full rounded-[13px] pl-9 text-[13px]",
+ SETTINGS_SEARCH_INPUT_CLASS,
+ )}
+ />
+
+
+
+
+
+ {sortLabel[sort]}
+
+
+
+
+ {(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
+ onSortChange(value)}>
+ {sortLabel[value]}
+ {sort === value ? : null}
+
+ ))}
+
+
+
+
+
+ ) : null}
+
+ {error ? (
+
+
+ {error}
+
+ ) : null}
+
+ {loading && !payload ? (
+
+
+ {tx("settings.automations.loading", "Loading automations...")}
+
+ ) : filtered.length && selectedJob ? (
+
+
+
+
+ {tx("settings.automations.queue", "Queue")}
+
+
+ {filtered.length}
+
+
+
+ {filtered.map((job) => (
+
setSelectedJobId(job.id)}
+ />
+ ))}
+
+
+
+
+ ) : (
+
+
+ {jobs.length
+ ? tx("settings.automations.noMatches", "No automations match this view.")
+ : tx("settings.automations.empty", "No automations yet.")}
+
+ {!jobs.length ? (
+ <>
+
+ {tx(
+ "settings.automations.emptyHint",
+ "Create automations in a chat so they keep the right context.",
+ )}
+
+
+ {tx("settings.automations.emptyAction", "Open a chat")}
+
+ >
+ ) : (
+
{
+ onQueryChange("");
+ onFilterChange("all");
+ }}
+ >
+ {tx("settings.automations.clearFilters", "Clear filters")}
+
+ )}
+
+ )}
+
+ );
+}
+
+function AutomationListItem({
+ job,
+ locale,
+ selected,
+ onSelect,
+}: {
+ job: SessionAutomationJob;
+ locale: string;
+ selected: boolean;
+ onSelect: () => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string, values?: Record) =>
+ t(key, { defaultValue: fallback, ...(values ?? {}) });
+ const status = automationStatus(job, tx);
+ const origin = automationOriginLabel(job, tx);
+ const nextRun = formatAutomationNext(job, tx);
+ const summary = automationSummary(job, tx);
+
+ return (
+
+
+
+
+
+
+ {job.name || job.id}
+
+
+
+ {summary}
+
+
+
+ {nextRun}
+
+
+ {origin}
+
+
+
+
+ {status.label}
+
+ {job.delete_after_run ? (
+
+ {tx("settings.automations.oneShot", "One-time")}
+
+ ) : null}
+
+
+
+
+ );
+}
+
+function AutomationDetailPanel({
+ job,
+ locale,
+ actionKey,
+ onAction,
+ onRequestEdit,
+ onRequestDelete,
+}: {
+ job: SessionAutomationJob;
+ locale: string;
+ actionKey: string | null;
+ onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise;
+ onRequestEdit: (job: SessionAutomationJob) => void;
+ onRequestDelete: (job: SessionAutomationJob) => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string, values?: Record) =>
+ t(key, { defaultValue: fallback, ...(values ?? {}) });
+ const status = automationStatus(job, tx);
+ const origin = automationOriginLabel(job, tx);
+ const originHref = job.origin?.channel === "websocket" && job.origin.session_key
+ ? `#/chat/${encodeURIComponent(job.origin.session_key)}`
+ : null;
+ const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null;
+ const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null;
+ const localTrigger = isLocalTriggerAutomation(job);
+ const triggerCommand = automationTriggerCommand(job);
+ const message = automationDetailText(job, tx);
+ const messageLabel = localTrigger
+ ? tx("settings.automations.fields.command", "Command")
+ : tx("settings.automations.fields.message", "Message");
+ const schedule = formatAutomationSchedule(job, locale, tx);
+ const [messageExpanded, setMessageExpanded] = useState(false);
+ const [commandCopied, setCommandCopied] = useState(false);
+ const messageNeedsExpansion = automationMessageNeedsExpansion(message);
+
+ useEffect(() => {
+ setMessageExpanded(false);
+ setCommandCopied(false);
+ }, [job.id]);
+
+ return (
+
+
+
+
+
+
+ {job.name || job.id}
+
+
{status.label}
+ {job.delete_after_run ? (
+
{tx("settings.automations.oneShot", "One-time")}
+ ) : null}
+
+
+ {schedule} · {origin}
+
+
+
+
+
+
+
+
+
+
+
+ {messageLabel}
+
+ {localTrigger && triggerCommand ? (
+
{
+ void copyTextToClipboard(triggerCommand).then((ok) => {
+ if (ok) setCommandCopied(true);
+ });
+ }}
+ >
+ {commandCopied ? (
+
+ ) : (
+
+ )}
+ {commandCopied
+ ? tx("settings.automations.commandCopied", "Copied")
+ : tx("settings.automations.copyCommand", "Copy")}
+
+ ) : null}
+
+
+ {message}
+
+ {messageNeedsExpansion ? (
+ setMessageExpanded((value) => !value)}
+ >
+ {messageExpanded
+ ? tx("settings.automations.message.showLess", "Show less")
+ : tx("settings.automations.message.showMore", "Show full message")}
+
+ ) : null}
+
+
+
+
+ {formatAutomationNext(job, tx)}
+
+
+ {originHref ? (
+
+ {origin}
+
+
+ ) : (
+ origin
+ )}
+
+
+
+ {job.state.last_error ? (
+
+ {job.state.last_error}
+
+ ) : null}
+
+
+
+
+
+ {schedule}
+
+
+
+ {created ? (
+
+
+ {tx("settings.automations.labels.created", "Created")}
+
+
{created}
+
+ ) : null}
+ {updated ? (
+
+
+ {tx("settings.automations.labels.updated", "Updated")}
+
+
{updated}
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
+
+function AutomationActionGroup({
+ job,
+ actionKey,
+ onAction,
+ onRequestEdit,
+ onRequestDelete,
+}: {
+ job: SessionAutomationJob;
+ actionKey: string | null;
+ onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise;
+ onRequestEdit: (job: SessionAutomationJob) => void;
+ onRequestDelete: (job: SessionAutomationJob) => void;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string, values?: Record) =>
+ t(key, { defaultValue: fallback, ...(values ?? {}) });
+ const canManage = !job.protected;
+ const hasLinkedChat = Boolean(job.origin);
+ const localTrigger = isLocalTriggerAutomation(job);
+ const canRun = canManage && hasLinkedChat && job.enabled && !job.state.pending && !localTrigger;
+ const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
+ const canToggle = canManage && (job.enabled || hasLinkedChat);
+ const toggleBusy = actionKey === `${toggleAction}:${job.id}`;
+
+ if (!canManage) {
+ return (
+
+ {tx("settings.automations.protected", "Protected")}
+
+ );
+ }
+
+ return (
+
+
onRequestEdit(job)}
+ >
+
+
+ {!localTrigger ? (
+
void onAction("run", job)}
+ >
+
+
+ ) : null}
+
void onAction(toggleAction, job)}
+ >
+ {job.enabled ? (
+
+ ) : (
+
+ )}
+
+
onRequestDelete(job)}
+ >
+
+
+
+ );
+}
+
+function AutomationStatusBadge({
+ tone = "neutral",
+ children,
+}: {
+ tone?: "neutral" | "success" | "warning";
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function automationMessageNeedsExpansion(message: string): boolean {
+ return message.length > 360 || message.split(/\r?\n/).length > 6;
+}
+
+function AutomationDetail({
+ label,
+ title,
+ secondary,
+ children,
+}: {
+ label: string;
+ title?: string;
+ secondary?: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+
+
+ {children}
+
+ {secondary ? (
+
+ {secondary}
+
+ ) : null}
+
+
+ );
+}
+
+type AutomationEveryUnit = "second" | "minute" | "hour" | "day";
+
+type AutomationEditDraft = {
+ name: string;
+ message: string;
+ scheduleKind: "at" | "every" | "cron";
+ everyValue: string;
+ everyUnit: AutomationEveryUnit;
+ cronExpr: string;
+ tz: string;
+ atLocal: string;
+};
+type AutomationScheduleUpdate = NonNullable;
+
+const AUTOMATION_EVERY_UNITS: Array<{ value: AutomationEveryUnit; ms: number }> = [
+ { value: "second", ms: 1000 },
+ { value: "minute", ms: 60_000 },
+ { value: "hour", ms: 3_600_000 },
+ { value: "day", ms: 86_400_000 },
+];
+
+export function AutomationEditDialog({
+ job,
+ saving,
+ onOpenChange,
+ onSave,
+}: {
+ job: SessionAutomationJob | null;
+ saving: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSave: (job: SessionAutomationJob, values: AutomationUpdatePayload) => void | Promise;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string, values?: Record) =>
+ t(key, { defaultValue: fallback, ...(values ?? {}) });
+ const [draft, setDraft] = useState(() => automationDraftFromJob(null));
+ const localTrigger = isLocalTriggerAutomation(job);
+
+ useEffect(() => {
+ setDraft(automationDraftFromJob(job));
+ }, [job]);
+
+ const validation = automationEditDraftError(draft, job, tx);
+ const scheduleOptions = [
+ { value: "every", label: tx("settings.automations.scheduleTypes.every", "Interval") },
+ { value: "cron", label: tx("settings.automations.scheduleTypes.cron", "Cron") },
+ { value: "at", label: tx("settings.automations.scheduleTypes.at", "Once") },
+ ];
+ const unitLabels: Record = {
+ second: tx("settings.automations.everyUnits.second", "Seconds"),
+ minute: tx("settings.automations.everyUnits.minute", "Minutes"),
+ hour: tx("settings.automations.everyUnits.hour", "Hours"),
+ day: tx("settings.automations.everyUnits.day", "Days"),
+ };
+
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
+ const payload = automationUpdatePayloadFromDraft(draft, job);
+ if (!job || typeof payload === "string") return;
+ void onSave(job, payload);
+ };
+
+ return (
+
+ {job ? (
+
+
+
+ {tx("settings.automations.editTitle", "Edit automation")}
+
+
+
+
+
+ {tx("settings.automations.fields.name", "Name")}
+
+ setDraft((prev) => ({ ...prev, name: event.target.value }))}
+ className="h-10 rounded-[12px]"
+ />
+
+
+ {!localTrigger ? (
+
+
+ {tx("settings.automations.fields.message", "Message")}
+
+ setDraft((prev) => ({ ...prev, message: event.target.value }))}
+ className="min-h-[160px] resize-none rounded-[12px] text-[13px] leading-5"
+ />
+
+ ) : null}
+
+ {!localTrigger ? (
+
+
+ {tx("settings.automations.fields.scheduleType", "Schedule type")}
+
+
+ setDraft((prev) => ({
+ ...prev,
+ scheduleKind: value as AutomationEditDraft["scheduleKind"],
+ }))
+ }
+ />
+
+ ) : null}
+
+ {!localTrigger && draft.scheduleKind === "every" ? (
+
+
+
+ {tx("settings.automations.fields.every", "Every")}
+
+
+ setDraft((prev) => ({ ...prev, everyValue: event.target.value }))
+ }
+ className="h-10 rounded-[12px]"
+ />
+
+
+
+ {tx("settings.automations.fields.unit", "Unit")}
+
+
+ setDraft((prev) => ({
+ ...prev,
+ everyUnit: event.target.value as AutomationEveryUnit,
+ }))
+ }
+ className="h-10 w-full rounded-[12px] border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
+ >
+ {AUTOMATION_EVERY_UNITS.map((unit) => (
+
+ {unitLabels[unit.value]}
+
+ ))}
+
+
+
+ ) : null}
+
+ {!localTrigger && draft.scheduleKind === "cron" ? (
+
+
+
+ {tx("settings.automations.fields.cronExpression", "Cron expression")}
+
+ setDraft((prev) => ({ ...prev, cronExpr: event.target.value }))}
+ placeholder="0 9 * * *"
+ className="h-10 rounded-[12px] font-mono text-[13px]"
+ />
+
+
+
+ {tx("settings.automations.fields.timezone", "Timezone")}
+
+ setDraft((prev) => ({ ...prev, tz: event.target.value }))}
+ placeholder="Asia/Shanghai"
+ className="h-10 rounded-[12px] text-[13px]"
+ />
+
+
+ ) : null}
+
+ {!localTrigger && draft.scheduleKind === "at" ? (
+
+
+ {tx("settings.automations.fields.runAt", "Run at")}
+
+ setDraft((prev) => ({ ...prev, atLocal: event.target.value }))}
+ className="h-10 rounded-[12px]"
+ />
+
+ ) : null}
+
+ {validation ? (
+
+ {validation}
+
+ ) : null}
+
+
+
+ onOpenChange(false)}
+ disabled={saving}
+ className="rounded-full"
+ >
+ {tx("settings.automations.cancel", "Cancel")}
+
+
+ {saving ? : null}
+ {tx("settings.automations.save", "Save")}
+
+
+
+
+ ) : null}
+
+ );
+}
+
+export function AutomationDeleteDialog({
+ job,
+ deleting,
+ onOpenChange,
+ onConfirm,
+}: {
+ job: SessionAutomationJob | null;
+ deleting: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: (job: SessionAutomationJob) => void | Promise;
+}) {
+ const { t } = useTranslation();
+ const tx = (key: string, fallback: string, values?: Record) =>
+ t(key, { defaultValue: fallback, ...(values ?? {}) });
+ return (
+
+
+
+ {tx("settings.automations.deleteTitle", "Delete automation")}
+
+ {tx(
+ "settings.automations.deleteDescription",
+ "This removes {{name}} from automations. Past chat messages stay in the session.",
+ { name: job?.name || job?.id || "" },
+ )}
+
+
+
+ onOpenChange(false)}
+ disabled={deleting}
+ className="rounded-full"
+ >
+ {tx("settings.automations.cancel", "Cancel")}
+
+ job && void onConfirm(job)}
+ disabled={!job || deleting}
+ className="rounded-full"
+ >
+ {deleting ? : null}
+ {tx("settings.automations.delete", "Delete")}
+
+
+
+
+ );
+}
+
+function isLocalTriggerAutomation(job: SessionAutomationJob | null): boolean {
+ if (!job) return false;
+ return job.kind === "local_trigger"
+ || job.payload.kind === "local_trigger"
+ || job.schedule.kind === "local";
+}
+
+function automationTriggerCommand(job: SessionAutomationJob): string {
+ return job.trigger?.command || job.payload.command || job.payload.message || "";
+}
+
+function automationSummary(
+ job: SessionAutomationJob,
+ tx: (key: string, fallback: string, values?: Record) => string,
+): string {
+ if (isLocalTriggerAutomation(job)) {
+ return automationTriggerCommand(job) || tx("settings.automations.localTrigger", "Local trigger");
+ }
+ return job.payload.message || tx("settings.automations.systemTask", "System-managed automation");
+}
+
+function automationDetailText(
+ job: SessionAutomationJob,
+ tx: (key: string, fallback: string, values?: Record) => string,
+): string {
+ return automationSummary(job, tx);
+}
+
+function automationNeedsAttention(job: SessionAutomationJob): boolean {
+ return job.state.last_status === "error";
+}
+
+function automationStatusKey(
+ job: SessionAutomationJob,
+): "active" | "running" | "paused" | "failed" | "system" | "completed" | "idle" {
+ if (job.protected) return "system";
+ if (job.state.pending) return "running";
+ if (!job.enabled) return "paused";
+ if (job.state.last_status === "error") return "failed";
+ if (isLocalTriggerAutomation(job)) return "active";
+ if (job.delete_after_run && !job.state.next_run_at_ms && job.state.last_status === "ok") {
+ return "completed";
+ }
+ if (!job.state.next_run_at_ms) return "idle";
+ return "active";
+}
+
+function sortAutomationJobs(jobs: SessionAutomationJob[], sort: AutomationSort): SessionAutomationJob[] {
+ const byName = (left: SessionAutomationJob, right: SessionAutomationJob) =>
+ (left.name || left.id).localeCompare(right.name || right.id);
+ return [...jobs].sort((left, right) => {
+ if (sort === "name") return byName(left, right);
+ if (sort === "last") {
+ return (right.state.last_run_at_ms ?? 0) - (left.state.last_run_at_ms ?? 0) || byName(left, right);
+ }
+ if (sort === "updated") {
+ return (right.updated_at_ms ?? 0) - (left.updated_at_ms ?? 0) || byName(left, right);
+ }
+ const leftNext = left.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
+ const rightNext = right.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
+ return leftNext - rightNext || byName(left, right);
+ });
+}
+
+function automationDraftFromJob(job: SessionAutomationJob | null): AutomationEditDraft {
+ const every = automationIntervalDraft(job?.schedule.every_ms ?? 3_600_000);
+ const scheduleKind = job?.schedule.kind === "at" || job?.schedule.kind === "cron"
+ ? job.schedule.kind
+ : "every";
+ return {
+ name: job?.name ?? "",
+ message: job?.payload.message ?? "",
+ scheduleKind,
+ everyValue: every.value,
+ everyUnit: every.unit,
+ cronExpr: job?.schedule.expr ?? "0 9 * * *",
+ tz: job?.schedule.tz ?? "",
+ atLocal: formatLocalDateTimeInput(job?.schedule.at_ms ?? Date.now() + 3_600_000),
+ };
+}
+
+function automationIntervalDraft(ms: number): { value: string; unit: AutomationEveryUnit } {
+ for (const unit of [...AUTOMATION_EVERY_UNITS].reverse()) {
+ if (ms >= unit.ms && ms % unit.ms === 0) {
+ return { value: String(ms / unit.ms), unit: unit.value };
+ }
+ }
+ return { value: String(Math.max(1, Math.round(ms / 60_000))), unit: "minute" };
+}
+
+function formatLocalDateTimeInput(ms: number): string {
+ const date = new Date(ms);
+ if (!Number.isFinite(date.getTime())) return "";
+ const local = new Date(ms - date.getTimezoneOffset() * 60_000);
+ return local.toISOString().slice(0, 16);
+}
+
+function automationEditDraftError(
+ draft: AutomationEditDraft,
+ job: SessionAutomationJob | null,
+ tx: (key: string, fallback: string, values?: Record) => string,
+): string | null {
+ if (!draft.name.trim()) return tx("settings.automations.validation.nameRequired", "Name is required.");
+ if (isLocalTriggerAutomation(job)) return null;
+ if (!draft.message.trim()) {
+ return tx("settings.automations.validation.messageRequired", "Message is required.");
+ }
+ if (draft.scheduleKind === "every") {
+ const value = Number(draft.everyValue);
+ if (!Number.isInteger(value) || value <= 0) {
+ return tx("settings.automations.validation.intervalRequired", "Interval must be a positive number.");
+ }
+ }
+ if (draft.scheduleKind === "cron" && !draft.cronExpr.trim()) {
+ return tx("settings.automations.validation.cronRequired", "Cron expression is required.");
+ }
+ if (draft.scheduleKind === "at") {
+ const atMs = new Date(draft.atLocal).getTime();
+ if (!Number.isFinite(atMs)) {
+ return tx("settings.automations.validation.timeRequired", "Run time is required.");
+ }
+ if (atMs <= Date.now() && automationScheduleChanged(draft, job)) {
+ return tx("settings.automations.validation.futureRequired", "Run time must be in the future.");
+ }
+ }
+ return null;
+}
+
+function automationUpdatePayloadFromDraft(
+ draft: AutomationEditDraft,
+ job: SessionAutomationJob | null,
+): AutomationUpdatePayload | string {
+ const name = draft.name.trim();
+ if (isLocalTriggerAutomation(job)) {
+ if (!name) return "invalid";
+ return { name };
+ }
+ const message = draft.message.trim();
+ if (!name || !message) return "invalid";
+ const payload: AutomationUpdatePayload = { name, message };
+ const schedule = automationSchedulePayloadFromDraft(draft);
+ if (typeof schedule === "string") return schedule;
+ if (automationScheduleChanged(draft, job, schedule)) {
+ payload.schedule = schedule;
+ }
+ return payload;
+}
+
+function automationSchedulePayloadFromDraft(draft: AutomationEditDraft): AutomationScheduleUpdate | string {
+ if (draft.scheduleKind === "every") {
+ const unit = AUTOMATION_EVERY_UNITS.find((candidate) => candidate.value === draft.everyUnit);
+ const value = Number(draft.everyValue);
+ if (!unit || !Number.isInteger(value) || value <= 0) return "invalid";
+ return { kind: "every", every_ms: value * unit.ms };
+ } else if (draft.scheduleKind === "cron") {
+ const expr = draft.cronExpr.trim();
+ if (!expr) return "invalid";
+ return { kind: "cron", expr, ...(draft.tz.trim() ? { tz: draft.tz.trim() } : {}) };
+ } else {
+ const atMs = new Date(draft.atLocal).getTime();
+ if (!Number.isFinite(atMs)) return "invalid";
+ return { kind: "at", at_ms: atMs };
+ }
+}
+
+function automationScheduleChanged(
+ draft: AutomationEditDraft,
+ job: SessionAutomationJob | null,
+ schedule: AutomationScheduleUpdate | string = automationSchedulePayloadFromDraft(draft),
+): boolean {
+ if (!job || typeof schedule === "string") return true;
+ if (schedule.kind !== job.schedule.kind) return true;
+ if (schedule.kind === "every") return schedule.every_ms !== job.schedule.every_ms;
+ if (schedule.kind === "cron") {
+ return schedule.expr !== (job.schedule.expr ?? "") || (schedule.tz ?? null) !== (job.schedule.tz ?? null);
+ }
+ return draft.atLocal !== formatLocalDateTimeInput(job.schedule.at_ms ?? NaN);
+}
+
+type AutomationSearchField = "id" | "name" | "message" | "chat" | "cron" | "schedule" | "status";
+
+interface AutomationSearchToken {
+ field: AutomationSearchField | null;
+ value: string;
+}
+
+const AUTOMATION_SEARCH_FIELDS = new Set([
+ "id",
+ "name",
+ "message",
+ "chat",
+ "cron",
+ "schedule",
+ "status",
+]);
+
+const HOST_AUTOMATION_CHANNEL_LABELS: Record = {
+ api: "API",
+ cli: "CLI",
+};
+
+function parseAutomationSearchQuery(query: string): AutomationSearchToken[] {
+ return (query.match(/[^\s:]+:"[^"]+"|"[^"]+"|\S+/g) ?? [])
+ .map((rawPart): AutomationSearchToken | null => {
+ const part = trimAutomationSearchValue(rawPart);
+ if (!part) return null;
+ const fieldMatch = part.match(/^([A-Za-z]+):(.*)$/);
+ if (!fieldMatch) return { field: null, value: part.toLowerCase() };
+ const field = fieldMatch[1].toLowerCase() as AutomationSearchField;
+ const value = trimAutomationSearchValue(fieldMatch[2]).toLowerCase();
+ if (!value) return null;
+ return AUTOMATION_SEARCH_FIELDS.has(field)
+ ? { field, value }
+ : { field: null, value: part.toLowerCase() };
+ })
+ .filter((token): token is AutomationSearchToken => Boolean(token));
+}
+
+function trimAutomationSearchValue(value: string): string {
+ return value.trim().replace(/^"|"$/g, "").trim();
+}
+
+function automationMatchesSearch(job: SessionAutomationJob, tokens: AutomationSearchToken[]): boolean {
+ return tokens.every((token) => automationSearchText(job, token.field).includes(token.value));
+}
+
+function automationSearchText(job: SessionAutomationJob, field: AutomationSearchField | null = null): string {
+ return automationSearchParts(job, field)
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+}
+
+function automationSearchParts(
+ job: SessionAutomationJob,
+ field: AutomationSearchField | null,
+): Array {
+ const originParts = automationOriginSearchParts(job);
+ const scheduleParts = automationScheduleSearchParts(job);
+ if (field === "id") return [job.id];
+ if (field === "name") return [job.name, job.id];
+ if (field === "message") return [job.payload.message, job.payload.command, job.trigger?.command];
+ if (field === "chat") return originParts;
+ if (field === "cron" || field === "schedule") return scheduleParts;
+ if (field === "status") return [automationStatusKey(job), job.enabled ? "enabled" : "disabled"];
+ return [
+ job.id,
+ job.name,
+ job.payload.message,
+ job.payload.command,
+ job.trigger?.command,
+ isLocalTriggerAutomation(job) ? "trigger local" : null,
+ ...scheduleParts,
+ automationStatusKey(job),
+ ...originParts,
+ ];
+}
+
+function automationOriginSearchParts(job: SessionAutomationJob): Array {
+ const origin = job.origin;
+ if (!origin) return [];
+ const channel = origin.channel.trim().toLowerCase();
+ return [
+ origin.session_key,
+ origin.title,
+ origin.preview,
+ origin.channel,
+ automationChannelDisplayName(channel),
+ ];
+}
+
+function automationScheduleSearchParts(job: SessionAutomationJob): Array {
+ const schedule = job.schedule;
+ const parts: Array = [
+ schedule.kind,
+ schedule.expr,
+ schedule.tz,
+ schedule.every_ms,
+ schedule.at_ms,
+ ];
+ if (schedule.kind === "cron" && schedule.expr) {
+ parts.push(...automationCronSearchParts(schedule.expr));
+ }
+ return parts;
+}
+
+function automationCronSearchParts(expr: string): string[] {
+ const parts = expr.trim().split(/\s+/);
+ if (parts.length !== 5) return [];
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
+ const everyDay = dayOfMonth === "*" && month === "*" && dayOfWeek === "*";
+ const numericMinute = cronNumericToken(minute, 59);
+ const numericHour = cronNumericToken(hour, 23);
+ if (numericMinute === null) return [];
+ const paddedMinute = String(numericMinute).padStart(2, "0");
+
+ if (numericHour !== null) {
+ const time = `${String(numericHour).padStart(2, "0")}:${paddedMinute}`;
+ return [time, `:${paddedMinute}`];
+ }
+
+ if (everyDay && hour === "*") {
+ return [`:${paddedMinute}`, `hourly at :${paddedMinute}`];
+ }
+
+ const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
+ if (!everyDay || !range) return [];
+ const start = Number(range[1]);
+ const end = Number(range[2]);
+ if (start > 23 || end > 23) return [];
+ const paddedRange = `${String(start).padStart(2, "0")}-${String(end).padStart(2, "0")}`;
+ const rawRange = `${start}-${end}`;
+ return [
+ paddedRange,
+ rawRange,
+ `:${paddedMinute}`,
+ `${paddedRange} at :${paddedMinute}`,
+ `hourly ${paddedRange} at :${paddedMinute}`,
+ ];
+}
+
+function automationMatchesFilter(job: SessionAutomationJob, filter: AutomationFilter): boolean {
+ const status = automationStatusKey(job);
+ if (filter === "active") return status === "active" || status === "running";
+ if (filter === "paused") return status === "paused";
+ if (filter === "failed") return automationNeedsAttention(job);
+ if (filter === "system") return Boolean(job.protected);
+ return true;
+}
+
+const AUTOMATION_FILTER_TONES: Partial<
+ Record
+> = {
+ active: {
+ text: "text-emerald-600 dark:text-emerald-400",
+ selectedText: "text-emerald-700 dark:text-emerald-300",
+ count: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
+ },
+ paused: {
+ text: "text-amber-600 dark:text-amber-400",
+ selectedText: "text-amber-700 dark:text-amber-300",
+ count: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
+ },
+ failed: {
+ text: "text-rose-600 dark:text-rose-400",
+ selectedText: "text-rose-700 dark:text-rose-300",
+ count: "bg-rose-500/10 text-rose-700 dark:text-rose-300",
+ },
+ system: {
+ text: "text-sky-600 dark:text-sky-400",
+ selectedText: "text-sky-700 dark:text-sky-300",
+ count: "bg-sky-500/10 text-sky-700 dark:text-sky-300",
+ },
+};
+
+function automationFilterToneClass(value: AutomationFilter, count: number, selected: boolean): string {
+ const tone = AUTOMATION_FILTER_TONES[value];
+ if (count <= 0 || !tone) return "";
+ return selected ? tone.selectedText : tone.text;
+}
+
+function automationFilterCountClass(value: AutomationFilter, count: number): string {
+ const tone = AUTOMATION_FILTER_TONES[value];
+ return count > 0 && tone ? tone.count : "";
+}
+
+function automationStatus(
+ job: SessionAutomationJob,
+ tx: (key: string, fallback: string, values?: Record) => string,
+): { label: string; tone: "neutral" | "success" | "warning" } {
+ const status = automationStatusKey(job);
+ if (status === "system") return { label: tx("settings.automations.status.system", "System"), tone: "neutral" };
+ if (status === "running") {
+ return { label: tx("settings.automations.status.running", "Running now"), tone: "warning" };
+ }
+ if (status === "paused") return { label: tx("settings.automations.status.paused", "Paused"), tone: "neutral" };
+ if (status === "failed") {
+ return { label: tx("settings.automations.status.failed", "Failed"), tone: "warning" };
+ }
+ if (status === "completed") {
+ return { label: tx("settings.automations.status.completed", "Completed"), tone: "neutral" };
+ }
+ if (status === "idle") {
+ return { label: tx("settings.automations.status.noSchedule", "No schedule"), tone: "neutral" };
+ }
+ return { label: tx("settings.automations.status.active", "Active"), tone: "success" };
+}
+
+function automationOriginLabel(
+ job: SessionAutomationJob,
+ tx: (key: string, fallback: string, values?: Record) => string,
+): string {
+ if (job.protected) return tx("settings.automations.origin.system", "System");
+ const origin = job.origin;
+ if (!origin) return tx("settings.automations.origin.unknown", "No linked chat");
+ if (origin.channel !== "websocket") return automationChannelLabel(origin.channel, tx);
+ return origin.title || origin.preview || origin.session_key || automationChannelLabel(origin.channel, tx);
+}
+
+function automationChannelLabel(
+ channel: string,
+ tx: (key: string, fallback: string, values?: Record) => string,
+): string {
+ const key = channel.trim().toLowerCase();
+ const displayName = automationChannelDisplayName(key);
+ return displayName
+ ? tx(`settings.automations.channels.${key}`, displayName)
+ : channel;
+}
+
+function automationChannelDisplayName(channel: string): string | undefined {
+ const key = channel.trim().toLowerCase();
+ return channelUiPresentation(key)?.displayName ?? HOST_AUTOMATION_CHANNEL_LABELS[key];
+}
+
+function formatAutomationSchedule(
+ job: SessionAutomationJob,
+ locale: string,
+ tx: (key: string, fallback: string, values?: Record) => string,
+): string {
+ if (job.schedule.kind === "at" && job.schedule.at_ms) {
+ return tx("settings.automations.schedule.at", "At {{time}}", {
+ time: fmtDateTime(job.schedule.at_ms, locale),
+ });
+ }
+ if (job.schedule.kind === "every" && job.schedule.every_ms) {
+ return tx("settings.automations.schedule.every", "Every {{duration}}", {
+ duration: formatAutomationInterval(job.schedule.every_ms, locale),
+ });
+ }
+ if (job.schedule.kind === "cron" && job.schedule.expr) {
+ const summary = formatCronScheduleSummary(job.schedule.expr, tx);
+ if (summary) {
+ return job.schedule.tz
+ ? tx("settings.automations.schedule.withTz", "{{summary}} · {{tz}}", {
+ summary,
+ tz: job.schedule.tz,
+ })
+ : summary;
+ }
+ return job.schedule.tz
+ ? tx("settings.automations.schedule.cronWithTz", "Cron {{expr}} · {{tz}}", {
+ expr: job.schedule.expr,
+ tz: job.schedule.tz,
+ })
+ : tx("settings.automations.schedule.cron", "Cron {{expr}}", { expr: job.schedule.expr });
+ }
+ if (isLocalTriggerAutomation(job)) {
+ return tx("settings.automations.schedule.local", "Local trigger");
+ }
+ return tx("settings.automations.schedule.custom", "Custom schedule");
+}
+
+function formatCronScheduleSummary(
+ expr: string,
+ tx: (key: string, fallback: string, values?: Record