mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 08:32:25 +00:00
2514 lines
91 KiB
TypeScript
2514 lines
91 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type Dispatch,
|
|
type ReactNode,
|
|
type SetStateAction,
|
|
} from "react";
|
|
import {
|
|
Activity,
|
|
Bot,
|
|
Brain,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Cloud,
|
|
Cpu,
|
|
Database,
|
|
Eye,
|
|
EyeOff,
|
|
Gem,
|
|
Globe2,
|
|
Grid3X3,
|
|
HardDrive,
|
|
Hexagon,
|
|
ImageIcon,
|
|
Info,
|
|
KeyRound,
|
|
Layers,
|
|
Loader2,
|
|
LogOut,
|
|
Moon,
|
|
Orbit,
|
|
Palette,
|
|
Pencil,
|
|
RotateCcw,
|
|
Search,
|
|
Server,
|
|
ShieldCheck,
|
|
SlidersHorizontal,
|
|
Sparkles,
|
|
Triangle,
|
|
Waves,
|
|
Zap,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
fetchSettings,
|
|
updateImageGenerationSettings,
|
|
updateProviderSettings,
|
|
updateSettings,
|
|
updateWebSearchSettings,
|
|
} from "@/lib/api";
|
|
import { cn } from "@/lib/utils";
|
|
import { useClient } from "@/providers/ClientProvider";
|
|
import type {
|
|
ImageGenerationSettingsUpdate,
|
|
SettingsPayload,
|
|
WebSearchSettingsUpdate,
|
|
} from "@/lib/types";
|
|
|
|
type SettingsSectionKey =
|
|
| "overview"
|
|
| "appearance"
|
|
| "models"
|
|
| "providers"
|
|
| "image"
|
|
| "web"
|
|
| "runtime"
|
|
| "advanced";
|
|
|
|
type LocalDensity = "comfortable" | "compact";
|
|
type LocalActivityMode = "auto" | "expanded";
|
|
|
|
interface LocalPreferences {
|
|
density: LocalDensity;
|
|
activityMode: LocalActivityMode;
|
|
codeWrap: boolean;
|
|
}
|
|
|
|
interface AgentSettingsDraft {
|
|
model: string;
|
|
provider: string;
|
|
modelPreset: string;
|
|
timezone: string;
|
|
botName: string;
|
|
botIcon: string;
|
|
toolHintMaxLength: number;
|
|
}
|
|
|
|
type PendingRestartSection = "runtime" | "web" | "image";
|
|
type PendingRestartSections = Record<PendingRestartSection, boolean>;
|
|
|
|
const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
|
|
|
|
const DEFAULT_LOCAL_PREFS: LocalPreferences = {
|
|
density: "comfortable",
|
|
activityMode: "auto",
|
|
codeWrap: true,
|
|
};
|
|
|
|
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,
|
|
web: false,
|
|
image: false,
|
|
};
|
|
|
|
interface SettingsViewProps {
|
|
theme: "light" | "dark";
|
|
onToggleTheme: () => void;
|
|
onBackToChat: () => void;
|
|
onModelNameChange: (modelName: string | null) => void;
|
|
onLogout?: () => void;
|
|
onRestart?: () => void;
|
|
isRestarting?: 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,
|
|
};
|
|
} 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 editableDefaultProvider(payload: SettingsPayload): string {
|
|
const base = defaultPreset(payload);
|
|
return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "";
|
|
}
|
|
|
|
export function SettingsView({
|
|
theme,
|
|
onToggleTheme,
|
|
onBackToChat,
|
|
onModelNameChange,
|
|
onLogout,
|
|
onRestart,
|
|
isRestarting = false,
|
|
}: SettingsViewProps) {
|
|
const { t } = useTranslation();
|
|
const { token } = useClient();
|
|
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
|
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
|
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("overview");
|
|
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
|
const [providerQuery, setProviderQuery] = useState("");
|
|
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({});
|
|
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>({
|
|
provider: "duckduckgo",
|
|
apiKey: "",
|
|
baseUrl: "",
|
|
maxResults: 5,
|
|
timeout: 30,
|
|
useJinaReader: true,
|
|
});
|
|
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>({
|
|
enabled: false,
|
|
provider: "openrouter",
|
|
model: "openai/gpt-5.4-image-2",
|
|
defaultAspectRatio: "1:1",
|
|
defaultImageSize: "1K",
|
|
maxImagesPerTurn: 4,
|
|
});
|
|
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
|
|
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
|
|
const [form, setForm] = useState<AgentSettingsDraft>({
|
|
model: "",
|
|
provider: "",
|
|
modelPreset: "default",
|
|
timezone: "UTC",
|
|
botName: "nanobot",
|
|
botIcon: "",
|
|
toolHintMaxLength: 40,
|
|
});
|
|
|
|
const text = useCallback(
|
|
(key: string, fallback: string, options?: Record<string, unknown>) =>
|
|
t(key, { defaultValue: fallback, ...(options ?? {}) }),
|
|
[t],
|
|
);
|
|
|
|
const applyPayload = useCallback((payload: SettingsPayload) => {
|
|
const fallbackDefault = defaultPreset(payload);
|
|
setSettings(payload);
|
|
setForm({
|
|
model: fallbackDefault?.model ?? payload.agent.model,
|
|
provider: editableDefaultProvider(payload),
|
|
modelPreset: modelPresetValue(payload),
|
|
timezone: payload.agent.timezone,
|
|
botName: payload.agent.bot_name,
|
|
botIcon: payload.agent.bot_icon,
|
|
toolHintMaxLength: payload.agent.tool_hint_max_length,
|
|
});
|
|
setWebSearchForm((prev) => ({
|
|
provider: payload.web_search.provider,
|
|
apiKey: prev.provider === payload.web_search.provider ? prev.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,
|
|
}));
|
|
setImageGenerationForm({
|
|
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,
|
|
});
|
|
if (payload.restart_required_sections) {
|
|
setPendingRestartSections({
|
|
runtime: payload.restart_required_sections.includes("runtime"),
|
|
web: payload.restart_required_sections.includes("web"),
|
|
image: payload.restart_required_sections.includes("image"),
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
fetchSettings(token)
|
|
.then((payload) => {
|
|
if (!cancelled) {
|
|
applyPayload(payload);
|
|
setError(null);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled) setError((err as Error).message);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [applyPayload, 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 ?? "",
|
|
};
|
|
}
|
|
return next;
|
|
});
|
|
}, [settings]);
|
|
|
|
const modelDirty = useMemo(() => {
|
|
if (!settings) return false;
|
|
const preset = modelPresetValue(settings);
|
|
const base = defaultPreset(settings);
|
|
return (
|
|
form.modelPreset !== preset ||
|
|
(form.modelPreset === "default" &&
|
|
(form.model !== (base?.model ?? settings.agent.model) ||
|
|
form.provider !== editableDefaultProvider(settings)))
|
|
);
|
|
}, [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.toolHintMaxLength !== settings.agent.tool_hint_max_length
|
|
);
|
|
}, [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 hasPendingRestart = useMemo(
|
|
() =>
|
|
!!settings?.requires_restart ||
|
|
pendingRestartSections.runtime ||
|
|
pendingRestartSections.web ||
|
|
pendingRestartSections.image,
|
|
[pendingRestartSections, settings?.requires_restart],
|
|
);
|
|
|
|
const saveModelSettings = async () => {
|
|
if (!settings || !modelDirty || saving) return;
|
|
setSaving(true);
|
|
try {
|
|
const defaultModel = defaultPreset(settings)?.model ?? settings.agent.model;
|
|
const defaultProvider = editableDefaultProvider(settings);
|
|
const payload = await updateSettings(token, {
|
|
modelPreset: form.modelPreset,
|
|
...(form.modelPreset === "default" && form.model !== defaultModel ? { model: form.model } : {}),
|
|
...(form.modelPreset === "default" && form.provider !== defaultProvider ? { provider: form.provider } : {}),
|
|
});
|
|
applyPayload(payload);
|
|
onModelNameChange(payload.agent.model || null);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveRuntimeSettings = async () => {
|
|
if (!settings || !runtimeDirty || saving) return;
|
|
setSaving(true);
|
|
try {
|
|
const payload = await updateSettings(token, {
|
|
timezone: form.timezone,
|
|
botName: form.botName,
|
|
botIcon: form.botIcon,
|
|
toolHintMaxLength: form.toolHintMaxLength,
|
|
});
|
|
applyPayload(payload);
|
|
if (payload.requires_restart) {
|
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
|
}
|
|
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 }));
|
|
}
|
|
setError(null);
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
} finally {
|
|
setImageGenerationSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveProvider = async (providerName: string) => {
|
|
if (providerSaving) return;
|
|
const provider = settings?.providers.find((item) => item.name === providerName);
|
|
if (!provider) return;
|
|
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
|
|
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(),
|
|
});
|
|
applyPayload(payload);
|
|
if (payload.requires_restart) {
|
|
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
|
}
|
|
setProviderForms((prev) => ({
|
|
...prev,
|
|
[providerName]: {
|
|
apiKey: "",
|
|
apiBase: providerForm.apiBase.trim(),
|
|
},
|
|
}));
|
|
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
|
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
|
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, web: true }));
|
|
}
|
|
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 ?? "",
|
|
},
|
|
}));
|
|
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 ?? "",
|
|
},
|
|
}));
|
|
setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false }));
|
|
}
|
|
return { ...prev, [providerName]: nextEditing };
|
|
});
|
|
};
|
|
|
|
const renderSection = () => {
|
|
if (!settings) return null;
|
|
switch (activeSection) {
|
|
case "overview":
|
|
return (
|
|
<OverviewSettings
|
|
settings={settings}
|
|
requiresRestart={hasPendingRestart}
|
|
onRestart={onRestart}
|
|
isRestarting={isRestarting}
|
|
onSelectSection={setActiveSection}
|
|
/>
|
|
);
|
|
case "appearance":
|
|
return (
|
|
<AppearanceSettings
|
|
theme={theme}
|
|
onToggleTheme={onToggleTheme}
|
|
localPrefs={localPrefs}
|
|
onChangeLocalPrefs={setLocalPrefs}
|
|
/>
|
|
);
|
|
case "models":
|
|
return (
|
|
<ModelsSettings
|
|
form={form}
|
|
setForm={setForm}
|
|
settings={settings}
|
|
dirty={modelDirty}
|
|
saving={saving}
|
|
onSave={saveModelSettings}
|
|
onOpenProviders={() => setActiveSection("providers")}
|
|
/>
|
|
);
|
|
case "providers":
|
|
return (
|
|
<ProvidersSettings
|
|
settings={settings}
|
|
expandedProvider={expandedProvider}
|
|
providerForms={providerForms}
|
|
visibleProviderKeys={visibleProviderKeys}
|
|
editingProviderKeys={editingProviderKeys}
|
|
providerSaving={providerSaving}
|
|
query={providerQuery}
|
|
onQueryChange={setProviderQuery}
|
|
onToggleProvider={handleToggleProvider}
|
|
onToggleProviderKey={toggleProviderKeyVisibility}
|
|
onToggleProviderKeyEditing={toggleProviderKeyEditing}
|
|
onChangeProviderForm={(provider, value) =>
|
|
setProviderForms((prev) => ({
|
|
...prev,
|
|
[provider]: {
|
|
apiKey: prev[provider]?.apiKey ?? "",
|
|
apiBase: prev[provider]?.apiBase ?? "",
|
|
...value,
|
|
},
|
|
}))
|
|
}
|
|
onSaveProvider={saveProvider}
|
|
onResetProviderDraft={resetProviderDraft}
|
|
imageProviderRestartPending={pendingRestartSections.image}
|
|
onRestart={onRestart}
|
|
isRestarting={isRestarting}
|
|
/>
|
|
);
|
|
case "image":
|
|
return (
|
|
<ImageGenerationSettings
|
|
settings={settings}
|
|
form={imageGenerationForm}
|
|
dirty={imageGenerationDirty}
|
|
saving={imageGenerationSaving}
|
|
onChangeForm={setImageGenerationForm}
|
|
onSave={saveImageGenerationSettings}
|
|
onOpenProviders={() => setActiveSection("providers")}
|
|
onRestart={onRestart}
|
|
isRestarting={isRestarting}
|
|
requiresRestartPending={pendingRestartSections.image}
|
|
/>
|
|
);
|
|
case "web":
|
|
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}
|
|
onRestart={onRestart}
|
|
isRestarting={isRestarting}
|
|
requiresRestartPending={pendingRestartSections.web}
|
|
/>
|
|
);
|
|
case "runtime":
|
|
return (
|
|
<RuntimeSettings
|
|
form={form}
|
|
setForm={setForm}
|
|
settings={settings}
|
|
dirty={runtimeDirty}
|
|
saving={saving}
|
|
onSave={saveRuntimeSettings}
|
|
onRestart={onRestart}
|
|
isRestarting={isRestarting}
|
|
requiresRestartPending={pendingRestartSections.runtime}
|
|
/>
|
|
);
|
|
case "advanced":
|
|
return <AdvancedSettings settings={settings} />;
|
|
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">
|
|
<SettingsSidebar
|
|
activeSection={activeSection}
|
|
onSelectSection={setActiveSection}
|
|
onBackToChat={onBackToChat}
|
|
onLogout={onLogout}
|
|
/>
|
|
|
|
<main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
|
<div className="mx-auto w-full max-w-[920px] px-5 py-8 sm:px-8 lg:py-12">
|
|
<div className="mb-7">
|
|
<p className="mb-2 text-[13px] font-medium text-muted-foreground">
|
|
{t("settings.sidebar.title")}
|
|
</p>
|
|
<h1 className="text-[28px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[34px]">
|
|
{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: "providers", icon: KeyRound, fallback: "Providers" },
|
|
{ key: "image", icon: ImageIcon, fallback: "Image" },
|
|
{ key: "web", icon: Globe2, fallback: "Web" },
|
|
{ key: "runtime", icon: Server, fallback: "Runtime" },
|
|
{ key: "advanced", icon: ShieldCheck, fallback: "Advanced" },
|
|
];
|
|
|
|
function titleForSection(section: SettingsSectionKey): string {
|
|
return SETTINGS_NAV_ITEMS.find((item) => item.key === section)?.fallback ?? "Settings";
|
|
}
|
|
|
|
function SettingsSidebar({
|
|
activeSection,
|
|
onSelectSection,
|
|
onBackToChat,
|
|
onLogout,
|
|
}: {
|
|
activeSection: SettingsSectionKey;
|
|
onSelectSection: (section: SettingsSectionKey) => void;
|
|
onBackToChat: () => void;
|
|
onLogout?: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<aside className="flex w-full shrink-0 flex-col border-b border-border/55 bg-card/62 px-4 pb-3 pt-4 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:py-4 md:shadow-[inset_-1px_0_0_rgba(255,255,255,0.55)]">
|
|
<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-[21px] font-semibold tracking-[-0.02em] 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 ? (
|
|
<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,
|
|
onRestart,
|
|
isRestarting,
|
|
onSelectSection,
|
|
}: {
|
|
settings: SettingsPayload;
|
|
requiresRestart: boolean;
|
|
onRestart?: () => void;
|
|
isRestarting?: boolean;
|
|
onSelectSection: (section: SettingsSectionKey) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
const configuredCount = settings.providers.filter((provider) => provider.configured).length;
|
|
const activePreset = settings.agent.model_preset || "default";
|
|
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
|
|
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 = `${providerLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${
|
|
settings.image_generation.provider_configured
|
|
? tx("settings.values.configured", "Configured")
|
|
: tx("settings.values.notConfigured", "Not configured")
|
|
}`;
|
|
return (
|
|
<div className="space-y-7">
|
|
<section>
|
|
<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="flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<span className="grid h-12 w-12 shrink-0 place-items-center rounded-[16px] bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
|
|
<Bot className="h-6 w-6" aria-hidden />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div className="text-[12px] font-medium text-muted-foreground">nanobot</div>
|
|
<div className="mt-0.5 truncate text-[18px] font-semibold leading-6 text-foreground">
|
|
{settings.agent.model}
|
|
</div>
|
|
<div className="mt-0.5 truncate text-[13px] leading-5 text-muted-foreground">
|
|
{activeProvider} · {activePreset}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
|
<StatusPill tone={requiresRestart ? "neutral" : "success"}>
|
|
{requiresRestart
|
|
? tx("settings.values.restartPending", "Restart pending")
|
|
: tx("settings.values.ready", "Ready")}
|
|
</StatusPill>
|
|
{requiresRestart && 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 ? t("app.system.restarting") : t("app.system.restart")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SettingsSectionTitle>{tx("settings.sections.ai", "AI")}</SettingsSectionTitle>
|
|
<SettingsGroup>
|
|
<OverviewListRow
|
|
icon={Bot}
|
|
title={tx("settings.overview.model", "Current model")}
|
|
value={settings.agent.model}
|
|
caption={`${activeProvider} · ${activePreset}`}
|
|
onClick={() => onSelectSection("models")}
|
|
/>
|
|
<OverviewListRow
|
|
icon={KeyRound}
|
|
title={tx("settings.overview.providers", "Providers")}
|
|
value={tx("settings.overview.configuredCount", "{{count}} configured").replace(
|
|
"{{count}}",
|
|
String(configuredCount),
|
|
)}
|
|
caption={tx("settings.overview.totalProviders", "{{count}} available").replace(
|
|
"{{count}}",
|
|
String(settings.providers.length),
|
|
)}
|
|
onClick={() => onSelectSection("providers")}
|
|
/>
|
|
</SettingsGroup>
|
|
</section>
|
|
|
|
<section>
|
|
<SettingsSectionTitle>{tx("settings.sections.capabilities", "Capabilities")}</SettingsSectionTitle>
|
|
<SettingsGroup>
|
|
<OverviewListRow
|
|
icon={Globe2}
|
|
title={tx("settings.overview.webSearch", "Web search")}
|
|
value={providerLabel(settings.web_search.providers, settings.web_search.provider)}
|
|
caption={webStatus}
|
|
onClick={() => onSelectSection("web")}
|
|
/>
|
|
<OverviewListRow
|
|
icon={ImageIcon}
|
|
title={tx("settings.overview.imageGeneration", "Image generation")}
|
|
value={imageStatus}
|
|
caption={imageCaption}
|
|
onClick={() => onSelectSection("image")}
|
|
/>
|
|
</SettingsGroup>
|
|
</section>
|
|
|
|
<section>
|
|
<SettingsSectionTitle>{tx("settings.sections.system", "System")}</SettingsSectionTitle>
|
|
<SettingsGroup>
|
|
<OverviewListRow
|
|
icon={Server}
|
|
title={tx("settings.rows.gateway", "Gateway")}
|
|
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
|
|
caption={
|
|
requiresRestart
|
|
? tx("settings.values.restartPending", "Restart pending")
|
|
: tx("settings.values.ready", "Ready")
|
|
}
|
|
onClick={() => onSelectSection("runtime")}
|
|
/>
|
|
<OverviewListRow
|
|
icon={HardDrive}
|
|
title={tx("settings.overview.workspace", "Workspace")}
|
|
value={settings.runtime.workspace_path}
|
|
caption={settings.runtime.config_path}
|
|
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 }))}
|
|
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
|
/>
|
|
</SettingsRow>
|
|
</SettingsGroup>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ModelsSettings({
|
|
form,
|
|
setForm,
|
|
settings,
|
|
dirty,
|
|
saving,
|
|
onSave,
|
|
onOpenProviders,
|
|
}: {
|
|
form: AgentSettingsDraft;
|
|
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
|
|
settings: SettingsPayload;
|
|
dirty: boolean;
|
|
saving: boolean;
|
|
onSave: () => void;
|
|
onOpenProviders: () => 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 providerOptions = showAutoProvider
|
|
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...configuredProviders]
|
|
: configuredProviders;
|
|
const providerValue = providerOptions.some((provider) => provider.name === form.provider)
|
|
? form.provider
|
|
: "";
|
|
const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset);
|
|
return (
|
|
<div className="space-y-7">
|
|
<section>
|
|
<SettingsSectionTitle>{tx("settings.sections.presets", "Presets")}</SettingsSectionTitle>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{settings.model_presets.map((preset) => (
|
|
<button
|
|
key={preset.name}
|
|
type="button"
|
|
onClick={() => setForm((prev) => ({ ...prev, modelPreset: preset.name }))}
|
|
className={cn(
|
|
"rounded-[22px] border px-4 py-4 text-left transition-colors",
|
|
form.modelPreset === preset.name
|
|
? "border-primary/35 bg-primary/5 text-foreground"
|
|
: "border-border/45 bg-card/82 hover:bg-muted/30",
|
|
)}
|
|
>
|
|
<span className="flex items-center justify-between gap-2">
|
|
<span className="truncate text-[15px] font-semibold">{preset.label}</span>
|
|
{form.modelPreset === preset.name ? <Check className="h-4 w-4" aria-hidden /> : null}
|
|
</span>
|
|
<span className="mt-2 block truncate text-[12px] text-muted-foreground">{preset.model}</span>
|
|
<span className="mt-1 block text-[12px] text-muted-foreground">
|
|
{preset.provider} · {preset.max_tokens} tokens
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SettingsSectionTitle>{t("settings.sections.ai")}</SettingsSectionTitle>
|
|
<SettingsGroup>
|
|
<SettingsRow
|
|
title={tx("settings.rows.selectedPreset", "Selected preset")}
|
|
description={tx("settings.help.selectedPreset", "Named presets are read-only here; edit them in config.json.")}
|
|
>
|
|
<StatusPill>{selectedPreset?.label ?? form.modelPreset}</StatusPill>
|
|
</SettingsRow>
|
|
{form.modelPreset === "default" ? (
|
|
<>
|
|
<SettingsRow
|
|
title={t("settings.rows.provider")}
|
|
description={t("settings.help.provider")}
|
|
>
|
|
<ProviderPicker
|
|
providers={providerOptions}
|
|
value={providerValue}
|
|
emptyLabel={t("settings.byok.noConfiguredProviders")}
|
|
onChange={(provider) => setForm((prev) => ({ ...prev, provider }))}
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow
|
|
title={t("settings.rows.model")}
|
|
description={t("settings.help.model")}
|
|
>
|
|
<Input
|
|
value={form.model}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
|
|
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
|
|
/>
|
|
</SettingsRow>
|
|
</>
|
|
) : (
|
|
<SettingsRow
|
|
title={tx("settings.rows.presetModel", "Preset model")}
|
|
description={tx("settings.help.presetModel", "Switch to Default to edit model and provider from the WebUI.")}
|
|
>
|
|
<span className="max-w-[280px] truncate text-right text-[13px] text-muted-foreground">
|
|
{selectedPreset?.model ?? settings.agent.model}
|
|
</span>
|
|
</SettingsRow>
|
|
)}
|
|
<SettingsFooter
|
|
dirty={dirty}
|
|
saving={saving}
|
|
saved={false}
|
|
onSave={onSave}
|
|
/>
|
|
{configuredProviders.length === 0 ? (
|
|
<SettingsRow title={t("settings.byok.configureFirst")}>
|
|
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
|
|
{t("settings.byok.openByok")}
|
|
</Button>
|
|
</SettingsRow>
|
|
) : null}
|
|
</SettingsGroup>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProvidersSettings({
|
|
settings,
|
|
expandedProvider,
|
|
providerForms,
|
|
visibleProviderKeys,
|
|
editingProviderKeys,
|
|
providerSaving,
|
|
query,
|
|
onQueryChange,
|
|
onToggleProvider,
|
|
onToggleProviderKey,
|
|
onToggleProviderKeyEditing,
|
|
onChangeProviderForm,
|
|
onSaveProvider,
|
|
onResetProviderDraft,
|
|
imageProviderRestartPending,
|
|
onRestart,
|
|
isRestarting,
|
|
}: {
|
|
settings: SettingsPayload;
|
|
expandedProvider: string | null;
|
|
providerForms: Record<string, { apiKey: string; apiBase: string }>;
|
|
visibleProviderKeys: Record<string, boolean>;
|
|
editingProviderKeys: Record<string, boolean>;
|
|
providerSaving: string | null;
|
|
query: string;
|
|
onQueryChange: (query: string) => void;
|
|
onToggleProvider: (provider: string) => void;
|
|
onToggleProviderKey: (provider: string) => void;
|
|
onToggleProviderKeyEditing: (provider: string) => void;
|
|
onChangeProviderForm: (provider: string, value: Partial<{ apiKey: string; apiBase: string }>) => void;
|
|
onSaveProvider: (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 ?? "",
|
|
};
|
|
const saving = providerSaving === provider.name;
|
|
const keyVisible = !!visibleProviderKeys[provider.name];
|
|
const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
|
|
const apiKeyRequired = provider.api_key_required ?? true;
|
|
const apiKey = form.apiKey.trim();
|
|
const apiBase = form.apiBase.trim();
|
|
const missingRequiredApiKey = apiKeyRequired && !provider.configured && !apiKey;
|
|
const missingOptionalCredential =
|
|
!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} />
|
|
<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"}>
|
|
{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">
|
|
<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>
|
|
<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") : t("settings.actions.save")}
|
|
</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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ImageGenerationSettings({
|
|
settings,
|
|
form,
|
|
dirty,
|
|
saving,
|
|
onChangeForm,
|
|
onSave,
|
|
onOpenProviders,
|
|
onRestart,
|
|
isRestarting,
|
|
requiresRestartPending,
|
|
}: {
|
|
settings: SettingsPayload;
|
|
form: ImageGenerationSettingsUpdate;
|
|
dirty: boolean;
|
|
saving: boolean;
|
|
onChangeForm: Dispatch<SetStateAction<ImageGenerationSettingsUpdate>>;
|
|
onSave: () => void;
|
|
onOpenProviders: () => void;
|
|
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 }))}
|
|
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")}
|
|
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,
|
|
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;
|
|
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")}
|
|
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 }))}
|
|
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 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 });
|
|
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 in runtime surfaces that use the configured bot identity.")}>
|
|
<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 beside 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", "IANA timezone used by runtime context and schedules.")}>
|
|
<Input
|
|
value={form.timezone}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, timezone: event.target.value }))}
|
|
className="h-8 w-[220px] rounded-full text-[13px]"
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow title={tx("settings.rows.toolHintMaxLength", "Tool hint length")} description={tx("settings.help.toolHintMaxLength", "Maximum characters shown in tool progress hints.")}>
|
|
<NumberInput
|
|
value={form.toolHintMaxLength}
|
|
min={20}
|
|
max={500}
|
|
onChange={(toolHintMaxLength) => setForm((prev) => ({ ...prev, toolHintMaxLength }))}
|
|
/>
|
|
</SettingsRow>
|
|
<RestartSettingsFooter
|
|
dirty={dirty}
|
|
saving={saving}
|
|
pendingRestart={requiresRestartPending}
|
|
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>
|
|
|
|
<section>
|
|
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
|
|
<SettingsGroup>
|
|
{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 ? t("app.system.restarting") : t("app.system.restart")}
|
|
</Button>
|
|
</SettingsRow>
|
|
) : null}
|
|
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
|
|
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Workspace path")} value={settings.runtime.workspace_path} />
|
|
<ReadOnlyRow title={tx("settings.rows.heartbeat", "Heartbeat")} value={settings.runtime.heartbeat.enabled ? `${settings.runtime.heartbeat.interval_s}s` : tx("settings.values.disabled", "Disabled")} />
|
|
<ReadOnlyRow title={tx("settings.rows.dream", "Dream")} value={settings.runtime.dream.schedule} />
|
|
<ReadOnlyRow title={tx("settings.rows.unifiedSession", "Unified session")} value={settings.runtime.unified_session ? tx("settings.values.enabled", "Enabled") : tx("settings.values.disabled", "Disabled")} />
|
|
</SettingsGroup>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AdvancedSettings({ settings }: { settings: SettingsPayload }) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
return (
|
|
<div className="space-y-7">
|
|
<section>
|
|
<SettingsSectionTitle>{tx("settings.sections.safety", "Safety")}</SettingsSectionTitle>
|
|
<SettingsGroup>
|
|
<ReadOnlyRow title={tx("settings.rows.restrictWorkspace", "Restrict to workspace")} value={settings.advanced.restrict_to_workspace ? tx("settings.values.enabled", "Enabled") : tx("settings.values.disabled", "Disabled")} />
|
|
<ReadOnlyRow title={tx("settings.rows.execTool", "Exec tool")} value={settings.advanced.exec_enabled ? tx("settings.values.enabled", "Enabled") : tx("settings.values.disabled", "Disabled")} />
|
|
<ReadOnlyRow title={tx("settings.rows.execSandbox", "Exec sandbox")} value={settings.advanced.exec_sandbox ?? tx("settings.values.notAvailable", "Not available")} />
|
|
<ReadOnlyRow title={tx("settings.rows.ssrfWhitelist", "SSRF whitelist")} value={String(settings.advanced.ssrf_whitelist_count)} />
|
|
</SettingsGroup>
|
|
</section>
|
|
|
|
<section>
|
|
<SettingsSectionTitle>{tx("settings.sections.integrations", "Integrations")}</SettingsSectionTitle>
|
|
<SettingsGroup>
|
|
<ReadOnlyRow title={tx("settings.rows.mcpServers", "MCP servers")} value={String(settings.advanced.mcp_server_count)} />
|
|
<ReadOnlyRow title={tx("settings.rows.pathAppend", "PATH append")} value={settings.advanced.exec_path_append_set ? tx("settings.values.configured", "Configured") : tx("settings.values.notConfigured", "Not configured")} />
|
|
<SettingsRow
|
|
title={tx("settings.rows.configurationDocs", "Configuration docs")}
|
|
description={tx("settings.help.advancedReadOnly", "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed.")}
|
|
>
|
|
<a
|
|
className="inline-flex h-8 items-center rounded-full border border-input bg-background px-3 text-[13px] font-medium text-foreground shadow-sm transition-colors hover:bg-muted"
|
|
href="https://github.com/HKUDS/nanobot/blob/main/docs/configuration.md"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
<Info className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
{tx("settings.actions.openDocs", "Open docs")}
|
|
</a>
|
|
</SettingsRow>
|
|
</SettingsGroup>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProviderPicker({
|
|
providers,
|
|
value,
|
|
emptyLabel,
|
|
onChange,
|
|
}: {
|
|
providers: Array<{ name: string; label: string }>;
|
|
value: string;
|
|
emptyLabel: string;
|
|
onChange: (provider: string) => void;
|
|
}) {
|
|
const selectedProvider = providers.find((provider) => provider.name === value) ?? null;
|
|
const disabled = providers.length === 0;
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<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="truncate">{selectedProvider?.label ?? emptyLabel}</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 rounded-[18px] border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]"
|
|
>
|
|
{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-3 py-2 text-[13px]",
|
|
"focus:bg-muted focus:text-foreground",
|
|
selected && "bg-primary/10 text-primary focus:bg-primary/12 focus:text-primary",
|
|
)}
|
|
>
|
|
<span className="truncate">{provider.label}</span>
|
|
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
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 orderUnconfiguredProviders(
|
|
providers: SettingsPayload["providers"],
|
|
): SettingsPayload["providers"] {
|
|
return providers
|
|
.map((provider, index) => ({ provider, index }))
|
|
.sort((left, right) => {
|
|
const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider);
|
|
return rank || left.index - right.index;
|
|
})
|
|
.map(({ provider }) => provider);
|
|
}
|
|
|
|
function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
|
|
const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
|
|
if (localRank !== undefined) return localRank;
|
|
if ((provider.api_key_required ?? true) === false) return 100;
|
|
return 200;
|
|
}
|
|
|
|
function 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),
|
|
);
|
|
}
|
|
|
|
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 providerLabel(
|
|
providers: Array<{ name: string; label: string }>,
|
|
value: string,
|
|
): string {
|
|
return providers.find((provider) => provider.name === value)?.label ?? value;
|
|
}
|
|
|
|
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,
|
|
vllm: Cpu,
|
|
ollama: Cpu,
|
|
lm_studio: Cpu,
|
|
atomic_chat: Cpu,
|
|
ovms: Cpu,
|
|
nvidia: Zap,
|
|
};
|
|
|
|
function ProviderIcon({ provider }: { provider: string }) {
|
|
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
|
|
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 OverviewListRow({
|
|
icon: Icon,
|
|
title,
|
|
value,
|
|
caption,
|
|
onClick,
|
|
}: {
|
|
icon: LucideIcon;
|
|
title: string;
|
|
value: string;
|
|
caption: string;
|
|
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"
|
|
>
|
|
<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>
|
|
<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">
|
|
<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 }: { title: string; value: string }) {
|
|
return (
|
|
<SettingsRow title={title}>
|
|
<span className="block max-w-[320px] truncate text-right text-[13px] text-muted-foreground">
|
|
{value}
|
|
</span>
|
|
</SettingsRow>
|
|
);
|
|
}
|
|
|
|
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 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 ? t("app.system.restarting") : t("app.system.restart")}
|
|
</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,
|
|
onSave,
|
|
}: {
|
|
dirty: boolean;
|
|
saving: boolean;
|
|
saved: boolean;
|
|
onSave: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
const statusMessage = 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={dirty || saved ? "accent" : undefined}>
|
|
{statusMessage}
|
|
</SettingsStatusMessage>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || 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,
|
|
label,
|
|
}: {
|
|
checked: boolean;
|
|
onChange: (checked: boolean) => void;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange(!checked)}
|
|
className={cn(
|
|
"inline-flex h-8 min-w-[64px] items-center justify-center rounded-full px-3 text-[12px] font-medium transition-colors",
|
|
checked
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
{label}
|
|
</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>
|
|
);
|
|
}
|