nanobot/webui/src/components/settings/SettingsView.tsx
Xubin Ren ab9f49970d
feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
2026-06-06 19:49:33 +08:00

5659 lines
204 KiB
TypeScript

import {
useCallback,
useEffect,
forwardRef,
useMemo,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from "react";
import {
Activity,
Bot,
Brain,
Check,
CircleAlert,
ChevronDown,
ChevronLeft,
ChevronRight,
Cloud,
Cpu,
Database,
Eye,
EyeOff,
Gem,
Globe2,
Grid3X3,
HardDrive,
Hexagon,
ImageIcon,
Layers,
Loader2,
LogOut,
Moon,
PlayCircle,
Plus,
Orbit,
Palette,
Pencil,
RotateCcw,
Search,
Server,
ShieldCheck,
SlidersHorizontal,
Sparkles,
Trash2,
Triangle,
Waves,
X,
Zap,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
createModelConfiguration,
fetchSettings,
fetchSettingsUsage,
fetchCliApps,
fetchMcpPresets,
fetchProviderModels,
importMcpConfig,
loginProviderOAuth,
logoutProviderOAuth,
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
updateImageGenerationSettings,
updateMcpServerTools,
updateModelConfiguration,
updateNetworkSafetySettings,
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
} from "@/lib/api";
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
import { getHostApi } from "@/lib/runtime";
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
import {
logoFallbackUrls,
providerBrand,
providerDisplayLabel,
} from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
import { shortWorkspacePath } from "@/lib/workspace";
import { useClient } from "@/providers/ClientProvider";
import type {
CliAppInfo,
CliAppsPayload,
ImageGenerationSettingsUpdate,
McpPresetInfo,
McpPresetsPayload,
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
SettingsPayload,
SkillSummary,
WebSearchSettingsUpdate,
WebuiDefaultAccessMode,
} from "@/lib/types";
export type SettingsSectionKey =
| "overview"
| "appearance"
| "models"
| "image"
| "browser"
| "apps"
| "skills"
| "runtime"
| "advanced";
type LocalDensity = "comfortable" | "compact";
type LocalActivityMode = "auto" | "expanded";
type AppsKindFilter = "all" | "cli" | "mcp";
type AppsCatalogItem =
| { id: string; kind: "cli"; app: CliAppInfo }
| { id: string; kind: "mcp"; preset: McpPresetInfo };
interface LocalPreferences {
density: LocalDensity;
activityMode: LocalActivityMode;
codeWrap: boolean;
brandLogos: boolean;
}
interface AgentSettingsDraft {
model: string;
provider: string;
modelPreset: string;
presetLabel: string;
contextWindowTokens: number;
timezone: string;
botName: string;
botIcon: string;
toolHintMaxLength: number;
}
interface ModelConfigurationDraft {
label: string;
provider: string;
model: string;
}
type PendingRestartSection = "runtime" | "browser" | "image";
type PendingRestartSections = Record<PendingRestartSection, boolean>;
type RestartAwarePayload = {
requires_restart?: boolean;
surface?: SettingsPayload["surface"];
runtime_surface?: SettingsPayload["runtime_surface"];
runtime_capabilities?: SettingsPayload["runtime_capabilities"];
};
type ProviderApiType = "auto" | "chat_completions" | "responses";
type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
"atomic_chat",
"byteplus",
"byteplus_coding_plan",
"huggingface",
"lm_studio",
"novita",
"ollama",
"openrouter",
"ovms",
"siliconflow",
"vllm",
"volcengine",
"volcengine_coding_plan",
]);
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
const FALLBACK_TIMEZONES = [
"UTC",
"Asia/Shanghai",
"Asia/Hong_Kong",
"Asia/Tokyo",
"Asia/Seoul",
"Asia/Singapore",
"Asia/Taipei",
"Asia/Dubai",
"Asia/Kolkata",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Europe/Amsterdam",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Toronto",
"America/Sao_Paulo",
"Australia/Sydney",
"Pacific/Auckland",
];
interface CustomMcpForm {
name: string;
transport: CustomMcpTransport;
command: string;
args: string;
url: string;
env: string;
headers: string;
toolTimeout: string;
}
const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable",
activityMode: "auto",
codeWrap: true,
brandLogos: true,
};
const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [
{ value: "auto", label: "Auto" },
{ value: "chat_completions", label: "Chat Completions" },
{ value: "responses", label: "Responses" },
];
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
name,
index,
]),
);
const IMAGE_ASPECT_RATIO_OPTIONS = ["1:1", "3:4", "9:16", "4:3", "16:9", "3:2", "2:3", "21:9"];
const IMAGE_SIZE_OPTIONS = ["1K", "2K", "4K", "1024x1024", "1536x1024", "1024x1536"];
const EMPTY_PENDING_RESTART_SECTIONS: PendingRestartSections = {
runtime: false,
browser: false,
image: false,
};
const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
name: "",
transport: "stdio",
command: "",
args: "",
url: "",
env: "",
headers: "",
toolTimeout: "30",
};
interface SettingsViewProps {
theme: "light" | "dark";
initialSection?: SettingsSectionKey;
initialSettings?: SettingsPayload | null;
showSidebar?: boolean;
onToggleTheme: () => void;
onBackToChat: () => void;
onModelNameChange: (modelName: string | null) => void;
onSettingsChange?: (payload: SettingsPayload) => void;
skills?: SkillSummary[];
onWorkspaceSettingsChange?: () => void | Promise<void>;
onSectionChange?: (section: SettingsSectionKey) => void;
onLogout?: () => void;
onRestart?: () => void;
onNativeEngineRestart?: () => Promise<string>;
isRestarting?: boolean;
hostChromeInset?: boolean;
}
function readLocalPreferences(): LocalPreferences {
try {
const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY);
if (!raw) return DEFAULT_LOCAL_PREFS;
const parsed = JSON.parse(raw) as Partial<LocalPreferences>;
return {
density: parsed.density === "compact" ? "compact" : "comfortable",
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos !== false,
};
} catch {
return DEFAULT_LOCAL_PREFS;
}
}
function modelPresetValue(payload: SettingsPayload): string {
return payload.agent.model_preset || "default";
}
function defaultPreset(payload: SettingsPayload): SettingsPayload["model_presets"][number] | null {
return payload.model_presets.find((preset) => preset.is_default) ?? null;
}
function normalizeContextWindowTokens(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 65_536;
}
function editableDefaultProvider(payload: SettingsPayload): string {
const base = defaultPreset(payload);
return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "";
}
function settingsProviderRow(
payload: SettingsPayload,
provider: string | null | undefined,
): SettingsPayload["providers"][number] | null {
if (!provider) return null;
return payload.providers.find((row) => row.name === provider) ?? null;
}
function settingsProviderConfigured(
payload: SettingsPayload,
provider: string | null | undefined,
): boolean {
const row = settingsProviderRow(payload, provider);
if (row) return row.configured;
return payload.agent.has_api_key;
}
const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
model: "",
provider: "",
modelPreset: "default",
presetLabel: "Default",
contextWindowTokens: 65_536,
timezone: "UTC",
botName: "nanobot",
botIcon: "",
toolHintMaxLength: 40,
};
const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
provider: "duckduckgo",
apiKey: "",
baseUrl: "",
maxResults: 5,
timeout: 30,
useJinaReader: true,
};
const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
enabled: false,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
defaultAspectRatio: "1:1",
defaultImageSize: "1K",
maxImagesPerTurn: 4,
};
const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
webuiAllowLocalServiceAccess: true,
webuiDefaultAccessMode: "default",
};
function agentDraftFromPayload(payload: SettingsPayload): AgentSettingsDraft {
const fallbackDefault = defaultPreset(payload);
const activePresetName = modelPresetValue(payload);
const activePreset =
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
return {
model: activePreset?.model ?? payload.agent.model,
provider: activePreset?.is_default
? editableDefaultProvider(payload)
: activePreset?.provider ?? editableDefaultProvider(payload),
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
toolHintMaxLength: payload.agent.tool_hint_max_length,
};
}
function webSearchFormFromPayload(
payload: SettingsPayload,
previous?: WebSearchSettingsUpdate,
): WebSearchSettingsUpdate {
return {
provider: payload.web_search.provider,
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
};
}
function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
return {
enabled: payload.image_generation.enabled,
provider: payload.image_generation.provider,
model: payload.image_generation.model,
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
defaultImageSize: payload.image_generation.default_image_size,
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
};
}
function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
return {
webuiAllowLocalServiceAccess:
payload.advanced.webui_allow_local_service_access ??
payload.advanced.allow_local_preview_access ??
true,
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
payload.advanced.webui_default_access_mode,
),
};
}
function pendingRestartSectionsFromPayload(payload: SettingsPayload): PendingRestartSections {
const sections = payload.restart_required_sections ?? [];
return {
runtime: sections.includes("runtime"),
browser: sections.includes("browser"),
image: sections.includes("image"),
};
}
export function SettingsView({
theme,
initialSection = "overview",
initialSettings = null,
showSidebar = true,
onToggleTheme,
onBackToChat,
onModelNameChange,
onSettingsChange,
skills = [],
onWorkspaceSettingsChange,
onSectionChange,
onLogout,
onRestart,
onNativeEngineRestart,
isRestarting = false,
hostChromeInset = false,
}: SettingsViewProps) {
const { t } = useTranslation();
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
const [loading, setLoading] = useState(() => initialSettings === null);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [modelConfigurationOpen, setModelConfigurationOpen] = useState(false);
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
const [modelConfigurationForm, setModelConfigurationForm] = useState<ModelConfigurationDraft>({
label: "",
provider: "",
model: "",
});
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null);
const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
const [hostEngineApplying, setHostEngineApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeSection, setActiveSection] = useState<SettingsSectionKey>(initialSection);
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [providerQuery, setProviderQuery] = useState("");
const [appsQuery, setAppsQuery] = useState("");
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("all");
const [mcpMessage, setMcpMessage] = useState<string | null>(null);
const [mcpError, setMcpError] = useState<string | null>(null);
const [mcpFieldValues, setMcpFieldValues] = useState<Record<string, Record<string, string>>>({});
const [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM);
const [mcpConfigImport, setMcpConfigImport] = useState("");
const [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
const [pendingRestartSections, setPendingRestartSections] = useState<PendingRestartSections>(
EMPTY_PENDING_RESTART_SECTIONS,
);
const [localPrefs, setLocalPrefs] = useState<LocalPreferences>(() => readLocalPreferences());
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
);
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
() =>
initialSettings
? imageGenerationFormFromPayload(initialSettings)
: DEFAULT_IMAGE_GENERATION_FORM,
);
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
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<AgentSettingsDraft>(() =>
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
);
const text = useCallback(
(key: string, fallback: string, options?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(options ?? {}) }),
[t],
);
const applyPayload = useCallback((payload: SettingsPayload) => {
setSettings(payload);
setForm(agentDraftFromPayload(payload));
setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev));
setImageGenerationForm(imageGenerationFormFromPayload(payload));
setNetworkSafetyForm(networkSafetyFormFromPayload(payload));
if (payload.restart_required_sections) {
setPendingRestartSections(pendingRestartSectionsFromPayload(payload));
}
onSettingsChange?.(payload);
}, [onSettingsChange]);
useEffect(() => {
if (!initialSettings || settings !== null) return;
applyPayload(initialSettings);
setLoading(false);
}, [applyPayload, initialSettings, settings]);
useEffect(() => {
let cancelled = false;
const showLoading = settings === null;
if (showLoading) setLoading(true);
fetchSettings(token)
.then((payload) => {
if (!cancelled) {
applyPayload(payload);
setError(null);
}
})
.catch((err) => {
if (!cancelled && showLoading) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [applyPayload, token]);
const hasSettings = settings !== null;
useEffect(() => {
if (activeSection !== "overview" || !hasSettings) return;
let cancelled = false;
const refresh = () => {
fetchSettingsUsage(token)
.then((usage) => {
if (cancelled) return;
setSettings((current) => (current ? { ...current, usage } : current));
})
.catch(() => {});
};
void refresh();
const interval = window.setInterval(refresh, 5000);
const onFocus = () => refresh();
const onVisibilityChange = () => {
if (document.visibilityState === "visible") refresh();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
cancelled = true;
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [activeSection, hasSettings, token]);
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
setCliAppsLoading(true);
fetchCliApps(token)
.then((payload) => {
if (!cancelled) {
setCliApps(payload);
setCliAppsError(null);
}
})
.catch((err) => {
if (!cancelled) setCliAppsError((err as Error).message);
})
.finally(() => {
if (!cancelled) setCliAppsLoading(false);
});
return () => {
cancelled = true;
};
}, [activeSection, token]);
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
setMcpPresetsLoading(true);
fetchMcpPresets(token)
.then((payload) => {
if (!cancelled) {
setMcpPresets(payload);
setMcpError(null);
}
})
.catch((err) => {
if (!cancelled) setMcpError((err as Error).message);
})
.finally(() => {
if (!cancelled) setMcpPresetsLoading(false);
});
return () => {
cancelled = true;
};
}, [activeSection, token]);
useEffect(() => {
try {
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
} catch {
// Browser-only preferences should never block settings.
}
}, [localPrefs]);
useEffect(() => {
if (!settings) return;
setProviderForms((prev) => {
const next = { ...prev };
for (const provider of settings.providers) {
next[provider.name] = {
apiKey: next[provider.name]?.apiKey ?? "",
apiBase: next[provider.name]?.apiBase ?? provider.api_base ?? provider.default_api_base ?? "",
apiType: next[provider.name]?.apiType ?? provider.api_type ?? "auto",
};
}
return next;
});
}, [settings]);
const modelDirty = useMemo(() => {
if (!settings) return false;
const activePresetName = modelPresetValue(settings);
const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset);
if (!selectedPreset) return form.modelPreset !== activePresetName;
const selectedProvider = selectedPreset.is_default
? editableDefaultProvider(settings)
: selectedPreset.provider;
return (
form.modelPreset !== activePresetName ||
form.model !== selectedPreset.model ||
form.provider !== selectedProvider ||
form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
(!selectedPreset.is_default && form.presetLabel.trim() !== selectedPreset.label)
);
}, [form, settings]);
const runtimeDirty = useMemo(() => {
if (!settings) return false;
return (
form.timezone !== settings.agent.timezone ||
form.botName !== settings.agent.bot_name ||
form.botIcon !== settings.agent.bot_icon
);
}, [form, settings]);
const imageGenerationDirty = useMemo(() => {
if (!settings) return false;
return (
imageGenerationForm.enabled !== settings.image_generation.enabled ||
imageGenerationForm.provider !== settings.image_generation.provider ||
imageGenerationForm.model !== settings.image_generation.model ||
imageGenerationForm.defaultAspectRatio !== settings.image_generation.default_aspect_ratio ||
imageGenerationForm.defaultImageSize !== settings.image_generation.default_image_size ||
imageGenerationForm.maxImagesPerTurn !== settings.image_generation.max_images_per_turn
);
}, [imageGenerationForm, settings]);
const networkSafetyDirty = useMemo(() => {
if (!settings) return false;
const currentLocalServiceAccess =
settings.advanced.webui_allow_local_service_access ?? settings.advanced.allow_local_preview_access ?? true;
const currentDefaultAccess = visibleWebuiDefaultAccessMode(settings.advanced.webui_default_access_mode);
return (
networkSafetyForm.webuiAllowLocalServiceAccess !== currentLocalServiceAccess ||
networkSafetyForm.webuiDefaultAccessMode !== currentDefaultAccess
);
}, [networkSafetyForm, settings]);
const configuredModelProviderOptions = useMemo(
() =>
settings?.providers
.filter((provider) => provider.configured)
.map((provider) => ({ name: provider.name, label: provider.label })) ?? [],
[settings],
);
const hasPendingRestart = useMemo(
() =>
!!settings?.requires_restart ||
pendingRestartSections.runtime ||
pendingRestartSections.browser ||
pendingRestartSections.image,
[pendingRestartSections, settings?.requires_restart],
);
const restartViaSettingsSurface = useCallback(async () => {
const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native";
if (
isNativeHost &&
settings?.runtime_capabilities?.can_restart_engine &&
onNativeEngineRestart
) {
setHostEngineApplying(true);
try {
const nextToken = await onNativeEngineRestart();
const payload = await fetchSettings(nextToken);
applyPayload(payload);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setHostEngineApplying(false);
}
return;
}
onRestart?.();
}, [applyPayload, onNativeEngineRestart, onRestart, settings]);
const maybeRestartHostEngine = useCallback(
async (payload: RestartAwarePayload) => {
const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface;
const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities;
const isNativeHost = surface === "native";
if (
!payload.requires_restart ||
!isNativeHost ||
!capabilities?.can_restart_engine ||
!onNativeEngineRestart
) {
return;
}
setHostEngineApplying(true);
try {
const nextToken = await onNativeEngineRestart();
const refreshed = await fetchSettings(nextToken);
applyPayload(refreshed);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setHostEngineApplying(false);
}
},
[applyPayload, onNativeEngineRestart, settings],
);
const saveModelSettings = async () => {
if (!settings || !modelDirty || saving) return;
setSaving(true);
try {
const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset);
let payload: SettingsPayload;
if (selectedPreset && !selectedPreset.is_default) {
payload = await updateModelConfiguration(token, {
name: selectedPreset.name,
label: form.presetLabel.trim(),
model: form.model,
provider: form.provider,
...(form.contextWindowTokens !== selectedPreset.context_window_tokens
? { contextWindowTokens: form.contextWindowTokens }
: {}),
});
} else {
const defaultModel = defaultPreset(settings)?.model ?? settings.agent.model;
const defaultProvider = editableDefaultProvider(settings);
const defaultContextWindowTokens = normalizeContextWindowTokens(
defaultPreset(settings)?.context_window_tokens ?? settings.agent.context_window_tokens,
);
payload = await updateSettings(token, {
modelPreset: form.modelPreset,
...(form.model !== defaultModel ? { model: form.model } : {}),
...(form.provider !== defaultProvider ? { provider: form.provider } : {}),
...(form.contextWindowTokens !== defaultContextWindowTokens
? { contextWindowTokens: form.contextWindowTokens }
: {}),
});
}
applyPayload(payload);
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
const openModelConfigurationDialog = () => {
if (!settings) return;
const currentProvider = settings.agent.provider;
const provider =
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
configuredModelProviderOptions[0]?.name ??
"";
setModelConfigurationForm({
label: "",
provider,
model: "",
});
setModelConfigurationOpen(true);
};
const handleCreateModelConfiguration = async () => {
if (modelConfigurationSaving) return;
const label = modelConfigurationForm.label.trim();
const provider = modelConfigurationForm.provider.trim();
const model = modelConfigurationForm.model.trim();
if (!label || !provider || !model) return;
setModelConfigurationSaving(true);
try {
const payload = await createModelConfiguration(token, {
label,
provider,
model,
});
applyPayload(payload);
onModelNameChange(payload.agent.model || null);
setModelConfigurationOpen(false);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setModelConfigurationSaving(false);
}
};
const saveRuntimeSettings = async () => {
if (!settings || !runtimeDirty || saving) return;
setSaving(true);
try {
const payload = await updateSettings(token, {
timezone: form.timezone,
botName: form.botName,
botIcon: form.botIcon,
});
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await onWorkspaceSettingsChange?.();
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
const saveImageGenerationSettings = async () => {
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
setImageGenerationSaving(true);
try {
const payload = await updateImageGenerationSettings(token, imageGenerationForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
}
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setImageGenerationSaving(false);
}
};
const saveNetworkSafetySettings = async () => {
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
setNetworkSafetySaving(true);
try {
const payload = await updateNetworkSafetySettings(token, networkSafetyForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setNetworkSafetySaving(false);
}
};
const saveProvider = async (providerName: string) => {
if (providerSaving) return;
const provider = settings?.providers.find((item) => item.name === providerName);
if (!provider) return;
if (provider.auth_type === "oauth") return;
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "", apiType: "auto" };
const apiKey = providerForm.apiKey.trim();
const apiKeyRequired = provider.api_key_required ?? true;
if (!provider.configured && apiKeyRequired && !apiKey) {
setError(t("settings.byok.apiKeyRequired"));
return;
}
setProviderSaving(providerName);
try {
const payload = await updateProviderSettings(token, {
provider: providerName,
apiKey: apiKey || undefined,
apiBase: providerForm.apiBase.trim(),
apiType: providerForm.apiType,
});
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
}
await maybeRestartHostEngine(payload);
setProviderForms((prev) => ({
...prev,
[providerName]: {
apiKey: "",
apiBase: providerForm.apiBase.trim(),
apiType: providerForm.apiType,
},
}));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setProviderSaving(null);
}
};
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
if (providerSaving) return;
setProviderSaving(providerName);
try {
const payload =
action === "login"
? await loginProviderOAuth(token, providerName)
: await logoutProviderOAuth(token, providerName);
applyPayload(payload);
setExpandedProvider(providerName);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setProviderSaving(null);
}
};
const saveWebSearch = async () => {
if (!settings || webSearchSaving) return;
const provider = settings.web_search.providers.find((item) => item.name === webSearchForm.provider);
if (!provider) return;
const apiKey = webSearchForm.apiKey?.trim() ?? "";
const baseUrl = webSearchForm.baseUrl?.trim() ?? "";
const hasExistingSecret =
provider.credential === "api_key" &&
webSearchForm.provider === settings.web_search.provider &&
!!settings.web_search.api_key_hint;
if (provider.credential === "api_key" && !apiKey && !hasExistingSecret) {
setError(t("settings.byok.webSearch.apiKeyRequired"));
return;
}
if (provider.credential === "base_url" && !baseUrl) {
setError(t("settings.byok.webSearch.baseUrlRequired"));
return;
}
setWebSearchSaving(true);
try {
const webFetchRestartRequired =
(webSearchForm.useJinaReader ?? settings.web.fetch.use_jina_reader) !==
settings.web.fetch.use_jina_reader;
const update: WebSearchSettingsUpdate = {
provider: webSearchForm.provider,
maxResults: webSearchForm.maxResults,
timeout: webSearchForm.timeout,
useJinaReader: webSearchForm.useJinaReader,
};
if (provider.credential === "api_key" && apiKey) update.apiKey = apiKey;
if (provider.credential === "base_url") update.baseUrl = baseUrl;
const payload = await updateWebSearchSettings(token, update);
applyPayload(payload);
if (payload.requires_restart || webFetchRestartRequired) {
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
}
await maybeRestartHostEngine(payload);
setWebSearchForm((prev) => ({
provider: payload.web_search.provider,
apiKey: "",
baseUrl: payload.web_search.base_url ?? prev.baseUrl ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
}));
setWebSearchKeyVisible(false);
setWebSearchKeyEditing(false);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setWebSearchSaving(false);
}
};
const resetProviderDraft = useCallback((providerName: string) => {
const provider = settings?.providers.find((item) => item.name === providerName);
if (!provider) return;
setProviderForms((prev) => ({
...prev,
[providerName]: {
apiKey: "",
apiBase: provider.api_base ?? provider.default_api_base ?? "",
apiType: provider.api_type ?? "auto",
},
}));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
}, [settings]);
const handleToggleProvider = useCallback((providerName: string) => {
if (expandedProvider) resetProviderDraft(expandedProvider);
setExpandedProvider(expandedProvider === providerName ? null : providerName);
}, [expandedProvider, resetProviderDraft]);
const resetWebSearchDraft = useCallback(() => {
if (!settings) return;
setWebSearchForm({
provider: settings.web_search.provider,
apiKey: "",
baseUrl: settings.web_search.base_url ?? "",
maxResults: settings.web_search.max_results,
timeout: settings.web_search.timeout,
useJinaReader: settings.web.fetch.use_jina_reader,
});
setWebSearchKeyVisible(false);
setWebSearchKeyEditing(false);
}, [settings]);
const handleWebSearchProviderChange = useCallback((provider: string) => {
if (!settings) return;
setWebSearchForm((prev) => ({
provider,
apiKey: "",
baseUrl: provider === settings.web_search.provider ? settings.web_search.base_url ?? "" : "",
maxResults: prev.maxResults ?? settings.web_search.max_results,
timeout: prev.timeout ?? settings.web_search.timeout,
useJinaReader: prev.useJinaReader ?? settings.web.fetch.use_jina_reader,
}));
setWebSearchKeyVisible(false);
setWebSearchKeyEditing(false);
}, [settings]);
const toggleProviderKeyVisibility = (providerName: string) => {
const isVisible = visibleProviderKeys[providerName];
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible }));
};
const toggleProviderKeyEditing = (providerName: string) => {
setEditingProviderKeys((prev) => {
const nextEditing = !prev[providerName];
if (!nextEditing) {
setProviderForms((forms) => ({
...forms,
[providerName]: {
apiKey: "",
apiBase: forms[providerName]?.apiBase ?? "",
apiType: forms[providerName]?.apiType ?? "auto",
},
}));
setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false }));
}
return { ...prev, [providerName]: nextEditing };
});
};
const handleCliAppAction = async (
action: "install" | "update" | "uninstall" | "test",
name: string,
) => {
const key = `${action}:${name}`;
setCliAppsAction(key);
setCliAppsMessage(null);
setCliAppsError(null);
try {
const payload = await runCliAppAction(token, action, name);
setCliApps(payload);
if (action !== "test") {
notifyCliAppsChanged(payload);
}
setCliAppsMessage(payload.last_action?.message ?? null);
setCliAppsFocusName(action === "uninstall" ? null : name);
} catch (err) {
setCliAppsError((err as Error).message);
} finally {
setCliAppsAction(null);
}
};
const handleMcpPresetAction = async (
action: "enable" | "remove" | "test",
name: string,
values: Record<string, string> = {},
) => {
const key = `${action}:${name}`;
setMcpPresetAction(key);
setMcpMessage(null);
setMcpError(null);
try {
const payload = await runMcpPresetAction(token, action, name, values);
setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null);
if (action !== "test") {
notifyMcpPresetsChanged(payload);
}
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
if (action === "enable") {
setMcpFieldValues((prev) => ({ ...prev, [name]: {} }));
}
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
const handleSaveCustomMcp = async () => {
const name = customMcpForm.name.trim();
const key = `custom:${name || "new"}`;
setMcpPresetAction(key);
setMcpMessage(null);
setMcpError(null);
try {
const payload = await saveCustomMcpServer(token, {
name,
transport: customMcpForm.transport,
command: customMcpForm.command,
args: customMcpForm.args,
url: customMcpForm.url,
env: customMcpForm.env,
headers: customMcpForm.headers,
tool_timeout: customMcpForm.toolTimeout,
});
setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null);
notifyMcpPresetsChanged(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
setCustomMcpForm((prev) => ({ ...DEFAULT_CUSTOM_MCP_FORM, transport: prev.transport }));
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
const handleImportMcpConfig = async () => {
setMcpPresetAction("import");
setMcpMessage(null);
setMcpError(null);
try {
const payload = await importMcpConfig(token, mcpConfigImport);
setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null);
notifyMcpPresetsChanged(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
setMcpConfigImport("");
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
const handleMcpToolsChange = async (name: string, enabledTools: string[]) => {
setMcpPresetAction(`tools:${name}`);
setMcpMessage(null);
setMcpError(null);
try {
const payload = await updateMcpServerTools(token, name, enabledTools);
setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null);
notifyMcpPresetsChanged(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
const renderSection = () => {
if (!settings) return null;
switch (activeSection) {
case "overview":
return (
<OverviewSettings
settings={settings}
requiresRestart={hasPendingRestart}
showBrandLogos={localPrefs.brandLogos}
onSelectSection={selectSection}
/>
);
case "appearance":
return (
<AppearanceSettings
theme={theme}
onToggleTheme={onToggleTheme}
localPrefs={localPrefs}
onChangeLocalPrefs={setLocalPrefs}
/>
);
case "models":
return (
<div className="space-y-8">
<ModelsSettings
token={token}
form={form}
setForm={setForm}
settings={settings}
dirty={modelDirty}
saving={saving}
showBrandLogos={localPrefs.brandLogos}
providerSaving={providerSaving}
onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")}
onSave={saveModelSettings}
onCreateConfiguration={openModelConfigurationDialog}
/>
<ProvidersSettings
settings={settings}
expandedProvider={expandedProvider}
providerForms={providerForms}
visibleProviderKeys={visibleProviderKeys}
editingProviderKeys={editingProviderKeys}
providerSaving={providerSaving}
query={providerQuery}
showBrandLogos={localPrefs.brandLogos}
onQueryChange={setProviderQuery}
onToggleProvider={handleToggleProvider}
onToggleProviderKey={toggleProviderKeyVisibility}
onToggleProviderKeyEditing={toggleProviderKeyEditing}
onChangeProviderForm={(provider, value) =>
setProviderForms((prev) => ({
...prev,
[provider]: {
apiKey: prev[provider]?.apiKey ?? "",
apiBase: prev[provider]?.apiBase ?? "",
apiType: prev[provider]?.apiType ?? "auto",
...value,
},
}))
}
onSaveProvider={saveProvider}
onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")}
onProviderOAuthLogout={(provider) => runProviderOAuth(provider, "logout")}
onResetProviderDraft={resetProviderDraft}
imageProviderRestartPending={pendingRestartSections.image}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
/>
</div>
);
case "image":
return (
<ImageGenerationSettings
settings={settings}
form={imageGenerationForm}
dirty={imageGenerationDirty}
saving={imageGenerationSaving}
onChangeForm={setImageGenerationForm}
onSave={saveImageGenerationSettings}
onOpenProviders={() => selectSection("models")}
showBrandLogos={localPrefs.brandLogos}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.image}
/>
);
case "browser":
return (
<WebSettings
settings={settings}
form={webSearchForm}
keyVisible={webSearchKeyVisible}
keyEditing={webSearchKeyEditing}
saving={webSearchSaving}
onChangeForm={setWebSearchForm}
onChangeProvider={handleWebSearchProviderChange}
onToggleKey={() => setWebSearchKeyVisible((visible) => !visible)}
onToggleKeyEditing={() => {
setWebSearchKeyEditing((editing) => !editing);
setWebSearchKeyVisible(false);
setWebSearchForm((prev) => ({ ...prev, apiKey: "" }));
}}
onReset={resetWebSearchDraft}
onSave={saveWebSearch}
showBrandLogos={localPrefs.brandLogos}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.browser}
/>
);
case "apps":
return (
<AppsCatalogSettings
cliApps={cliApps}
mcpPresets={mcpPresets}
cliAppsLoading={cliAppsLoading}
mcpPresetsLoading={mcpPresetsLoading}
query={appsQuery}
filter={appsKindFilter}
cliActionKey={cliAppsAction}
mcpActionKey={mcpPresetAction}
cliMessage={cliAppsMessage}
cliError={cliAppsError}
cliFocusName={cliAppsFocusName}
mcpMessage={mcpMessage}
mcpError={mcpError}
mcpFieldValues={mcpFieldValues}
customMcpForm={customMcpForm}
mcpConfigImport={mcpConfigImport}
showBrandLogos={localPrefs.brandLogos}
requiresRestartPending={pendingRestartSections.runtime}
onQueryChange={setAppsQuery}
onFilterChange={setAppsKindFilter}
onCliAction={handleCliAppAction}
onMcpAction={handleMcpPresetAction}
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 "skills":
return <SkillsCatalogSettings skills={skills} />;
case "runtime":
return (
<RuntimeSettings
form={form}
setForm={setForm}
settings={settings}
dirty={runtimeDirty}
saving={saving}
onSave={saveRuntimeSettings}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.runtime}
/>
);
case "advanced":
return (
<AdvancedSettings
form={networkSafetyForm}
dirty={networkSafetyDirty}
saving={networkSafetySaving}
isNativeHostSurface={(settings.surface ?? settings.runtime_surface) === "native"}
onChangeForm={setNetworkSafetyForm}
onSave={saveNetworkSafetySettings}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.runtime}
/>
);
default:
return null;
}
};
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(circle_at_50%_0%,hsl(var(--muted))_0%,hsl(var(--background))_42%)] md:flex-row">
{showSidebar ? (
<SettingsSidebar
activeSection={activeSection}
onSelectSection={selectSection}
onBackToChat={onBackToChat}
onLogout={onLogout}
hostChromeInset={hostChromeInset}
/>
) : null}
<NewModelConfigurationDialog
open={modelConfigurationOpen}
draft={modelConfigurationForm}
providers={configuredModelProviderOptions}
saving={modelConfigurationSaving}
showProviderLogos={localPrefs.brandLogos}
onOpenChange={setModelConfigurationOpen}
onChangeDraft={setModelConfigurationForm}
onSave={handleCreateModelConfiguration}
/>
<main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<div
className={cn(
"mx-auto w-full max-w-[920px] px-5 py-8 sm:px-8 lg:py-12",
hostChromeInset && "pt-[4.25rem] sm:pt-[4.25rem] lg:pt-[4.75rem]",
)}
>
<div className="mb-7">
{!showSidebar ? (
<button
type="button"
onClick={onBackToChat}
className="mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
</button>
) : null}
<p className="mb-2 text-[12px] font-normal text-muted-foreground">
{t("settings.sidebar.title")}
</p>
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
{text(`settings.nav.${activeSection}`, titleForSection(activeSection))}
</h1>
</div>
{loading ? (
<div className="flex h-48 items-center justify-center rounded-[24px] border border-border/50 bg-card/75 text-sm text-muted-foreground shadow-[0_20px_70px_rgba(15,23,42,0.07)]">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("settings.status.loading")}
</div>
) : error && !settings ? (
<SettingsGroup>
<SettingsRow title={t("settings.status.loadError")}>
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
</SettingsRow>
</SettingsGroup>
) : settings ? (
<div className="space-y-5">
{error ? (
<div className="rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
{renderSection()}
</div>
) : null}
</div>
</main>
</div>
);
}
const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fallback: string }> = [
{ key: "overview", icon: Activity, fallback: "Overview" },
{ key: "appearance", icon: Palette, fallback: "Appearance" },
{ key: "models", icon: SlidersHorizontal, fallback: "Models" },
{ key: "image", icon: ImageIcon, fallback: "Image" },
{ key: "browser", icon: Globe2, fallback: "Web" },
{ key: "runtime", icon: Server, fallback: "System" },
{ key: "advanced", icon: ShieldCheck, fallback: "Security" },
];
function visibleWebuiDefaultAccessMode(mode: string | null | undefined): WebuiDefaultAccessMode {
return mode === "full" ? "full" : "default";
}
function titleForSection(section: SettingsSectionKey): string {
return SETTINGS_NAV_ITEMS.find((item) => item.key === section)?.fallback ?? "Settings";
}
function SettingsSidebar({
activeSection,
onSelectSection,
onBackToChat,
onLogout,
hostChromeInset,
}: {
activeSection: SettingsSectionKey;
onSelectSection: (section: SettingsSectionKey) => void;
onBackToChat: () => void;
onLogout?: () => void;
hostChromeInset?: boolean;
}) {
const { t } = useTranslation();
return (
<aside
className={cn(
"flex w-full shrink-0 flex-col border-b border-border/55 bg-card/62 px-4 pb-3 shadow-[inset_0_-1px_0_rgba(255,255,255,0.55)] backdrop-blur-xl dark:bg-card/45 dark:shadow-none md:w-[17rem] md:border-b-0 md:border-r md:px-3 md:pb-4 md:shadow-[inset_-1px_0_0_rgba(255,255,255,0.55)]",
hostChromeInset ? "pt-[4.25rem] md:pt-[4.25rem]" : "pt-4 md:pt-4",
)}
>
<button
type="button"
onClick={onBackToChat}
className="mb-2 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground md:mb-3"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
</button>
<div className="mb-3 px-1 md:mb-4 md:px-2">
<h2 className="text-[18px] font-normal tracking-normal text-foreground">
{t("settings.sidebar.title")}
</h2>
</div>
<nav
aria-label={t("settings.sidebar.ariaLabel")}
className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden md:mx-0 md:block md:space-y-1 md:overflow-visible md:px-0 md:pb-0"
>
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
<button
key={key}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onSelectSection(key)}
className={cn(
"flex h-9 w-auto shrink-0 items-center gap-2 rounded-full px-3 text-left text-[13px] font-medium transition-colors md:w-full md:rounded-[10px] md:px-2.5",
active
? "bg-muted/90 text-foreground shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="truncate">{t(`settings.nav.${key}`, { defaultValue: fallback })}</span>
</button>
);
})}
</nav>
<div className="hidden md:mt-auto md:block md:pt-4">
{onLogout && !hostChromeInset ? (
<Button
type="button"
variant="ghost"
onClick={onLogout}
className="h-9 w-full justify-start gap-2 rounded-[10px] px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
>
<LogOut className="h-4 w-4" aria-hidden />
{t("app.account.logout")}
</Button>
) : null}
</div>
</aside>
);
}
function OverviewSettings({
settings,
requiresRestart,
onSelectSection,
showBrandLogos,
}: {
settings: SettingsPayload;
requiresRestart: boolean;
onSelectSection: (section: SettingsSectionKey) => void;
showBrandLogos: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const activePreset = settings.agent.model_preset || "default";
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider);
const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider);
const activeModelValue = activeProviderConfigured
? settings.agent.model
: tx("settings.values.notConfigured", "Not configured");
const activeModelCaption = activeProviderConfigured
? `${activeProvider} · ${activePreset}`
: activeProviderLabel || settings.agent.model
? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
const webStatus = settings.web.enable
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
const imageStatus = settings.image_generation.enabled
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
const imageCaption = `${providerDisplayLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${
settings.image_generation.provider_configured
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")
}`;
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
const runtimeTitle = isNativeHost
? tx("settings.rows.engine", "Engine")
: tx("settings.rows.gateway", "Gateway");
const runtimeValue = isNativeHost
? tx("settings.values.privateEngine", "Private engine")
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
const runtimeCaption = isNativeHost
? tx("settings.values.unixSocket", "Unix socket")
: requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready");
return (
<div className="space-y-7">
<section>
<TokenUsageHeatmap usage={settings.usage} />
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.ai", "AI")}</SettingsSectionTitle>
<SettingsGroup>
<OverviewListRow
icon={Bot}
valueLogoProvider={activeProvider}
title={tx("settings.overview.model", "Current model")}
value={activeModelValue}
caption={activeModelCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("models")}
/>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.capabilities", "Capabilities")}</SettingsSectionTitle>
<SettingsGroup>
<OverviewListRow
icon={Globe2}
valueLogoProvider={settings.web_search.provider}
title={tx("settings.overview.webSearch", "Web search")}
value={providerDisplayLabel(settings.web_search.providers, settings.web_search.provider)}
caption={webStatus}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("browser")}
/>
<OverviewListRow
icon={ImageIcon}
valueLogoProvider={settings.image_generation.provider}
title={tx("settings.overview.imageGeneration", "Image generation")}
value={imageStatus}
caption={imageCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("image")}
/>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.system", "System")}</SettingsSectionTitle>
<SettingsGroup>
<OverviewListRow
icon={Server}
title={runtimeTitle}
value={runtimeValue}
caption={runtimeCaption}
onClick={() => onSelectSection("runtime")}
/>
<OverviewListRow
icon={HardDrive}
title={tx("settings.overview.workspace", "Workspace")}
value={tx("settings.values.defaultWorkspace", "Default workspace")}
caption={workspaceCaption}
onClick={() => onSelectSection("runtime")}
/>
</SettingsGroup>
</section>
</div>
);
}
function AppearanceSettings({
theme,
onToggleTheme,
localPrefs,
onChangeLocalPrefs,
}: {
theme: "light" | "dark";
onToggleTheme: () => void;
localPrefs: LocalPreferences;
onChangeLocalPrefs: Dispatch<SetStateAction<LocalPreferences>>;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={t("settings.rows.theme")}
description={t("settings.help.theme")}
>
<button
type="button"
onClick={onToggleTheme}
className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
>
<span
className={cn(
"rounded-full px-3 py-1 transition-colors",
theme === "light" && "bg-background text-foreground shadow-sm",
)}
>
{t("settings.values.light")}
</span>
<span
className={cn(
"rounded-full px-3 py-1 transition-colors",
theme === "dark" && "bg-background text-foreground shadow-sm",
)}
>
{t("settings.values.dark")}
</span>
</button>
</SettingsRow>
<SettingsRow
title={t("settings.rows.language")}
description={t("settings.help.language")}
>
<LanguageSwitcher />
</SettingsRow>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.localPreferences", "Local preferences")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.density", "Density")}
description={tx("settings.help.density", "Stored only in this browser.")}
>
<SegmentedControl
value={localPrefs.density}
options={[
{ value: "comfortable", label: tx("settings.values.comfortable", "Comfortable") },
{ value: "compact", label: tx("settings.values.compact", "Compact") },
]}
onChange={(density) =>
onChangeLocalPrefs((prev) => ({ ...prev, density: density as LocalDensity }))
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.activityMode", "Activity detail")}
description={tx("settings.help.activityMode", "Choose how much agent activity chrome to show by default.")}
>
<SegmentedControl
value={localPrefs.activityMode}
options={[
{ value: "auto", label: tx("settings.values.auto", "Auto") },
{ value: "expanded", label: tx("settings.values.expanded", "Expanded") },
]}
onChange={(activityMode) =>
onChangeLocalPrefs((prev) => ({ ...prev, activityMode: activityMode as LocalActivityMode }))
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.codeWrap", "Code wrapping")}
description={tx("settings.help.codeWrap", "Keep long code lines readable on smaller screens.")}
>
<ToggleButton
checked={localPrefs.codeWrap}
onChange={(codeWrap) => onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))}
ariaLabel={tx("settings.rows.codeWrap", "Code wrapping")}
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx("settings.help.brandLogos", "Show third-party provider and CLI logos in Settings.")}
>
<ToggleButton
checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
ariaLabel={tx("settings.rows.brandLogos", "Brand logos")}
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
</section>
</div>
);
}
function NewModelConfigurationDialog({
open,
draft,
providers,
saving,
showProviderLogos,
onOpenChange,
onChangeDraft,
onSave,
}: {
open: boolean;
draft: ModelConfigurationDraft;
providers: Array<{ name: string; label: string }>;
saving: boolean;
showProviderLogos: boolean;
onOpenChange: (open: boolean) => void;
onChangeDraft: Dispatch<SetStateAction<ModelConfigurationDraft>>;
onSave: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const canSave = Boolean(draft.label.trim() && draft.provider.trim() && draft.model.trim());
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[520px] rounded-[28px] border-border/55 bg-card/95 p-0 shadow-[0_28px_90px_rgba(15,23,42,0.20)] backdrop-blur-xl dark:border-white/10">
<form
onSubmit={(event) => {
event.preventDefault();
onSave();
}}
>
<DialogHeader className="border-b border-border/45 px-5 py-4 text-left">
<DialogTitle className="text-[18px] font-semibold tracking-[-0.01em]">
{tx("settings.models.newConfiguration", "New model configuration")}
</DialogTitle>
<DialogDescription className="text-[12.5px] leading-5">
{tx("settings.models.newConfigurationHelp", "Save a provider and model as a one-click option.")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-5 py-5">
<label className="block">
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{tx("settings.models.configurationName", "Configuration name")}
</span>
<Input
autoFocus
value={draft.label}
placeholder={tx("settings.models.configurationNamePlaceholder", "Fast writing")}
onChange={(event) =>
onChangeDraft((prev) => ({ ...prev, label: event.target.value }))
}
className="h-10 rounded-full px-4 text-[14px]"
/>
</label>
<div className="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<label className="block">
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{tx("settings.rows.model", "Model")}
</span>
<Input
value={draft.model}
placeholder="openai/gpt-4.1"
onChange={(event) =>
onChangeDraft((prev) => ({ ...prev, model: event.target.value }))
}
className="h-10 rounded-full px-4 text-[14px]"
/>
</label>
<div className="block">
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{tx("settings.rows.provider", "Provider")}
</span>
<ProviderPicker
providers={providers}
value={draft.provider}
emptyLabel={tx("settings.byok.noConfiguredProviders", "No configured providers")}
showProviderLogos={showProviderLogos}
onChange={(provider) =>
onChangeDraft((prev) => ({ ...prev, provider }))
}
/>
</div>
</div>
</div>
<DialogFooter className="border-t border-border/45 px-5 py-4 sm:space-x-2">
<Button
type="button"
variant="ghost"
className="rounded-full"
disabled={saving}
onClick={() => onOpenChange(false)}
>
{tx("settings.actions.cancel", "Cancel")}
</Button>
<Button
type="submit"
variant="outline"
className="rounded-full"
disabled={!canSave || saving || providers.length === 0}
>
{saving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{saving ? tx("settings.actions.saving", "Saving...") : tx("settings.actions.save", "Save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function ModelsSettings({
token,
form,
setForm,
settings,
dirty,
saving,
showBrandLogos,
providerSaving,
onProviderOAuthLogin,
onSave,
onCreateConfiguration,
}: {
token: string;
form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
settings: SettingsPayload;
dirty: boolean;
saving: boolean;
showBrandLogos: boolean;
providerSaving: string | null;
onProviderOAuthLogin: (provider: string) => void;
onSave: () => void;
onCreateConfiguration: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const configuredProviders = settings.providers.filter((provider) => provider.configured);
const showAutoProvider = defaultPreset(settings)?.provider === "auto" || form.provider === "auto";
const selectableProviders = uniqueProviders(configuredProviders);
const providerOptions = showAutoProvider
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
: selectableProviders;
const providerValue = providerOptions.some((provider) => provider.name === form.provider)
? form.provider
: "";
const selectedPreset =
settings.model_presets.find((preset) => preset.name === form.modelPreset) ?? null;
const selectedProvider = settings.providers.find((provider) => provider.name === form.provider);
const selectedProviderNeedsSignIn =
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
const selectedProviderConfigured = settingsProviderConfigured(settings, form.provider);
const modelFieldsMissing =
!form.model.trim() ||
!form.provider.trim() ||
Boolean(selectedPreset && !selectedPreset.is_default && !form.presetLabel.trim());
return (
<div className="space-y-7">
<section>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.currentModel", "Current configuration")}
description={tx("settings.help.currentModel", "Used for new replies.")}
>
<ModelPresetPicker
presets={settings.model_presets}
value={form.modelPreset}
settings={settings}
draftModel={form.model}
draftProvider={form.provider}
providerConfigured={selectedProviderConfigured}
showProviderLogos={showBrandLogos}
onChange={(modelPreset) => {
const nextPreset = settings.model_presets.find((preset) => preset.name === modelPreset);
setForm((prev) => ({
...prev,
modelPreset,
model: nextPreset?.model ?? prev.model,
provider: nextPreset?.is_default
? editableDefaultProvider(settings)
: nextPreset?.provider ?? prev.provider,
presetLabel: nextPreset?.label ?? modelPreset,
contextWindowTokens: normalizeContextWindowTokens(
nextPreset?.context_window_tokens ?? prev.contextWindowTokens,
),
}));
}}
onCreateConfiguration={onCreateConfiguration}
/>
</SettingsRow>
{selectedPreset && !selectedPreset.is_default ? (
<SettingsRow
title={tx("settings.models.configurationName", "Configuration name")}
description={tx("settings.models.configurationNameHelp", "Rename this saved model configuration.")}
>
<Input
value={form.presetLabel}
onChange={(event) =>
setForm((prev) => ({ ...prev, presetLabel: event.target.value }))
}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
/>
</SettingsRow>
) : null}
<SettingsRow
title={t("settings.rows.provider")}
description={t("settings.help.provider")}
>
<ProviderPicker
providers={providerOptions}
value={providerValue}
emptyLabel={t("settings.byok.noConfiguredProviders")}
showProviderLogos={showBrandLogos}
onChange={(provider) =>
setForm((prev) => ({
...prev,
provider,
model: provider === prev.provider ? prev.model : "",
}))
}
/>
</SettingsRow>
{selectedProviderNeedsSignIn ? (
<SettingsRow
title={tx("settings.oauth.signInRequired", "Sign in required")}
description={tx(
"settings.oauth.signInBeforeSaving",
"Sign in before saving this OAuth provider as the active model provider.",
)}
>
<Button
size="sm"
variant="outline"
onClick={() => selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
className="rounded-full"
>
{selectedProviderSigningIn ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{selectedProviderSigningIn
? tx("settings.oauth.signingIn", "Signing in...")
: tx("settings.oauth.signIn", "Sign in")}
</Button>
</SettingsRow>
) : null}
<SettingsRow
title={t("settings.rows.model")}
description={t("settings.help.model")}
>
<ModelIdPicker
token={token}
settings={settings}
provider={form.provider}
value={form.model}
showProviderLogos={showBrandLogos}
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.contextWindow", "Context window")}
description={tx(
"settings.help.contextWindow",
"Choose the default context budget for this model configuration.",
)}
>
<SegmentedControl
value={String(form.contextWindowTokens)}
options={CONTEXT_WINDOW_TOKEN_OPTIONS.map((tokens) => ({
value: String(tokens),
label: tokens === 262_144 ? "256K" : "64K",
}))}
onChange={(value) =>
setForm((prev) => ({
...prev,
contextWindowTokens: normalizeContextWindowTokens(Number(value)),
}))
}
/>
</SettingsRow>
<SettingsFooter
dirty={dirty}
saving={saving}
saved={false}
disabled={selectedProviderNeedsSignIn || modelFieldsMissing}
message={
selectedProviderNeedsSignIn
? tx("settings.oauth.signInBeforeSaving", "Sign in before saving this OAuth provider as the active model provider.")
: undefined
}
onSave={onSave}
/>
</SettingsGroup>
</section>
</div>
);
}
function ProvidersSettings({
settings,
expandedProvider,
providerForms,
visibleProviderKeys,
editingProviderKeys,
providerSaving,
query,
showBrandLogos,
onQueryChange,
onToggleProvider,
onToggleProviderKey,
onToggleProviderKeyEditing,
onChangeProviderForm,
onSaveProvider,
onProviderOAuthLogin,
onProviderOAuthLogout,
onResetProviderDraft,
imageProviderRestartPending,
onRestart,
isRestarting,
}: {
settings: SettingsPayload;
expandedProvider: string | null;
providerForms: Record<string, ProviderForm>;
visibleProviderKeys: Record<string, boolean>;
editingProviderKeys: Record<string, boolean>;
providerSaving: string | null;
query: string;
showBrandLogos: boolean;
onQueryChange: (query: string) => void;
onToggleProvider: (provider: string) => void;
onToggleProviderKey: (provider: string) => void;
onToggleProviderKeyEditing: (provider: string) => void;
onChangeProviderForm: (provider: string, value: Partial<ProviderForm>) => void;
onSaveProvider: (provider: string) => void;
onProviderOAuthLogin: (provider: string) => void;
onProviderOAuthLogout: (provider: string) => void;
onResetProviderDraft: (provider: string) => void;
imageProviderRestartPending: boolean;
onRestart?: () => void;
isRestarting?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const configuredProviders = settings.providers.filter((provider) => provider.configured);
const unconfiguredProviders = useMemo(
() => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)),
[settings.providers],
);
const filteredConfigured = filterProviders(configuredProviders, query);
const filteredUnconfigured = filterProviders(unconfiguredProviders, query);
const renderProviderRow = (provider: SettingsPayload["providers"][number]) => {
const expanded = expandedProvider === provider.name;
const form = providerForms[provider.name] ?? {
apiKey: "",
apiBase: provider.api_base ?? provider.default_api_base ?? "",
apiType: provider.api_type ?? "auto",
};
const saving = providerSaving === provider.name;
const isOauthProvider = provider.auth_type === "oauth";
const keyVisible = !!visibleProviderKeys[provider.name];
const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
const apiKeyRequired = provider.api_key_required ?? true;
const apiKey = form.apiKey.trim();
const apiBase = form.apiBase.trim();
const missingRequiredApiKey = !isOauthProvider && apiKeyRequired && !provider.configured && !apiKey;
const missingOptionalCredential =
!isOauthProvider && !apiKeyRequired && !provider.configured && !apiKey && !apiBase;
return (
<div key={provider.name} className="divide-y divide-border/45">
<button
type="button"
onClick={() => onToggleProvider(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"
>
<span className="flex min-w-0 items-center gap-3">
<ProviderIcon
provider={provider.name}
showBrandLogos={showBrandLogos}
/>
<span className="min-w-0">
<span className="block truncate text-[15px] font-semibold leading-5 text-foreground">
{provider.label}
</span>
<span className="block truncate text-[12px] text-muted-foreground">
{provider.api_base || provider.default_api_base || provider.name}
</span>
</span>
</span>
<StatusPill tone={provider.configured ? "success" : "neutral"}>
{isOauthProvider
? provider.configured
? tx("settings.oauth.signedIn", "Signed in")
: tx("settings.oauth.notSignedIn", "Not signed in")
: provider.configured
? t("settings.byok.configured")
: t("settings.byok.notConfigured")}
</StatusPill>
</button>
{expanded ? (
<div className="space-y-3 bg-muted/18 px-4 py-4 sm:px-5">
{isOauthProvider ? (
<div className="flex flex-col gap-3 rounded-[18px] border border-border/45 bg-background/75 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-[13px] font-semibold text-foreground">
{tx("settings.oauth.authentication", "OAuth authentication")}
</p>
<p className="mt-1 truncate text-[12px] text-muted-foreground">
{provider.configured
? t("settings.oauth.signedInAs", {
account: provider.oauth_account || provider.label,
defaultValue: "Signed in as {{account}}",
})
: tx("settings.oauth.signInHelp", "Sign in from this device; no API key is stored in config.")}
</p>
</div>
<div className="flex shrink-0 justify-end gap-2">
{provider.configured ? (
<Button
size="sm"
variant="ghost"
onClick={() => onProviderOAuthLogout(provider.name)}
disabled={saving}
className="rounded-full"
>
{tx("settings.oauth.signOut", "Sign out")}
</Button>
) : null}
<Button
size="sm"
variant="outline"
onClick={() => onProviderOAuthLogin(provider.name)}
disabled={saving || !provider.oauth_login_supported}
className="rounded-full"
>
{saving ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
{saving
? tx("settings.oauth.signingIn", "Signing in...")
: provider.configured
? tx("settings.oauth.signInAgain", "Sign in again")
: tx("settings.oauth.signIn", "Sign in")}
</Button>
</div>
</div>
) : (
<>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{t("settings.byok.apiKey")}
</span>
<div className="relative">
{editingKey ? (
<>
<Input
type={keyVisible ? "text" : "password"}
value={form.apiKey}
onChange={(event) =>
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]"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => 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 ? (
<EyeOff className="h-3.5 w-3.5" aria-hidden />
) : (
<Eye className="h-3.5 w-3.5" aria-hidden />
)}
</Button>
</>
) : (
<>
<div className="flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground">
{provider.api_key_hint ?? t("settings.byok.configuredKeyHint")}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => 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"
>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
</>
)}
</div>
</label>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{t("settings.byok.apiBase")}
</span>
<Input
value={form.apiBase}
onChange={(event) =>
onChangeProviderForm(provider.name, { apiBase: event.target.value })
}
placeholder={provider.default_api_base ?? t("settings.byok.apiBasePlaceholder")}
className="h-9 rounded-full text-[13px]"
/>
</label>
{provider.name === "openai" ? (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.byok.apiType", "API type")}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="h-9 w-full justify-between rounded-full px-3 text-[13px]"
>
<span>
{OPENAI_API_TYPE_OPTIONS.find((option) => option.value === form.apiType)?.label ??
form.apiType}
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[220px]">
{OPENAI_API_TYPE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() => onChangeProviderForm(provider.name, { apiType: option.value })}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</label>
) : null}
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => onResetProviderDraft(provider.name)}
className="rounded-full"
>
{t("settings.actions.cancel")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onSaveProvider(provider.name)}
disabled={saving || missingRequiredApiKey || missingOptionalCredential}
className="rounded-full"
>
{saving ? t("settings.actions.saving") : tx("settings.providers.saveProvider", "Save provider")}
</Button>
</div>
</>
)}
</div>
) : null}
</div>
);
};
return (
<div className="space-y-6">
<p className="max-w-[42rem] text-[13px] leading-6 text-muted-foreground">
{t("settings.byok.description")}
</p>
{imageProviderRestartPending && onRestart ? (
<div className="flex min-h-[48px] items-center justify-between gap-3 border-y border-border/55 py-3">
<p className="text-[13px] leading-5 text-muted-foreground">
{tx("settings.status.imageProviderRestart", "Image provider changes saved. Restart when ready.")}
</p>
<div className="shrink-0">
<Button
size="sm"
variant="ghost"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
</Button>
</div>
</div>
) : null}
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.providers.searchPlaceholder", "Search providers")}
className="h-10 rounded-full pl-9 text-[13px]"
/>
</div>
<ProviderSection
title={t("settings.byok.configuredSection")}
count={filteredConfigured.length}
empty={t("settings.byok.noConfiguredProviders")}
>
{filteredConfigured.map(renderProviderRow)}
</ProviderSection>
<ProviderSection
title={t("settings.byok.notConfiguredSection")}
count={filteredUnconfigured.length}
empty={tx("settings.providers.noMatches", "No providers match this search.")}
>
{filteredUnconfigured.map(renderProviderRow)}
</ProviderSection>
<ThirdPartyBrandNotice />
</div>
);
}
function ImageGenerationSettings({
settings,
form,
dirty,
saving,
onChangeForm,
onSave,
onOpenProviders,
showBrandLogos,
onRestart,
isRestarting,
requiresRestartPending,
}: {
settings: SettingsPayload;
form: ImageGenerationSettingsUpdate;
dirty: boolean;
saving: boolean;
onChangeForm: Dispatch<SetStateAction<ImageGenerationSettingsUpdate>>;
onSave: () => void;
onOpenProviders: () => void;
showBrandLogos: boolean;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const selectedProvider =
settings.image_generation.providers.find((provider) => provider.name === form.provider) ??
settings.image_generation.providers[0];
const providerConfigured = !!selectedProvider?.configured;
const missingCredential = form.enabled && !providerConfigured;
const aspectOptions = optionRowsWithCurrent(
IMAGE_ASPECT_RATIO_OPTIONS.map((value) => ({ name: value, label: value })),
form.defaultAspectRatio,
);
const sizeOptions = optionRowsWithCurrent(
IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })),
form.defaultImageSize,
);
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{tx("settings.sections.imageGeneration", "Image generation")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.imageGeneration", "Image generation")}
description={tx("settings.help.imageGeneration", "Expose generate_image in chats when a configured image provider is available.")}
>
<ToggleButton
checked={form.enabled}
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
ariaLabel={tx("settings.rows.imageGeneration", "Image generation")}
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.imageProvider", "Image provider")}
description={tx("settings.help.imageProvider", "Choose the registry provider used by generate_image.")}
>
<ProviderPicker
providers={settings.image_generation.providers}
value={form.provider}
emptyLabel={tx("settings.image.selectProvider", "Select provider")}
showProviderLogos={showBrandLogos}
onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.imageProviderStatus", "Provider status")}
description={tx("settings.help.imageProviderStatus", "Image generation reuses provider credentials from Providers.")}
>
<div className="flex flex-wrap items-center justify-end gap-2">
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
{providerConfigured
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")}
</StatusPill>
{!providerConfigured ? (
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
{tx("settings.image.configureProvider", "Configure provider")}
</Button>
) : null}
</div>
</SettingsRow>
<SettingsRow title={tx("settings.rows.imageProviderBase", "Provider base")}>
<span className="max-w-[320px] truncate text-right text-[13px] text-muted-foreground">
{selectedProvider?.api_base || selectedProvider?.default_api_base || selectedProvider?.name || tx("settings.values.notAvailable", "Not available")}
</span>
</SettingsRow>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.imageDefaults", "Defaults")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.imageModel", "Image model")}
description={tx("settings.help.imageModel", "Model name sent to the selected image provider.")}
>
<Input
value={form.model}
onChange={(event) => onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.defaultAspectRatio", "Default aspect")}
description={tx("settings.help.defaultAspectRatio", "Used when the prompt does not choose an aspect ratio.")}
>
<ProviderPicker
providers={aspectOptions}
value={form.defaultAspectRatio}
emptyLabel={tx("settings.image.selectAspect", "Select aspect")}
onChange={(defaultAspectRatio) =>
onChangeForm((prev) => ({ ...prev, defaultAspectRatio }))
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.defaultImageSize", "Default size")}
description={tx("settings.help.defaultImageSize", "Size hint sent to providers that support it.")}
>
<ProviderPicker
providers={sizeOptions}
value={form.defaultImageSize}
emptyLabel={tx("settings.image.selectSize", "Select size")}
onChange={(defaultImageSize) =>
onChangeForm((prev) => ({ ...prev, defaultImageSize }))
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}
description={tx("settings.help.maxImagesPerTurn", "Upper bound for one generate_image request.")}
>
<NumberInput
value={form.maxImagesPerTurn}
min={1}
max={8}
onChange={(maxImagesPerTurn) =>
onChangeForm((prev) => ({ ...prev, maxImagesPerTurn }))
}
/>
</SettingsRow>
<ReadOnlyRow title={tx("settings.rows.imageSaveDir", "Save directory")} value={settings.image_generation.save_dir} />
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
disabled={missingCredential}
message={
missingCredential
? tx("settings.image.missingCredential", "Configure this provider before enabling image generation.")
: undefined
}
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
onSave={onSave}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
</div>
);
}
function WebSettings({
settings,
form,
keyVisible,
keyEditing,
saving,
onChangeForm,
onChangeProvider,
onToggleKey,
onToggleKeyEditing,
onReset,
onSave,
showBrandLogos,
onRestart,
isRestarting,
requiresRestartPending,
}: {
settings: SettingsPayload;
form: WebSearchSettingsUpdate;
keyVisible: boolean;
keyEditing: boolean;
saving: boolean;
onChangeForm: Dispatch<SetStateAction<WebSearchSettingsUpdate>>;
onChangeProvider: (provider: string) => void;
onToggleKey: () => void;
onToggleKeyEditing: () => void;
onReset: () => void;
onSave: () => void;
showBrandLogos: boolean;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const selectedProvider =
settings.web_search.providers.find((provider) => provider.name === form.provider) ??
settings.web_search.providers[0];
const hasExistingSecret =
selectedProvider?.credential === "api_key" &&
form.provider === settings.web_search.provider &&
!!settings.web_search.api_key_hint;
const showKeyInput = selectedProvider?.credential === "api_key" && (!hasExistingSecret || keyEditing);
const apiKey = form.apiKey?.trim() ?? "";
const baseUrl = form.baseUrl?.trim() ?? "";
const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader;
const dirty =
form.provider !== settings.web_search.provider ||
apiKey.length > 0 ||
baseUrl !== (settings.web_search.base_url ?? "") ||
form.maxResults !== settings.web_search.max_results ||
form.timeout !== settings.web_search.timeout ||
effectiveJinaReader !== settings.web.fetch.use_jina_reader;
const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader;
const missingCredential =
selectedProvider?.credential === "api_key"
? !apiKey && !hasExistingSecret
: selectedProvider?.credential === "base_url"
? !baseUrl
: false;
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={t("settings.byok.webSearch.provider")}
description={t("settings.byok.webSearch.providerHelp")}
>
<ProviderPicker
providers={settings.web_search.providers}
value={form.provider}
emptyLabel={t("settings.byok.webSearch.selectProvider")}
showProviderLogos={showBrandLogos}
onChange={onChangeProvider}
/>
</SettingsRow>
{selectedProvider?.credential === "none" ? (
<SettingsRow
title={t("settings.byok.webSearch.credentials")}
description={t("settings.byok.webSearch.noCredentialHelp")}
>
<StatusPill tone="success">{t("settings.byok.webSearch.noCredentialRequired")}</StatusPill>
</SettingsRow>
) : null}
{selectedProvider?.credential === "api_key" ? (
<SettingsRow
title={t("settings.byok.apiKey")}
description={t("settings.byok.webSearch.apiKeyHelp")}
>
<div className="relative w-[280px] max-w-full">
{showKeyInput ? (
<>
<Input
type={keyVisible ? "text" : "password"}
value={form.apiKey ?? ""}
onChange={(event) =>
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]"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onToggleKey}
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 ? (
<EyeOff className="h-3.5 w-3.5" aria-hidden />
) : (
<Eye className="h-3.5 w-3.5" aria-hidden />
)}
</Button>
</>
) : (
<>
<div className="flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground">
{settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onToggleKeyEditing}
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"
>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
</>
)}
</div>
</SettingsRow>
) : null}
{selectedProvider?.credential === "base_url" ? (
<SettingsRow
title={t("settings.byok.webSearch.baseUrl")}
description={t("settings.byok.webSearch.baseUrlHelp")}
>
<Input
value={form.baseUrl ?? ""}
onChange={(event) =>
onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value }))
}
placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")}
className="h-9 w-[280px] rounded-full text-[13px]"
/>
</SettingsRow>
) : null}
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.webBehavior", "Behavior")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.maxResults", "Max results")}
description={tx("settings.help.maxResults", "Results returned by each web_search call.")}
>
<NumberInput
value={form.maxResults ?? settings.web_search.max_results}
min={1}
max={10}
onChange={(maxResults) => onChangeForm((prev) => ({ ...prev, maxResults }))}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.timeout", "Timeout")}
description={tx("settings.help.timeout", "Seconds before a search provider request times out.")}
>
<NumberInput
value={form.timeout ?? settings.web_search.timeout}
min={1}
max={120}
onChange={(timeout) => onChangeForm((prev) => ({ ...prev, timeout }))}
suffix="s"
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.jinaReader", "Jina reader")}
description={tx("settings.help.jinaReader", "Use Jina Reader for web_fetch when available.")}
>
<ToggleButton
checked={effectiveJinaReader}
onChange={(useJinaReader) => onChangeForm((prev) => ({ ...prev, useJinaReader }))}
ariaLabel={tx("settings.rows.jinaReader", "Jina reader")}
label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
disabled={missingCredential}
message={
missingCredential
? t("settings.byok.webSearch.missingCredential")
: requiresRestartPending && !dirty
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
: jinaReaderDirty
? tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")
: dirty
? t("settings.byok.webSearch.saveHint")
: undefined
}
onSave={onSave}
onRestart={onRestart}
onReset={onReset}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
</div>
);
}
function AppsCatalogSettings({
cliApps,
mcpPresets,
cliAppsLoading,
mcpPresetsLoading,
query,
filter,
cliActionKey,
mcpActionKey,
cliMessage,
cliError,
cliFocusName,
mcpMessage,
mcpError,
mcpFieldValues,
customMcpForm,
mcpConfigImport,
showBrandLogos,
requiresRestartPending,
onQueryChange,
onFilterChange,
onCliAction,
onMcpAction,
onDismissStatus,
onBackToChat,
onMcpFieldChange,
onCustomMcpFormChange,
onMcpConfigImportChange,
onSaveCustomMcp,
onImportMcpConfig,
onMcpToolsChange,
onRestart,
isRestarting,
}: {
cliApps: CliAppsPayload | null;
mcpPresets: McpPresetsPayload | null;
cliAppsLoading: boolean;
mcpPresetsLoading: boolean;
query: string;
filter: AppsKindFilter;
cliActionKey: string | null;
mcpActionKey: string | null;
cliMessage: string | null;
cliError: string | null;
cliFocusName: string | null;
mcpMessage: string | null;
mcpError: string | null;
mcpFieldValues: Record<string, Record<string, string>>;
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<string, string>) => void;
onDismissStatus: () => void;
onBackToChat: () => void;
onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void;
onCustomMcpFormChange: Dispatch<SetStateAction<CustomMcpForm>>;
onMcpConfigImportChange: (value: string) => void;
onSaveCustomMcp: () => void;
onImportMcpConfig: () => void;
onMcpToolsChange: (name: string, enabledTools: string[]) => void;
onRestart?: () => void;
isRestarting?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const filterOptions = [
{ value: "all", label: tx("settings.apps.filterAll", "All") },
{ value: "cli", label: tx("settings.apps.filterCli", "App CLIs") },
{ value: "mcp", label: tx("settings.apps.filterMcp", "MCP services") },
];
const normalizedQuery = query.trim().toLowerCase();
const items: AppsCatalogItem[] = [
...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })),
...(mcpPresets?.presets ?? []).map((preset) => ({
id: `mcp:${preset.name}`,
kind: "mcp" as const,
preset,
})),
]
.filter((item) => filter === "all" || item.kind === filter)
.filter((item) => !normalizedQuery || appsSearchText(item).includes(normalizedQuery))
.sort((left, right) => {
const rank = Number(!appsReady(left)) - Number(!appsReady(right));
return rank || appsTitle(left).localeCompare(appsTitle(right));
});
const focusedApp = cliFocusName
? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed)
: null;
const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets;
const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null);
const statusIsError = Boolean(cliError || mcpError);
const caption = t("settings.apps.caption", {
cli: cliApps?.installed_count ?? 0,
mcp: mcpPresets?.installed_count ?? 0,
defaultValue: "{{cli}} CLI · {{mcp}} MCP",
});
return (
<div className="space-y-7">
<section className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{tx(
"settings.apps.description",
"Add local app adapters and connected tool servers that nanobot can use from chat.",
)}
</p>
<span className="text-[12px] font-medium text-muted-foreground">{caption}</span>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")}
className="h-12 rounded-[14px] border-border/70 bg-card/90 pl-11 text-[15px] shadow-sm"
/>
</div>
<SegmentedControl
value={filter}
options={filterOptions}
onChange={(value) => onFilterChange(value as AppsKindFilter)}
/>
</div>
</section>
{statusMessage ? (
<div
className={cn(
"flex items-center justify-between gap-3 rounded-[12px] border py-2.5 pl-4 pr-2 text-[13px]",
statusIsError
? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/55 bg-muted/35 text-muted-foreground",
)}
>
<span className="min-w-0">{statusMessage}</span>
<button
type="button"
aria-label={tx("settings.actions.dismiss", "Dismiss")}
title={tx("settings.actions.dismiss", "Dismiss")}
onClick={onDismissStatus}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
statusIsError
? "text-destructive/70 hover:bg-destructive/10 hover:text-destructive"
: "text-muted-foreground/70 hover:bg-muted hover:text-foreground",
)}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
) : null}
{focusedApp ? (
<CliAppReadyPanel app={focusedApp} showBrandLogos={showBrandLogos} onBackToChat={onBackToChat} />
) : null}
{requiresRestartPending ? (
<div className="flex flex-col gap-3 rounded-[12px] border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
<span>{tx("settings.mcp.restartRequired", "Restart nanobot to connect updated MCP tools.")}</span>
{onRestart ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRestart}
disabled={isRestarting}
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
</Button>
) : null}
</div>
) : null}
<section>
<div className="flex items-center justify-between border-b border-border/45 pb-3">
<SettingsSectionTitle>{tx("settings.apps.featured", "Featured")}</SettingsSectionTitle>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{items.length}
</span>
</div>
{loading ? (
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
{tx("settings.apps.loading", "Loading Apps...")}
</div>
) : items.length ? (
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
{items.map((item) =>
item.kind === "cli" ? (
<CliAppsCatalogRow
key={item.id}
app={item.app}
actionKey={cliActionKey}
showBrandLogos={showBrandLogos}
onAction={onCliAction}
/>
) : (
<McpAppsCatalogRow
key={item.id}
preset={item.preset}
values={mcpFieldValues[item.preset.name] ?? {}}
actionKey={mcpActionKey}
showBrandLogos={showBrandLogos}
onFieldChange={onMcpFieldChange}
onAction={onMcpAction}
onToolsChange={onMcpToolsChange}
/>
),
)}
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{tx("settings.apps.empty", "No apps match this filter.")}
</div>
)}
</section>
{filter !== "cli" ? (
<McpCustomServerPanel
form={customMcpForm}
configImport={mcpConfigImport}
actionKey={mcpActionKey}
onFormChange={onCustomMcpFormChange}
onConfigImportChange={onMcpConfigImportChange}
onSave={onSaveCustomMcp}
onImportConfig={onImportMcpConfig}
/>
) : null}
<ThirdPartyBrandNotice />
</div>
);
}
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 (
<article className="group flex min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 transition-colors hover:bg-muted/45">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{app.display_name}</h3>
<AppsTypeBadge>{tx("settings.apps.cliLabel", "CLI")}</AppsTypeBadge>
</div>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{app.installed ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppsActionButton
ariaLabel={tx("settings.cliApps.statusInstalled", "CLI installed")}
busy={testBusy || updateBusy}
disabled={busy}
tone="installed"
>
<Check className="h-4 w-4" aria-hidden />
</AppsActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}>
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.test", "Test CLI")}
</DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}>
<RotateCcw className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.update", "Update CLI")}
</DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("uninstall", app.name)}>
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.uninstall", "Uninstall CLI")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AppsActionButton
ariaLabel={tx("settings.cliApps.uninstall", "Uninstall CLI")}
busy={uninstallBusy}
disabled={busy && !uninstallBusy}
tone="danger"
onClick={() => onAction("uninstall", app.name)}
>
<Trash2 className="h-4 w-4" aria-hidden />
</AppsActionButton>
</>
) : app.install_supported ? (
<AppsActionButton
ariaLabel={tx("settings.cliApps.install", "Install CLI")}
busy={installBusy}
onClick={() => onAction("install", app.name)}
>
<Plus className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : (
<AppsActionButton ariaLabel={tx("settings.cliApps.unavailable", "Unavailable")} disabled>
<Plus className="h-4 w-4" aria-hidden />
</AppsActionButton>
)}
</div>
</article>
);
}
function McpAppsCatalogRow({
preset,
values,
actionKey,
showBrandLogos,
onFieldChange,
onAction,
onToolsChange,
}: {
preset: McpPresetInfo;
values: Record<string, string>;
actionKey: string | null;
showBrandLogos: boolean;
onFieldChange: (presetName: string, fieldName: string, value: string) => void;
onAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
onToolsChange: (name: string, enabledTools: string[]) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [setupOpen, setSetupOpen] = useState(false);
const [toolsOpen, setToolsOpen] = useState(false);
const enableBusy = actionKey === `enable:${preset.name}`;
const removeBusy = actionKey === `remove:${preset.name}`;
const testBusy = actionKey === `test:${preset.name}`;
const toolsBusy = actionKey === `tools:${preset.name}`;
const busy = enableBusy || removeBusy || testBusy || toolsBusy;
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
const hasFields = preset.required_fields.length > 0;
const needsSetupInput = missingFields.length > 0;
const readyInstalled = preset.installed && preset.configured;
const canEnable =
preset.install_supported &&
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
const toolNames = preset.tool_names ?? [];
const enabledTools = preset.enabled_tools ?? ["*"];
const allowAllTools = enabledTools.includes("*");
const enabledSet = new Set(allowAllTools ? toolNames : enabledTools);
const description = preset.description || preset.note || preset.requires || preset.name;
const statusLabel = mcpPresetStatusLabel(preset.status, tx);
useEffect(() => {
if (preset.configured || !preset.install_supported) setSetupOpen(false);
}, [preset.configured, preset.install_supported]);
const enableOrOpenSetup = () => {
if (needsSetupInput || (preset.installed && !preset.configured && hasFields)) {
setSetupOpen(true);
return;
}
onAction("enable", preset.name, values);
};
const submitSetup = () => {
if (!canEnable) return;
onAction("enable", preset.name, values);
};
const setTools = (next: string[]) => onToolsChange(preset.name, next);
const toggleTool = (toolName: string) => {
const next = new Set(allowAllTools ? toolNames : enabledTools);
if (next.has(toolName)) next.delete(toolName);
else next.add(toolName);
const nextValues = Array.from(next);
setTools(nextValues.length === toolNames.length ? ["*"] : nextValues);
};
return (
<article className="rounded-[14px] transition-colors hover:bg-muted/45">
<div className="group flex min-w-0 items-center gap-3 px-3 py-3">
<McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
<AppsTypeBadge>{tx("settings.apps.mcpLabel", "MCP")}</AppsTypeBadge>
</div>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{readyInstalled ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppsActionButton
ariaLabel={statusLabel}
busy={testBusy || toolsBusy}
disabled={busy}
tone="installed"
>
<Check className="h-4 w-4" aria-hidden />
</AppsActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.mcp.test", "Test")}
</DropdownMenuItem>
{toolNames.length ? (
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
<SlidersHorizontal className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.mcp.toolScope", "Tools")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem disabled={busy} onClick={() => onAction("remove", preset.name)}>
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.mcp.remove", "Remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AppsActionButton
ariaLabel={tx("settings.mcp.remove", "Remove")}
busy={removeBusy}
disabled={busy && !removeBusy}
tone="danger"
onClick={() => onAction("remove", preset.name)}
>
<Trash2 className="h-4 w-4" aria-hidden />
</AppsActionButton>
</>
) : preset.installed && !preset.configured ? (
<AppsActionButton
ariaLabel={hasFields ? tx("settings.mcp.configure", "Configure") : tx("settings.mcp.enable", "Enable")}
busy={enableBusy}
onClick={() => {
if (hasFields) setSetupOpen(true);
else onAction("enable", preset.name, values);
}}
>
<Plus className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : preset.install_supported ? (
<AppsActionButton
ariaLabel={needsSetupInput ? tx("settings.mcp.setup", "Set up") : tx("settings.mcp.enable", "Enable")}
busy={enableBusy}
onClick={enableOrOpenSetup}
>
<Plus className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : (
<AppsActionButton ariaLabel={tx("settings.mcp.comingSoon", "Coming soon")} disabled>
<Plus className="h-4 w-4" aria-hidden />
</AppsActionButton>
)}
</div>
</div>
{setupOpen && preset.install_supported && hasFields ? (
<div className="mx-3 mb-3 rounded-[14px] border border-border/45 bg-card/85 p-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[12.5px] font-semibold text-foreground">
{t("settings.mcp.connectTitle", {
name: preset.display_name,
defaultValue: "Connect {{name}}",
})}
</div>
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
{tx("settings.mcp.connectHint", "Add the key from your account settings.")}
</p>
</div>
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={() => setSetupOpen(false)}
className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold text-muted-foreground"
>
{tx("actions.cancel", "Cancel")}
</Button>
</div>
<div className="mt-3 grid gap-2">
{preset.required_fields.map((field) => (
<label key={field.name} className="min-w-0">
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
{field.label}
{field.configured ? (
<span className="ml-1 font-normal text-emerald-600 dark:text-emerald-300">
{tx("settings.mcp.configured", "configured")}
</span>
) : null}
</span>
<Input
type={field.secret ? "password" : "text"}
value={values[field.name] ?? ""}
onChange={(event) => 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]"
/>
</label>
))}
</div>
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
disabled={busy || !canEnable}
onClick={submitSetup}
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
{enableBusy ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{preset.installed
? tx("settings.mcp.updateSetup", "Update setup")
: tx("settings.mcp.saveAndEnable", "Save and enable")}
</Button>
</div>
</div>
) : null}
{toolsOpen && readyInstalled && toolNames.length ? (
<div className="mx-3 mb-3 rounded-[14px] border border-border/45 bg-card/85 p-3 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11.5px] font-medium text-muted-foreground">
{tx("settings.mcp.toolScope", "Tools")}
</div>
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
variant={allowAllTools ? "default" : "outline"}
disabled={toolsBusy}
onClick={() => setTools(["*"])}
className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold"
>
{tx("settings.mcp.allTools", "All")}
</Button>
<Button
type="button"
size="sm"
variant={!allowAllTools && enabledSet.size === 0 ? "default" : "outline"}
disabled={toolsBusy}
onClick={() => setTools([])}
className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold"
>
{tx("settings.mcp.noTools", "None")}
</Button>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{toolNames.map((toolName) => {
const selected = enabledSet.has(toolName);
return (
<button
key={toolName}
type="button"
disabled={toolsBusy}
onClick={() => 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",
)}
>
<span className="block max-w-[220px] truncate">{toolName}</span>
</button>
);
})}
</div>
</div>
) : null}
</article>
);
}
function AppsTypeBadge({ children }: { children: ReactNode }) {
return (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none tracking-[0.06em] text-muted-foreground">
{children}
</span>
);
}
const AppsActionButton = forwardRef<HTMLButtonElement, {
ariaLabel: string;
busy?: boolean;
disabled?: boolean;
tone?: "default" | "installed" | "danger";
onClick?: () => void;
children: ReactNode;
}>(function AppsActionButton({
ariaLabel,
busy,
disabled,
tone = "default",
onClick,
children,
}, ref) {
return (
<Button
ref={ref}
type="button"
size="icon"
variant="ghost"
aria-label={ariaLabel}
title={ariaLabel}
disabled={disabled || busy}
onClick={onClick}
className={cn(
"h-9 w-9 rounded-full text-muted-foreground transition-colors",
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : children}
</Button>
);
});
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<SetStateAction<CustomMcpForm>>;
onConfigImportChange: (value: string) => void;
onSave: () => void;
onImportConfig: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [activeMode, setActiveMode] = useState<"custom" | "import" | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const customBusy = actionKey?.startsWith("custom:") ?? false;
const importBusy = actionKey === "import" || actionKey === "import-cursor";
const remote = form.transport !== "stdio";
const canSave = Boolean(form.name.trim()) && (remote ? Boolean(form.url.trim()) : Boolean(form.command.trim()));
const update = <K extends keyof CustomMcpForm>(key: K, value: CustomMcpForm[K]) => {
onFormChange((prev) => ({ ...prev, [key]: value }));
};
const transports: Array<{ value: CustomMcpTransport; label: string }> = [
{ value: "stdio", label: "stdio" },
{ value: "streamableHttp", label: "HTTP" },
{ value: "sse", label: "SSE" },
];
return (
<section className="overflow-hidden rounded-[16px] border border-border/45 bg-card/72 shadow-[0_10px_30px_rgba(15,23,42,0.045)]">
<div className="flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[11px] bg-muted text-muted-foreground">
<Server className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0">
<h3 className="text-[13px] font-semibold leading-5 text-foreground">
{tx("settings.mcp.moreOptions", "More MCP options")}
</h3>
<p className="truncate text-[12px] text-muted-foreground">
{tx("settings.mcp.moreOptionsSubtitle", "Add a custom server or import mcp.json.")}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:shrink-0">
<Button
type="button"
size="sm"
variant={activeMode === "custom" ? "default" : "outline"}
onClick={() => setActiveMode((mode) => (mode === "custom" ? null : "custom"))}
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
<Server className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.mcp.customAction", "Custom")}
</Button>
<Button
type="button"
size="sm"
variant={activeMode === "import" ? "default" : "outline"}
onClick={() => setActiveMode((mode) => (mode === "import" ? null : "import"))}
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
<Database className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.mcp.importAction", "Import")}
</Button>
</div>
</div>
{activeMode === "custom" ? (
<div className="border-t border-border/35 bg-muted/18 px-3 py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<label className="min-w-0 flex-1">
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
{tx("settings.mcp.serverName", "Server name")}
</span>
<Input
value={form.name}
onChange={(event) => update("name", event.target.value)}
placeholder="docs"
className="h-9 rounded-full bg-background/80 text-[12.5px]"
/>
</label>
<div className="min-w-[228px]">
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
{tx("settings.mcp.transport", "Transport")}
</span>
<SegmentedControl
value={form.transport}
options={transports}
onChange={(value) => update("transport", value as CustomMcpTransport)}
/>
</div>
{remote ? (
<label className="min-w-0 flex-[1.4]">
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
{tx("settings.mcp.serverUrl", "URL")}
</span>
<Input
value={form.url}
onChange={(event) => update("url", event.target.value)}
placeholder={form.transport === "sse" ? "https://example.com/sse" : "https://example.com/mcp"}
className="h-9 rounded-full bg-background/80 text-[12.5px]"
/>
</label>
) : (
<label className="min-w-0 flex-[1.4]">
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
{tx("settings.mcp.command", "Command")}
</span>
<Input
value={form.command}
onChange={(event) => update("command", event.target.value)}
placeholder="npx"
className="h-9 rounded-full bg-background/80 text-[12.5px]"
/>
</label>
)}
<Button
type="button"
size="sm"
onClick={onSave}
disabled={!canSave || customBusy}
className="h-9 shrink-0 rounded-full px-4 text-[12.5px] font-semibold"
>
{customBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
{tx("settings.mcp.saveCustom", "Save MCP")}
</Button>
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setAdvancedOpen((open) => !open)}
className="mt-2 h-8 rounded-full px-2 text-[12px] font-medium text-muted-foreground hover:text-foreground"
>
<ChevronDown
className={cn("mr-1.5 h-3.5 w-3.5 transition-transform", advancedOpen ? "rotate-180" : "")}
aria-hidden
/>
{advancedOpen
? tx("settings.mcp.hideAdvanced", "Hide advanced")
: tx("settings.mcp.advancedOptions", "Advanced options")}
</Button>
{advancedOpen ? (
<div className="mt-2 grid gap-2 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_180px]">
{!remote ? (
<label className="min-w-0">
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
{tx("settings.mcp.args", "Args JSON")}
</span>
<Textarea
value={form.args}
onChange={(event) => update("args", event.target.value)}
placeholder={'["-y", "docs-mcp"]'}
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
/>
</label>
) : (
<label className="min-w-0">
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
{tx("settings.mcp.headers", "Headers JSON")}
</span>
<Textarea
value={form.headers}
onChange={(event) => update("headers", event.target.value)}
placeholder={'{"Authorization":"Bearer ..."}'}
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
/>
</label>
)}
<label className="min-w-0">
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
{tx("settings.mcp.env", "Env JSON")}
</span>
<Textarea
value={form.env}
onChange={(event) => update("env", event.target.value)}
placeholder={'{"API_KEY":"..."}'}
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
/>
</label>
<label className="min-w-0">
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
{tx("settings.mcp.timeout", "Tool timeout")}
</span>
<Input
value={form.toolTimeout}
onChange={(event) => update("toolTimeout", event.target.value)}
inputMode="numeric"
className="h-9 rounded-full bg-background/80 text-[12.5px]"
/>
</label>
</div>
) : null}
</div>
) : null}
{activeMode === "import" ? (
<div className="border-t border-border/35 bg-muted/18 px-3 py-3">
<div className="flex flex-col gap-2 lg:flex-row lg:items-end">
<label className="min-w-0 flex-1">
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
{tx("settings.mcp.configImport", "Import mcp.json")}
</span>
<Textarea
value={configImport}
onChange={(event) => 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]"
/>
</label>
<Button
type="button"
size="sm"
onClick={onImportConfig}
disabled={!configImport.trim() || importBusy}
className="h-9 shrink-0 rounded-full px-4 text-[12.5px] font-semibold"
>
{importBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Database className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
{tx("settings.mcp.importConfig", "Import")}
</Button>
</div>
</div>
) : null}
</section>
);
}
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 [logoIndex, setLogoIndex] = useState(0);
const bg = preset.brand_color || "hsl(var(--muted))";
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
const logoUrl = logoUrls[logoIndex];
const initials = preset.display_name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || preset.name.slice(0, 2).toUpperCase();
useEffect(() => setLogoIndex(0), [preset.logo_url]);
if (showBrandLogos && logoUrl) {
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
style={{ boxShadow: `inset 0 0 0 1px ${preset.brand_color ?? "transparent"}22` }}
>
<img
src={logoUrl}
alt=""
className="h-6 w-6 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
);
}
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
style={{ backgroundColor: bg }}
>
{initials}
</span>
);
}
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 (
<section
className={cn(
"rounded-[12px] border border-border/55 bg-card/88 px-4 py-3",
"shadow-[0_8px_26px_rgba(15,23,42,0.055)]",
)}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{app.display_name}
</h3>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
<Check className="h-3 w-3 text-emerald-600 dark:text-emerald-300" aria-hidden />
{t("settings.cliApps.readyStatus", { defaultValue: "Ready" })}
</span>
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<span className="font-mono">@{app.name}</span>
<span aria-hidden>·</span>
<span className="truncate font-mono">{app.entry_point || app.name}</span>
<span aria-hidden>·</span>
<span>{app.category}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={copyPrompt}
className="h-8 rounded-full px-3 text-[12px] font-medium text-muted-foreground hover:bg-muted/65 hover:text-foreground"
>
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
{copied
? t("settings.cliApps.readyCopied", { defaultValue: "Copied" })
: t("settings.cliApps.readyTry", { name: app.name, defaultValue: "Try @{{name}}" })}
</Button>
<Button
type="button"
size="sm"
onClick={onBackToChat}
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
{t("settings.cliApps.openChat", { defaultValue: "Open chat" })}
<ChevronRight className="ml-1.5 h-3.5 w-3.5" aria-hidden />
</Button>
</div>
</div>
</section>
);
}
function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
const [logoIndex, setLogoIndex] = useState(0);
const bg = app.brand_color || "hsl(var(--muted))";
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
const logoUrl = logoUrls[logoIndex];
const initials = app.display_name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || app.name.slice(0, 2).toUpperCase();
useEffect(() => setLogoIndex(0), [app.logo_url]);
if (showBrandLogos && logoUrl) {
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
style={{ boxShadow: `inset 0 0 0 1px ${app.brand_color ?? "transparent"}22` }}
>
<img
src={logoUrl}
alt=""
className="h-6 w-6 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
);
}
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
style={{ backgroundColor: bg }}
>
{initials}
</span>
);
}
function RuntimeSettings({
form,
setForm,
settings,
dirty,
saving,
onSave,
onRestart,
isRestarting,
requiresRestartPending,
}: {
form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
settings: SettingsPayload;
dirty: boolean;
saving: boolean;
onSave: () => void;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const isNativeHost = getHostApi() !== null || (settings.surface ?? settings.runtime_surface) === "native";
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<string | null>(null);
const [hostActionMessage, setHostActionMessage] = useState<{
target: "logs" | "diagnostics";
message: string;
} | null>(null);
const [hostActionBusy, setHostActionBusy] =
useState<"logs" | "diagnostics" | null>(null);
const hostApi = getHostApi();
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<string | void>,
successMessage: (result: string | void) => string,
failureMessage: string,
) => {
if (!hostApi) {
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 (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{tx("settings.sections.identity", "Identity")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={tx("settings.rows.botName", "Bot name")} description={tx("settings.help.botName", "Shown wherever nanobot uses a display name.")}>
<Input
value={form.botName}
onChange={(event) => setForm((prev) => ({ ...prev, botName: event.target.value }))}
className="h-8 w-[220px] rounded-full text-[13px]"
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.botIcon", "Bot icon")} description={tx("settings.help.botIcon", "Short emoji or text shown with the bot name.")}>
<Input
value={form.botIcon}
onChange={(event) => setForm((prev) => ({ ...prev, botIcon: event.target.value }))}
className="h-8 w-[120px] rounded-full text-center text-[13px]"
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.timezone", "Timezone")} description={tx("settings.help.timezone", "Used for schedules and time-aware replies.")}>
<TimezonePicker
value={form.timezone}
onChange={(timezone) => setForm((prev) => ({ ...prev, timezone }))}
/>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
dirtyMessage={
isNativeHost
? tx("settings.status.hostRestartAfterSaving", "Save changes and nanobot will restart its engine.")
: tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")
}
pendingMessage={
isNativeHost
? tx("settings.status.hostRestartPending", "Saved. Restarting engine when ready.")
: tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
}
onSave={onSave}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
{isNativeHost ? (
<section>
<SettingsSectionTitle>{tx("settings.sections.nativeHost", "Native host")}</SettingsSectionTitle>
<SettingsGroup>
<ReadOnlyRow title={tx("settings.rows.engine", "Engine")} value={engineState} />
{settings.runtime_capabilities?.can_open_logs ? (
<SettingsRow
title={tx("settings.rows.logs", "Logs")}
description={
hostActionMessage?.target === "logs"
? hostActionMessage.message
: tx("settings.help.logs", "Open the native engine log folder.")
}
>
<Button
size="sm"
variant="outline"
onClick={() =>
void runHostAction(
"logs",
() => hostApi!.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")}
</Button>
</SettingsRow>
) : null}
{settings.runtime_capabilities?.can_export_diagnostics ? (
<SettingsRow
title={tx("settings.rows.diagnostics", "Diagnostics")}
description={
hostActionMessage?.target === "diagnostics"
? hostActionMessage.message
: diagnosticsPath
? diagnosticsPath
: tx("settings.help.diagnostics", "Export a small runtime report for support.")
}
>
<Button
size="sm"
variant="outline"
onClick={() =>
void runHostAction(
"diagnostics",
async () => {
const path = await hostApi!.exportDiagnostics();
setDiagnosticsPath(path);
return path;
},
(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")}
</Button>
</SettingsRow>
) : null}
</SettingsGroup>
</section>
) : null}
<section>
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
<SettingsGroup>
{!isNativeHost ? (
<ReadOnlyRow
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
/>
) : null}
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
{onRestart && !requiresRestartPending ? (
<SettingsRow
title={t("settings.rows.restart")}
description={t("app.system.restartHint")}
>
<Button
size="sm"
variant="outline"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? restartingActionLabel : restartActionLabel}
</Button>
</SettingsRow>
) : null}
</SettingsGroup>
</section>
</div>
);
}
function AdvancedSettings({
form,
dirty,
saving,
requiresRestartPending,
isNativeHostSurface,
onChangeForm,
onSave,
onRestart,
isRestarting,
}: {
form: NetworkSafetySettingsUpdate;
dirty: boolean;
saving: boolean;
requiresRestartPending: boolean;
isNativeHostSurface: boolean;
onChangeForm: Dispatch<SetStateAction<NetworkSafetySettingsUpdate>>;
onSave: () => void;
onRestart?: () => void;
isRestarting?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>
{isNativeHostSurface
? tx("settings.sections.hostSafety", "App safety")
: tx("settings.sections.webuiSafety", "Web safety")}
</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.localServiceAccess", "Local Service Access")}
description={tx(
isNativeHostSurface ? "settings.help.localServiceAccessNative" : "settings.help.localServiceAccess",
isNativeHostSurface
? "Allow Full Access shell commands to reach services on this Mac."
: "Allow Full Access shell commands to reach localhost services.",
)}
>
<ToggleButton
checked={form.webuiAllowLocalServiceAccess}
onChange={(webuiAllowLocalServiceAccess) =>
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")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.webuiDefaultAccess", "Default access")}
description={tx(
isNativeHostSurface ? "settings.help.webuiDefaultAccessNative" : "settings.help.webuiDefaultAccess",
isNativeHostSurface
? "Used by native chats without a project-specific permission."
: "Used by web chats without a project-specific permission.",
)}
>
<SegmentedControl
value={form.webuiDefaultAccessMode}
options={[
{ value: "default", label: tx("settings.values.defaultPermission", "Default Permission") },
{ value: "full", label: tx("settings.values.fullAccess", "Full Access") },
]}
onChange={(webuiDefaultAccessMode) =>
onChangeForm((prev) => ({
...prev,
webuiDefaultAccessMode: webuiDefaultAccessMode as WebuiDefaultAccessMode,
}))
}
/>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
onSave={onSave}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
<p className="max-w-3xl px-1 text-sm leading-6 text-muted-foreground">
{tx(
"settings.help.securityManagedControls",
"Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
)}
</p>
</div>
);
}
function TimezonePicker({
value,
onChange,
}: {
value: string;
onChange: (timezone: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [query, setQuery] = useState("");
const options = useMemo(() => timezoneOptions(value), [value]);
const filteredOptions = useMemo(() => filterTimezoneOptions(options, query), [options, query]);
return (
<DropdownMenu onOpenChange={(open) => !open && setQuery("")}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"h-8 w-[220px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className="truncate">{value || tx("settings.timezone.select", "Select timezone")}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-[340px] max-w-[calc(100vw-2rem)]"
>
<div className="sticky top-0 z-10 bg-popover px-1 pb-1">
<div className="flex h-9 items-center gap-2 rounded-full border border-input bg-background px-3">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<Input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder={tx("settings.timezone.search", "Search timezone")}
className="h-7 border-0 bg-transparent px-0 text-[13px] shadow-none focus-visible:ring-0"
/>
</div>
</div>
<div
className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
data-testid="timezone-picker-list"
>
{filteredOptions.length ? (
filteredOptions.map((option) => {
const selected = option.name === value;
return (
<DropdownMenuItem
key={option.name}
onSelect={() => onChange(option.name)}
className={cn(
"flex h-9 cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 text-[13px]",
"focus:bg-muted/85 focus:text-foreground",
selected && "bg-muted/80 text-foreground focus:bg-muted",
)}
>
<span className="min-w-0 truncate font-medium text-foreground">{option.name}</span>
<span className="ml-auto flex shrink-0 items-center gap-2">
<span className="text-[11.5px] font-medium text-muted-foreground/80">
{option.offset}
</span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</span>
</DropdownMenuItem>
);
})
) : (
<div className="px-3 py-5 text-center text-[12px] text-muted-foreground">
{tx("settings.timezone.empty", "No matching timezones.")}
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={disabled}>
<Button
type="button"
variant="outline"
disabled={disabled}
className={cn(
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
disabled && "text-muted-foreground",
)}
>
<span className="flex min-w-0 items-center gap-2">
{selectedProvider && showProviderLogos ? (
<ProviderPickerIcon
provider={selectedProvider.name}
showBrandLogos={showProviderLogos}
/>
) : null}
<span className="truncate">{selectedProvider?.label ?? emptyLabel}</span>
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
>
{providers.map((provider) => {
const selected = provider.name === value;
return (
<DropdownMenuItem
key={provider.name}
onSelect={() => onChange(provider.name)}
className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2.5 py-2 text-[13px]",
"focus:bg-muted/85 focus:text-foreground",
selected && "bg-muted/80 text-foreground focus:bg-muted",
)}
>
<span className="flex min-w-0 items-center gap-2">
{showProviderLogos ? (
<ProviderPickerIcon
provider={provider.name}
showBrandLogos={showProviderLogos}
/>
) : null}
<span className="truncate">{provider.label}</span>
</span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
function ModelIdPicker({
token,
settings,
provider,
value,
showProviderLogos,
onChange,
}: {
token: string;
settings: SettingsPayload;
provider: string;
value: string;
showProviderLogos: boolean;
onChange: (model: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const effectiveProvider =
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
const providerRow = settingsProviderRow(settings, effectiveProvider);
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
const providerRequiresConfiguration = hasConcreteProvider && !providerConfigured;
const providerUsesManualModelIds =
hasConcreteProvider && providerConfigured && providerRow?.auth_type === "oauth";
const canFetchModels =
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
const normalizedQuery = query.trim().toLowerCase();
const providerModels = payload?.models ?? [];
const visibleModels = providerModels
.filter((model) => {
if (!normalizedQuery) return true;
return [model.id, model.label ?? "", model.owned_by ?? ""]
.some((field) => field.toLowerCase().includes(normalizedQuery));
})
.slice(0, 80);
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 = payload?.status === "available";
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
const customCandidate = query.trim();
const allowCustomModel = !providerRequiresConfiguration;
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
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(token, 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, token]);
const selectModel = (model: string) => {
onChange(model);
setOpen(false);
};
const renderModelRow = (
model: ProviderModelsPayload["models"][number],
options: { selected?: boolean } = {},
) => (
<DropdownMenuItem
key={model.id}
onSelect={() => selectModel(model.id)}
className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
"focus:bg-muted/85 focus:text-foreground",
options.selected && "bg-muted/80 text-foreground focus:bg-muted",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0 truncate font-medium text-foreground">
{model.label ?? model.id}
</span>
</span>
<span className="ml-2 flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
</span>
</DropdownMenuItem>
);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"h-9 w-[min(360px,70vw)] justify-between rounded-full border-input bg-background px-3 text-[12px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={modelUnconfigured}
/>
<span
className={cn(
"min-w-0 truncate font-medium",
value ? "text-foreground" : "text-muted-foreground",
)}
>
{value || tx("settings.models.selectModel", "Select model")}
</span>
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
>
<div className="p-1 pb-1.5">
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder={tx("settings.models.searchModels", "Search or type model ID")}
className="h-8 rounded-full pl-8 pr-3 text-[12px]"
/>
</div>
</div>
{providerRequiresConfiguration ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : providerUsesManualModelIds ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : !canFetchModels ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
</div>
) : waitingForModelSearch ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
</div>
) : loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{tx("settings.models.loadingModels", "Loading models...")}
</div>
) : error || payload?.status === "error" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
</div>
) : payload?.status === "not_configured" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : isCatalog && !normalizedQuery ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
{providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
</div>
) : null}
{showModels && visibleModels.length ? (
<div className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent">
{visibleModels.map((model) =>
renderModelRow(model, { selected: model.id === value }),
)}
</div>
) : showModels ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{tx("settings.models.noModelResults", "No matching models.")}
</div>
) : null}
{allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value ? (
<>
{showModels ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
onSelect={() => selectModel(customCandidate)}
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px] focus:bg-muted/85"
>
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
<Pencil className="h-3 w-3" aria-hidden />
</span>
<span className="min-w-0 truncate">
{tx("settings.models.useCustomModel", "Use")}{" "}
<span className="font-medium text-foreground">{customCandidate}</span>
</span>
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
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 ProviderPickerIcon({
provider,
showBrandLogos,
unconfigured = false,
}: {
provider: string;
showBrandLogos: boolean;
unconfigured?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
const logoUrl = brand?.logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [provider]);
if (unconfigured) {
return (
<span
data-testid="provider-picker-unconfigured-icon"
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
aria-hidden
>
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
</span>
);
}
if (showBrandLogos && logoUrl) {
return (
<span
data-testid={`provider-picker-logo-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]"
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
aria-hidden
>
<img
src={logoUrl}
alt=""
className="h-3.5 w-3.5 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
);
}
if (showBrandLogos && brand) {
return (
<span
data-testid={`provider-picker-logo-fallback-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
return (
<span
className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
aria-hidden
>
<Icon className="h-3 w-3" strokeWidth={2} />
</span>
);
}
function ProviderSection({
title,
count,
empty,
children,
}: {
title: string;
count: number;
empty: string;
children: ReactNode;
}) {
return (
<section className="space-y-3">
<ByokSectionHeader title={title} count={count} />
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.07)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.22)]">
{count > 0 ? (
<div className="divide-y divide-border/45">{children}</div>
) : (
<ByokEmptyState>{empty}</ByokEmptyState>
)}
</div>
</section>
);
}
function ByokSectionHeader({ title, count }: { title: string; count: number }) {
return (
<div className="flex items-center justify-between px-1">
<h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{title}
</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11.5px] font-medium text-muted-foreground">
{count}
</span>
</div>
);
}
function ByokEmptyState({ children }: { children: ReactNode }) {
return (
<div className="rounded-[18px] border border-dashed border-border/65 bg-card/45 px-4 py-5 text-[13px] text-muted-foreground">
{children}
</div>
);
}
function ThirdPartyBrandNotice() {
const { t } = useTranslation();
return (
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
{t("settings.legal.thirdPartyBrands", {
defaultValue:
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
})}
</p>
);
}
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<string>();
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 filterProviders(
providers: SettingsPayload["providers"],
query: string,
): SettingsPayload["providers"] {
const normalized = query.trim().toLowerCase();
if (!normalized) return providers;
return providers.filter((provider) =>
`${provider.name} ${provider.label} ${provider.api_base ?? ""} ${provider.default_api_base ?? ""}`
.toLowerCase()
.includes(normalized),
);
}
interface TimezoneOption {
name: string;
offset: string;
searchText: string;
}
function timezoneOptions(current: string): TimezoneOption[] {
return timezonesWithCurrent(current).map((name) => {
const offset = timezoneOffset(name);
return {
name,
offset,
searchText: `${name} ${name.replace(/_/g, " ")} ${offset}`.toLowerCase(),
};
});
}
function timezonesWithCurrent(current: string): string[] {
const intl = Intl as typeof Intl & {
supportedValuesOf?: (key: "timeZone") => string[];
};
let values: string[];
try {
values = intl.supportedValuesOf?.("timeZone") ?? [];
} catch {
values = [];
}
const deduped = new Set([...FALLBACK_TIMEZONES, ...values, current].filter(Boolean));
return Array.from(deduped).sort((left, right) => {
if (left === "UTC") return -1;
if (right === "UTC") return 1;
return left.localeCompare(right);
});
}
function filterTimezoneOptions(options: TimezoneOption[], query: string): TimezoneOption[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return options;
return options.filter((option) => option.searchText.includes(normalized));
}
function timezoneOffset(timezone: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
timeZoneName: "shortOffset",
hour: "2-digit",
minute: "2-digit",
}).formatToParts(new Date());
const value = parts.find((part) => part.type === "timeZoneName")?.value;
return value ? value.replace(/^GMT$/, "UTC").replace(/^GMT/, "UTC") : "UTC";
} catch {
return "Custom timezone";
}
}
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 settings.agent.resolved_provider || settings.agent.provider || preset.provider;
}
return provider;
}
const PROVIDER_ICONS: Record<string, LucideIcon> = {
custom: Hexagon,
openrouter: Sparkles,
skywork: Sparkles,
aihubmix: Triangle,
anthropic: Brain,
openai: Bot,
deepseek: Waves,
zhipu: Grid3X3,
dashscope: Cloud,
moonshot: Moon,
minimax: Zap,
minimax_anthropic: Brain,
groq: Cpu,
huggingface: Layers,
gemini: Gem,
mistral: Orbit,
siliconflow: Layers,
volcengine: Cloud,
volcengine_coding_plan: Cloud,
byteplus: Cloud,
byteplus_coding_plan: Cloud,
qianfan: Database,
ant_ling: Sparkles,
azure_openai: Cloud,
bedrock: Database,
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 [logoIndex, setLogoIndex] = useState(0);
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
const logoUrl = brand?.logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [provider]);
if (showBrandLogos && logoUrl) {
return (
<span
data-testid={`provider-logo-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-[14px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
>
<img
src={logoUrl}
alt=""
className="h-6 w-6 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
);
}
if (showBrandLogos && brand) {
return (
<span
data-testid={`provider-logo-fallback-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] text-[11px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
<Icon className="h-5 w-5" strokeWidth={2} aria-hidden />
</span>
);
}
function OverviewRowIcon({
icon: Icon,
}: {
icon: LucideIcon;
}) {
return (
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/82 transition-colors group-hover:bg-muted/80 dark:bg-muted/70">
<Icon className="h-4 w-4" aria-hidden />
</span>
);
}
function OverviewValueLogo({
provider,
showBrandLogos,
}: {
provider: string | null | undefined;
showBrandLogos: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const brand = provider ? providerBrand(provider) : null;
const logoUrl = brand?.logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [provider]);
if (!provider || !showBrandLogos || !brand) return null;
if (logoUrl) {
return (
<span
data-testid={`overview-logo-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]"
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
aria-hidden
>
<img
src={logoUrl}
alt=""
className="h-3.5 w-3.5 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
);
}
return (
<span
data-testid={`overview-logo-fallback-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
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 (
<button
type="button"
onClick={onClick}
className="group flex min-h-[68px] w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
>
<OverviewRowIcon icon={Icon} />
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-medium leading-5 text-foreground">{title}</span>
<span className="mt-0.5 block truncate text-[12px] leading-5 text-muted-foreground">{caption}</span>
</span>
<span className="ml-auto flex min-w-0 max-w-[48%] items-center gap-2">
<OverviewValueLogo provider={valueLogoProvider} showBrandLogos={showBrandLogos} />
<span className="truncate text-right text-[13px] leading-5 text-muted-foreground">
{value}
</span>
<ChevronRight
className="h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</span>
</button>
);
}
function SettingsSectionTitle({ children }: { children: ReactNode }) {
return (
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{children}
</h2>
);
}
function SettingsGroup({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.075)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.24)]">
<div className="divide-y divide-border/45">{children}</div>
</div>
);
}
function SettingsRow({
title,
description,
children,
}: {
title: string;
description?: string;
children?: ReactNode;
}) {
return (
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="min-w-0">
<div className="text-[14px] font-medium leading-5 text-foreground">{title}</div>
{description ? (
<div className="mt-0.5 max-w-[28rem] text-[12px] leading-5 text-muted-foreground">
{description}
</div>
) : null}
</div>
{children ? <div className="shrink-0 sm:ml-6">{children}</div> : null}
</div>
);
}
function ReadOnlyRow({
title,
value,
description,
}: {
title: string;
value: string;
description?: string;
}) {
return (
<SettingsRow title={title} description={description}>
<span className="block max-w-[320px] truncate text-right text-[13px] text-muted-foreground">
{value}
</span>
</SettingsRow>
);
}
function ModelPresetPicker({
presets,
value,
settings,
draftModel,
draftProvider,
providerConfigured,
showProviderLogos,
onChange,
onCreateConfiguration,
}: {
presets: SettingsPayload["model_presets"];
value: string;
settings: SettingsPayload;
draftModel: string;
draftProvider: string;
providerConfigured: boolean;
showProviderLogos: boolean;
onChange: (preset: string) => void;
onCreateConfiguration: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const selectedPreset = presets.find((preset) => preset.name === value) ?? presets[0] ?? null;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={!presets.length}>
<Button
type="button"
variant="outline"
aria-label={tx("settings.rows.currentModel", "Current configuration")}
disabled={!presets.length}
className={cn(
"h-12 w-[min(430px,72vw)] justify-between rounded-full border-input bg-background px-3.5 text-[13px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{selectedPreset ? (
<ModelPresetOptionContent
preset={selectedPreset}
settings={settings}
draftModel={draftModel}
draftProvider={draftProvider}
forceUnconfigured={selectedPreset?.is_default ? !providerConfigured : undefined}
showProviderLogos={showProviderLogos}
compact
/>
) : (
<span className="truncate text-muted-foreground">
{tx("settings.models.selectModel", "Select model")}
</span>
)}
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-[20rem] w-[430px] max-w-[calc(100vw-2rem)] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
>
{presets.map((preset) => {
const selected = preset.name === value;
return (
<DropdownMenuItem
key={preset.name}
onSelect={() => onChange(preset.name)}
className={cn(
"flex cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 py-2 text-[13px]",
"focus:bg-muted/85 focus:text-foreground",
selected && "bg-muted/80 text-foreground focus:bg-muted",
)}
>
<ModelPresetOptionContent
preset={preset}
settings={settings}
draftModel={draftModel}
draftProvider={draftProvider}
showProviderLogos={showProviderLogos}
/>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
})}
<div className="mt-1 border-t border-border/55 pt-1">
<DropdownMenuItem
onSelect={() => {
window.setTimeout(onCreateConfiguration, 0);
}}
className={cn(
"flex cursor-default items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] font-medium",
"text-foreground focus:bg-muted/85 focus:text-foreground",
)}
>
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<Plus className="h-3.5 w-3.5" aria-hidden />
</span>
<span>{tx("settings.models.addConfiguration", "Add configuration")}</span>
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ModelPresetOptionContent({
preset,
settings,
draftModel,
draftProvider,
forceUnconfigured,
showProviderLogos,
compact = false,
}: {
preset: SettingsPayload["model_presets"][number];
settings: SettingsPayload;
draftModel: string;
draftProvider: string;
forceUnconfigured?: boolean;
showProviderLogos: boolean;
compact?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const provider = modelPresetProviderKey(preset, settings, {
draftProvider: preset.is_default ? draftProvider : undefined,
});
const model = preset.is_default ? draftModel : preset.model;
const providerName = providerDisplayLabel(settings.providers, provider);
const providerConfigured =
forceUnconfigured === undefined
? settingsProviderConfigured(settings, provider)
: !forceUnconfigured;
const title = providerConfigured ? model || preset.label : tx("settings.values.notConfigured", "Not configured");
const caption = providerConfigured
? `${providerName}${preset.label ? ` · ${preset.label}` : ""}`
: providerName || model || preset.label
? [providerName, model || preset.label].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
return (
<span className="flex min-w-0 items-center gap-2.5">
<ProviderPickerIcon
provider={provider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0 text-left leading-tight">
<span
className={cn(
"block truncate font-medium",
providerConfigured ? "text-foreground" : "text-amber-800 dark:text-amber-200",
)}
>
{title}
</span>
<span
className={cn(
"mt-0.5 block truncate text-muted-foreground",
compact ? "text-[11.5px]" : "text-[12px]",
)}
>
{caption}
</span>
</span>
</span>
);
}
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 = getHostApi() !== null;
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 (
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="min-w-0 text-[13px] leading-5 text-muted-foreground">
<SettingsStatusMessage tone={statusTone}>{statusMessage}</SettingsStatusMessage>
</div>
<div className="flex w-full shrink-0 flex-wrap justify-end gap-2 sm:w-auto">
{pendingRestart && !dirty && onRestart ? (
<Button
size="sm"
variant="ghost"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? restartingLabel : restartLabel}
</Button>
) : null}
{onReset ? (
<Button
size="sm"
variant="ghost"
onClick={onReset}
disabled={!dirty || saving}
className="rounded-full"
>
{t("settings.actions.cancel")}
</Button>
) : null}
<Button
size="sm"
variant="outline"
onClick={onSave}
disabled={!dirty || disabled || saving}
className="rounded-full"
>
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
</Button>
</div>
</div>
);
}
function SettingsFooter({
dirty,
saving,
saved,
disabled = false,
message,
onSave,
}: {
dirty: boolean;
saving: boolean;
saved: boolean;
disabled?: boolean;
message?: string;
onSave: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const statusMessage = message ?? (dirty
? t("settings.status.unsaved")
: saved
? t("settings.status.savedRestart")
: tx("settings.status.upToDate", "Up to date."));
return (
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="text-[13px] text-muted-foreground">
<SettingsStatusMessage tone={disabled ? "danger" : dirty || saved ? "accent" : undefined}>
{statusMessage}
</SettingsStatusMessage>
</div>
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || disabled || saving} className="rounded-full">
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
</Button>
</div>
</div>
);
}
function SettingsStatusMessage({
children,
tone,
}: {
children?: ReactNode;
tone?: "accent" | "danger";
}) {
if (!children) return null;
return (
<span
className={cn(
"inline-flex items-center gap-2",
tone === "accent" && "font-medium text-blue-600 dark:text-blue-300",
tone === "danger" && "font-medium text-destructive",
)}
>
{tone ? (
<span
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full",
tone === "accent" &&
"bg-blue-500 shadow-[0_0_0_3px_rgba(59,130,246,0.14)] dark:bg-blue-400 dark:shadow-[0_0_0_3px_rgba(96,165,250,0.18)]",
tone === "danger" && "bg-destructive/70",
)}
aria-hidden
/>
) : null}
<span>{children}</span>
</span>
);
}
function StatusPill({
children,
tone = "neutral",
}: {
children: ReactNode;
tone?: "neutral" | "success" | "warning";
}) {
return (
<span
className={cn(
"inline-flex max-w-[260px] items-center rounded-full px-2.5 py-1 text-[12px] font-medium",
tone === "success" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
tone === "warning" && "bg-amber-500/10 text-amber-700 dark:text-amber-300",
tone === "neutral" && "bg-muted text-muted-foreground",
)}
>
<span className="truncate">{children}</span>
</span>
);
}
function SegmentedControl({
value,
options,
onChange,
}: {
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
}) {
return (
<div className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"rounded-full px-3 py-1 transition-colors",
value === option.value && "bg-background text-foreground shadow-sm",
)}
>
{option.label}
</button>
))}
</div>
);
}
function ToggleButton({
checked,
onChange,
ariaLabel,
label,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
ariaLabel?: string;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel ?? label}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-[22px] w-[38px] shrink-0 items-center rounded-full p-[2px]",
"transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
checked
? "bg-[#2997FF] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)]"
: "bg-muted shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] hover:bg-muted/80",
)}
>
<span
aria-hidden
className={cn(
"h-[18px] w-[18px] rounded-full bg-background shadow-[0_1px_2px_rgba(0,0,0,0.18),0_2px_7px_rgba(0,0,0,0.11)]",
"transition-transform duration-200 ease-out",
checked ? "translate-x-[16px]" : "translate-x-0",
)}
/>
<span className="sr-only">{label}</span>
</button>
);
}
function NumberInput({
value,
min,
max,
onChange,
suffix,
}: {
value: number;
min: number;
max: number;
onChange: (value: number) => void;
suffix?: string;
}) {
return (
<div className="flex items-center gap-2">
<Input
type="number"
min={min}
max={max}
value={value}
onChange={(event) => {
const parsed = Number(event.target.value);
if (Number.isFinite(parsed)) onChange(parsed);
}}
className="h-8 w-24 rounded-full text-[13px]"
/>
{suffix ? <span className="text-[12px] text-muted-foreground">{suffix}</span> : null}
</div>
);
}