Maintainer edit: redact external channel chat identifiers from the WebUI automation payload and reject malformed or unschedulable automation updates before they mutate cron jobs.
7224 lines
265 KiB
TypeScript
7224 lines
265 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
forwardRef,
|
|
useMemo,
|
|
useState,
|
|
type Dispatch,
|
|
type FormEvent,
|
|
type ReactNode,
|
|
type SetStateAction,
|
|
} from "react";
|
|
import {
|
|
Activity,
|
|
ArrowUpCircle,
|
|
ArrowUpDown,
|
|
Bot,
|
|
Brain,
|
|
Check,
|
|
CircleAlert,
|
|
ChevronDown,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Cloud,
|
|
Cpu,
|
|
Database,
|
|
Eye,
|
|
EyeOff,
|
|
ExternalLink,
|
|
Gem,
|
|
Globe2,
|
|
Grid3X3,
|
|
HardDrive,
|
|
Hexagon,
|
|
ImageIcon,
|
|
Layers,
|
|
Loader2,
|
|
LogOut,
|
|
Mic,
|
|
Moon,
|
|
PauseCircle,
|
|
PlayCircle,
|
|
Plus,
|
|
Orbit,
|
|
Palette,
|
|
Pencil,
|
|
RotateCcw,
|
|
Search,
|
|
Server,
|
|
ShieldCheck,
|
|
SlidersHorizontal,
|
|
Sparkles,
|
|
Trash2,
|
|
Triangle,
|
|
Waves,
|
|
X,
|
|
Zap,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { 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 {
|
|
checkVersion,
|
|
createModelConfiguration,
|
|
fetchAutomations,
|
|
fetchSettings,
|
|
fetchSettingsUsage,
|
|
fetchCliApps,
|
|
fetchMcpPresets,
|
|
fetchProviderModels,
|
|
importMcpConfig,
|
|
loginProviderOAuth,
|
|
logoutProviderOAuth,
|
|
runAutomationAction,
|
|
runCliAppAction,
|
|
runMcpPresetAction,
|
|
saveCustomMcpServer,
|
|
updateAutomation,
|
|
updateImageGenerationSettings,
|
|
updateMcpServerTools,
|
|
updateModelConfiguration,
|
|
updateNetworkSafetySettings,
|
|
updateProviderSettings,
|
|
updateSettings,
|
|
updateTranscriptionSettings,
|
|
updateWebSearchSettings,
|
|
} from "@/lib/api";
|
|
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
|
|
import { getHostApi } from "@/lib/runtime";
|
|
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
|
|
import { fmtDateTime, relativeTime } from "@/lib/format";
|
|
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 {
|
|
AutomationsPayload,
|
|
AutomationUpdatePayload,
|
|
CliAppInfo,
|
|
CliAppsPayload,
|
|
ImageGenerationSettingsUpdate,
|
|
McpPresetInfo,
|
|
McpPresetsPayload,
|
|
NetworkSafetySettingsUpdate,
|
|
ProviderModelsPayload,
|
|
SessionAutomationJob,
|
|
SettingsPayload,
|
|
SkillSummary,
|
|
TranscriptionSettingsUpdate,
|
|
WebSearchSettingsUpdate,
|
|
WebuiDefaultAccessMode,
|
|
} from "@/lib/types";
|
|
|
|
export type SettingsSectionKey =
|
|
| "overview"
|
|
| "appearance"
|
|
| "models"
|
|
| "image"
|
|
| "voice"
|
|
| "browser"
|
|
| "apps"
|
|
| "automations"
|
|
| "skills"
|
|
| "runtime"
|
|
| "advanced";
|
|
|
|
type LocalDensity = "comfortable" | "compact";
|
|
type LocalActivityMode = "auto" | "expanded";
|
|
type AppsKindFilter = "all" | "cli" | "mcp";
|
|
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
|
|
type AutomationSort = "next" | "last" | "updated" | "name";
|
|
type AutomationAction = "enable" | "disable" | "delete" | "run";
|
|
type AppsCatalogItem =
|
|
| { id: string; kind: "cli"; app: CliAppInfo }
|
|
| { id: string; kind: "mcp"; preset: McpPresetInfo };
|
|
|
|
interface 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;
|
|
if (provider === "auto") {
|
|
const resolvedRow = settingsProviderRow(
|
|
payload,
|
|
payload.agent.resolved_provider ?? payload.agent.provider,
|
|
);
|
|
if (resolvedRow) return resolvedRow.configured;
|
|
}
|
|
return payload.agent.has_api_key;
|
|
}
|
|
|
|
const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
|
|
model: "",
|
|
provider: "",
|
|
modelPreset: "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_TRANSCRIPTION_FORM: TranscriptionSettingsUpdate = {
|
|
enabled: true,
|
|
provider: "groq",
|
|
model: "",
|
|
language: "",
|
|
maxDurationSec: 120,
|
|
maxUploadMb: 25,
|
|
};
|
|
|
|
const DEFAULT_TRANSCRIPTION_SETTINGS: NonNullable<SettingsPayload["transcription"]> = {
|
|
enabled: true,
|
|
provider: "groq",
|
|
provider_configured: false,
|
|
model: "whisper-large-v3",
|
|
language: null,
|
|
max_duration_sec: 120,
|
|
max_upload_mb: 25,
|
|
providers: [],
|
|
};
|
|
|
|
const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
|
|
webuiAllowLocalServiceAccess: true,
|
|
webuiDefaultAccessMode: "default",
|
|
};
|
|
|
|
function agentDraftFromPayload(payload: SettingsPayload): 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 transcriptionFormFromPayload(payload: SettingsPayload): TranscriptionSettingsUpdate {
|
|
const transcription = payload.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
|
return {
|
|
enabled: transcription.enabled,
|
|
provider: transcription.provider,
|
|
model: transcription.model,
|
|
language: transcription.language ?? "",
|
|
maxDurationSec: transcription.max_duration_sec,
|
|
maxUploadMb: transcription.max_upload_mb,
|
|
};
|
|
}
|
|
|
|
function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
|
|
return {
|
|
webuiAllowLocalServiceAccess:
|
|
payload.advanced.webui_allow_local_service_access ??
|
|
payload.advanced.allow_local_preview_access ??
|
|
true,
|
|
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
|
|
payload.advanced.webui_default_access_mode,
|
|
),
|
|
};
|
|
}
|
|
|
|
function pendingRestartSectionsFromPayload(payload: SettingsPayload): PendingRestartSections {
|
|
const sections = payload.restart_required_sections ?? [];
|
|
return {
|
|
runtime: sections.includes("runtime"),
|
|
browser: sections.includes("browser"),
|
|
image: sections.includes("image"),
|
|
};
|
|
}
|
|
|
|
export function SettingsView({
|
|
theme,
|
|
initialSection = "overview",
|
|
initialSettings = null,
|
|
showSidebar = true,
|
|
onToggleTheme,
|
|
onBackToChat,
|
|
onModelNameChange,
|
|
onSettingsChange,
|
|
skills = [],
|
|
onWorkspaceSettingsChange,
|
|
onSectionChange,
|
|
onLogout,
|
|
onRestart,
|
|
onNativeEngineRestart,
|
|
isRestarting = false,
|
|
hostChromeInset = false,
|
|
}: SettingsViewProps) {
|
|
const { t } = useTranslation();
|
|
const { token } = useClient();
|
|
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
|
|
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
|
|
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
|
|
const [automations, setAutomations] = useState<AutomationsPayload | null>(null);
|
|
const [loading, setLoading] = useState(() => initialSettings === null);
|
|
const [cliAppsLoading, setCliAppsLoading] = useState(true);
|
|
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
|
|
const [automationsLoading, setAutomationsLoading] = useState(false);
|
|
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 [transcriptionSaving, setTranscriptionSaving] = 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 [automationsQuery, setAutomationsQuery] = useState("");
|
|
const [automationsFilter, setAutomationsFilter] = useState<AutomationFilter>("all");
|
|
const [automationsSort, setAutomationsSort] = useState<AutomationSort>("next");
|
|
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 [automationsError, setAutomationsError] = useState<string | null>(null);
|
|
const [automationAction, setAutomationAction] = useState<string | null>(null);
|
|
const [automationPendingDelete, setAutomationPendingDelete] =
|
|
useState<SessionAutomationJob | null>(null);
|
|
const [automationPendingEdit, setAutomationPendingEdit] =
|
|
useState<SessionAutomationJob | 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 [transcriptionForm, setTranscriptionForm] = useState<TranscriptionSettingsUpdate>(
|
|
() => initialSettings ? transcriptionFormFromPayload(initialSettings) : DEFAULT_TRANSCRIPTION_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));
|
|
setTranscriptionForm(transcriptionFormFromPayload(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]);
|
|
|
|
const refreshAutomations = useCallback(
|
|
async (showLoading = false) => {
|
|
if (showLoading) setAutomationsLoading(true);
|
|
try {
|
|
const payload = await fetchAutomations(token);
|
|
setAutomations(payload);
|
|
setAutomationsError(null);
|
|
} catch (err) {
|
|
setAutomationsError((err as Error).message);
|
|
} finally {
|
|
if (showLoading) setAutomationsLoading(false);
|
|
}
|
|
},
|
|
[token],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (activeSection !== "automations") return;
|
|
let cancelled = false;
|
|
const refresh = async (showLoading = false) => {
|
|
if (cancelled) return;
|
|
if (showLoading) setAutomationsLoading(true);
|
|
try {
|
|
const payload = await fetchAutomations(token);
|
|
if (cancelled) return;
|
|
setAutomations(payload);
|
|
setAutomationsError(null);
|
|
} catch (err) {
|
|
if (!cancelled) setAutomationsError((err as Error).message);
|
|
} finally {
|
|
if (!cancelled && showLoading) setAutomationsLoading(false);
|
|
}
|
|
};
|
|
void refresh(true);
|
|
const interval = window.setInterval(() => void refresh(false), 5000);
|
|
const refreshOnFocus = () => {
|
|
if (document.visibilityState !== "hidden") void refresh(false);
|
|
};
|
|
window.addEventListener("focus", refreshOnFocus);
|
|
document.addEventListener("visibilitychange", refreshOnFocus);
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearInterval(interval);
|
|
window.removeEventListener("focus", refreshOnFocus);
|
|
document.removeEventListener("visibilitychange", refreshOnFocus);
|
|
};
|
|
}, [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 transcriptionDirty = useMemo(() => {
|
|
if (!settings) return false;
|
|
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
|
return (
|
|
transcriptionForm.enabled !== transcription.enabled ||
|
|
transcriptionForm.provider !== transcription.provider ||
|
|
transcriptionForm.model !== transcription.model ||
|
|
transcriptionForm.language !== (transcription.language ?? "") ||
|
|
transcriptionForm.maxDurationSec !== transcription.max_duration_sec ||
|
|
transcriptionForm.maxUploadMb !== transcription.max_upload_mb
|
|
);
|
|
}, [settings, transcriptionForm]);
|
|
|
|
const networkSafetyDirty = useMemo(() => {
|
|
if (!settings) return false;
|
|
const currentLocalServiceAccess =
|
|
settings.advanced.webui_allow_local_service_access ?? settings.advanced.allow_local_preview_access ?? true;
|
|
const currentDefaultAccess = visibleWebuiDefaultAccessMode(settings.advanced.webui_default_access_mode);
|
|
return (
|
|
networkSafetyForm.webuiAllowLocalServiceAccess !== currentLocalServiceAccess ||
|
|
networkSafetyForm.webuiDefaultAccessMode !== currentDefaultAccess
|
|
);
|
|
}, [networkSafetyForm, settings]);
|
|
|
|
const configuredModelProviderOptions = useMemo(
|
|
() =>
|
|
settings?.providers
|
|
.filter((provider) => provider.configured && provider.model_selectable !== false)
|
|
.map((provider) => ({ name: provider.name, label: provider.label })) ?? [],
|
|
[settings],
|
|
);
|
|
|
|
const hasPendingRestart = useMemo(
|
|
() =>
|
|
!!settings?.requires_restart ||
|
|
pendingRestartSections.runtime ||
|
|
pendingRestartSections.browser ||
|
|
pendingRestartSections.image,
|
|
[pendingRestartSections, settings?.requires_restart],
|
|
);
|
|
|
|
const restartViaSettingsSurface = useCallback(async () => {
|
|
const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native";
|
|
if (
|
|
isNativeHost &&
|
|
settings?.runtime_capabilities?.can_restart_engine &&
|
|
onNativeEngineRestart
|
|
) {
|
|
setHostEngineApplying(true);
|
|
try {
|
|
const nextToken = await onNativeEngineRestart();
|
|
const payload = await fetchSettings(nextToken);
|
|
applyPayload(payload);
|
|
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
} finally {
|
|
setHostEngineApplying(false);
|
|
}
|
|
return;
|
|
}
|
|
onRestart?.();
|
|
}, [applyPayload, onNativeEngineRestart, onRestart, settings]);
|
|
|
|
const maybeRestartHostEngine = useCallback(
|
|
async (payload: RestartAwarePayload) => {
|
|
const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface;
|
|
const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities;
|
|
const isNativeHost = surface === "native";
|
|
if (
|
|
!payload.requires_restart ||
|
|
!isNativeHost ||
|
|
!capabilities?.can_restart_engine ||
|
|
!onNativeEngineRestart
|
|
) {
|
|
return;
|
|
}
|
|
setHostEngineApplying(true);
|
|
try {
|
|
const nextToken = await onNativeEngineRestart();
|
|
const refreshed = await fetchSettings(nextToken);
|
|
applyPayload(refreshed);
|
|
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
} finally {
|
|
setHostEngineApplying(false);
|
|
}
|
|
},
|
|
[applyPayload, onNativeEngineRestart, settings],
|
|
);
|
|
|
|
const saveModelSettings = async () => {
|
|
if (!settings || !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 saveTranscriptionSettings = async () => {
|
|
if (!settings || !transcriptionDirty || transcriptionSaving) return;
|
|
setTranscriptionSaving(true);
|
|
try {
|
|
const payload = await updateTranscriptionSettings(token, transcriptionForm);
|
|
applyPayload(payload);
|
|
if (payload.requires_restart) {
|
|
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
|
}
|
|
await maybeRestartHostEngine(payload);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
} finally {
|
|
setTranscriptionSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveNetworkSafetySettings = async () => {
|
|
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
|
|
setNetworkSafetySaving(true);
|
|
try {
|
|
const payload = await updateNetworkSafetySettings(token, networkSafetyForm);
|
|
applyPayload(payload);
|
|
if (payload.requires_restart) {
|
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
|
}
|
|
await maybeRestartHostEngine(payload);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
} finally {
|
|
setNetworkSafetySaving(false);
|
|
}
|
|
};
|
|
|
|
const 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 handleAutomationAction = async (
|
|
action: AutomationAction,
|
|
job: SessionAutomationJob,
|
|
) => {
|
|
const key = `${action}:${job.id}`;
|
|
setAutomationAction(key);
|
|
setAutomationsError(null);
|
|
try {
|
|
const payload = await runAutomationAction(token, action, job.id);
|
|
setAutomations(payload);
|
|
if (action === "delete") setAutomationPendingDelete(null);
|
|
if (action === "run") {
|
|
window.setTimeout(() => void refreshAutomations(false), 1200);
|
|
window.setTimeout(() => void refreshAutomations(false), 4000);
|
|
}
|
|
} catch (err) {
|
|
setAutomationsError((err as Error).message);
|
|
} finally {
|
|
setAutomationAction(null);
|
|
}
|
|
};
|
|
|
|
const handleAutomationEdit = async (
|
|
job: SessionAutomationJob,
|
|
values: AutomationUpdatePayload,
|
|
) => {
|
|
const key = `update:${job.id}`;
|
|
setAutomationAction(key);
|
|
setAutomationsError(null);
|
|
try {
|
|
const payload = await updateAutomation(token, job.id, values);
|
|
setAutomations(payload);
|
|
setAutomationPendingEdit(null);
|
|
} catch (err) {
|
|
setAutomationsError((err as Error).message);
|
|
} finally {
|
|
setAutomationAction(null);
|
|
}
|
|
};
|
|
|
|
const handleMcpPresetAction = async (
|
|
action: "enable" | "remove" | "test",
|
|
name: string,
|
|
values: Record<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 "voice":
|
|
return (
|
|
<TranscriptionSettings
|
|
settings={settings}
|
|
form={transcriptionForm}
|
|
dirty={transcriptionDirty}
|
|
saving={transcriptionSaving}
|
|
onChangeForm={setTranscriptionForm}
|
|
onSave={saveTranscriptionSettings}
|
|
onOpenProviders={() => selectSection("models")}
|
|
showBrandLogos={localPrefs.brandLogos}
|
|
onRestart={restartViaSettingsSurface}
|
|
isRestarting={isRestarting || hostEngineApplying}
|
|
requiresRestartPending={pendingRestartSections.browser}
|
|
/>
|
|
);
|
|
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 "automations":
|
|
return (
|
|
<AutomationsSettings
|
|
payload={automations}
|
|
loading={automationsLoading}
|
|
query={automationsQuery}
|
|
filter={automationsFilter}
|
|
sort={automationsSort}
|
|
actionKey={automationAction}
|
|
error={automationsError}
|
|
onQueryChange={setAutomationsQuery}
|
|
onFilterChange={setAutomationsFilter}
|
|
onSortChange={setAutomationsSort}
|
|
onAction={handleAutomationAction}
|
|
onRequestEdit={setAutomationPendingEdit}
|
|
onRequestDelete={setAutomationPendingDelete}
|
|
/>
|
|
);
|
|
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}
|
|
/>
|
|
|
|
<AutomationDeleteDialog
|
|
job={automationPendingDelete}
|
|
deleting={automationAction === `delete:${automationPendingDelete?.id ?? ""}`}
|
|
onOpenChange={(open) => {
|
|
if (!open) setAutomationPendingDelete(null);
|
|
}}
|
|
onConfirm={(job) => handleAutomationAction("delete", job)}
|
|
/>
|
|
|
|
<AutomationEditDialog
|
|
job={automationPendingEdit}
|
|
saving={automationAction === `update:${automationPendingEdit?.id ?? ""}`}
|
|
onOpenChange={(open) => {
|
|
if (!open) setAutomationPendingEdit(null);
|
|
}}
|
|
onSave={handleAutomationEdit}
|
|
/>
|
|
|
|
<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: "voice", icon: Mic, fallback: "Voice" },
|
|
{ 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 webSearchProvider =
|
|
settings.web_search.providers.find((provider) => provider.name === settings.web_search.provider) ??
|
|
settings.web_search.providers[0];
|
|
const webSearchProviderLabel = providerDisplayLabel(
|
|
settings.web_search.providers,
|
|
settings.web_search.provider,
|
|
);
|
|
const webSearchCredentialStatus =
|
|
webSearchProvider?.credential === "none"
|
|
? tx("settings.byok.webSearch.noCredentialRequired", "No key required")
|
|
: webSearchProvider?.credential === "base_url"
|
|
? settings.web_search.base_url
|
|
? tx("settings.values.configured", "Configured")
|
|
: tx("settings.values.notConfigured", "Not configured")
|
|
: settings.web_search.api_key_hint
|
|
? tx("settings.values.configured", "Configured")
|
|
: tx("settings.values.notConfigured", "Not configured");
|
|
const webCaption = `${webSearchProviderLabel} · ${webSearchCredentialStatus}`;
|
|
const imageStatus = settings.image_generation.enabled
|
|
? tx("settings.values.enabled", "Enabled")
|
|
: tx("settings.values.disabled", "Disabled");
|
|
const imageCaption = `${providerDisplayLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${
|
|
settings.image_generation.provider_configured
|
|
? tx("settings.values.configured", "Configured")
|
|
: tx("settings.values.notConfigured", "Not configured")
|
|
}`;
|
|
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
|
const voiceStatus = transcription.enabled
|
|
? tx("settings.values.enabled", "Enabled")
|
|
: tx("settings.values.disabled", "Disabled");
|
|
const voiceCaption = `${providerDisplayLabel(transcription.providers, transcription.provider)} · ${
|
|
transcription.provider_configured
|
|
? tx("settings.values.configured", "Configured")
|
|
: tx("settings.values.notConfigured", "Not configured")
|
|
}`;
|
|
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
|
|
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
|
|
const runtimeTitle = isNativeHost
|
|
? tx("settings.rows.engine", "Engine")
|
|
: tx("settings.rows.gateway", "Gateway");
|
|
const runtimeValue = isNativeHost
|
|
? tx("settings.values.privateEngine", "Private engine")
|
|
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
|
|
const runtimeCaption = isNativeHost
|
|
? tx("settings.values.unixSocket", "Unix socket")
|
|
: requiresRestart
|
|
? tx("settings.values.restartPending", "Restart pending")
|
|
: tx("settings.values.ready", "Ready");
|
|
return (
|
|
<div className="space-y-7">
|
|
<section>
|
|
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
|
|
</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={webStatus}
|
|
caption={webCaption}
|
|
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")}
|
|
/>
|
|
<OverviewListRow
|
|
icon={Mic}
|
|
valueLogoProvider={transcription.provider}
|
|
title={tx("settings.overview.voiceInput", "Voice input")}
|
|
value={voiceStatus}
|
|
caption={voiceCaption}
|
|
showBrandLogos={showBrandLogos}
|
|
onClick={() => onSelectSection("voice")}
|
|
/>
|
|
</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>
|
|
|
|
<section>
|
|
<SettingsSectionTitle>{tx("settings.sections.about", "About")}</SettingsSectionTitle>
|
|
<SettingsGroup>
|
|
<VersionCheckRow currentVersion={settings.version?.current} />
|
|
</SettingsGroup>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function VersionCheckRow({ currentVersion }: { currentVersion?: string }) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
const { token } = useClient();
|
|
const [checking, setChecking] = useState(false);
|
|
const [result, setResult] = useState<
|
|
| { type: "up-to-date" }
|
|
| { type: "update"; latestVersion: string; pypiUrl?: string }
|
|
| { type: "error"; message: string }
|
|
| null
|
|
>(null);
|
|
|
|
const handleCheck = async () => {
|
|
setChecking(true);
|
|
setResult(null);
|
|
try {
|
|
const res = await checkVersion(token);
|
|
if (res.updateAvailable) {
|
|
setResult({
|
|
type: "update",
|
|
latestVersion: res.updateAvailable.latestVersion,
|
|
pypiUrl: res.updateAvailable.pypiUrl,
|
|
});
|
|
} else {
|
|
setResult({ type: "up-to-date" });
|
|
}
|
|
} catch (err) {
|
|
setResult({ type: "error", message: (err as Error).message });
|
|
} finally {
|
|
setChecking(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<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">
|
|
{tx("settings.about.version", "Version")}
|
|
</div>
|
|
<div className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
|
{currentVersion ? `v${currentVersion}` : "nanobot"}
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 flex-col items-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void handleCheck()}
|
|
disabled={checking}
|
|
className="rounded-full"
|
|
>
|
|
{checking ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
) : (
|
|
<ArrowUpCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
)}
|
|
{checking
|
|
? tx("settings.about.checking", "Checking...")
|
|
: tx("settings.about.checkForUpdates", "Check for updates")}
|
|
</Button>
|
|
{result?.type === "up-to-date" ? (
|
|
<span className="inline-flex items-center gap-1.5 text-[12px] text-emerald-600 dark:text-emerald-300">
|
|
<Check className="h-3 w-3" aria-hidden />
|
|
{tx("settings.about.upToDate", "You're up to date")}
|
|
</span>
|
|
) : null}
|
|
{result?.type === "update" ? (
|
|
<span className="inline-flex items-center gap-1.5 text-[12px] text-blue-600 dark:text-blue-300">
|
|
<ArrowUpCircle className="h-3 w-3" aria-hidden />
|
|
{tx("settings.about.updateAvailable", "Update available")}{result.latestVersion && ` v${result.latestVersion}`}
|
|
{result.pypiUrl ? (
|
|
<a
|
|
href={result.pypiUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-0.5 underline-offset-2 hover:underline"
|
|
>
|
|
PyPI
|
|
<ExternalLink className="h-2.5 w-2.5" aria-hidden />
|
|
</a>
|
|
) : null}
|
|
</span>
|
|
) : null}
|
|
{result?.type === "error" ? (
|
|
<span className="text-[12px] text-destructive">{result.message}</span>
|
|
) : null}
|
|
</div>
|
|
</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 TranscriptionSettings({
|
|
settings,
|
|
form,
|
|
dirty,
|
|
saving,
|
|
onChangeForm,
|
|
onSave,
|
|
onOpenProviders,
|
|
showBrandLogos,
|
|
onRestart,
|
|
isRestarting,
|
|
requiresRestartPending,
|
|
}: {
|
|
settings: SettingsPayload;
|
|
form: TranscriptionSettingsUpdate;
|
|
dirty: boolean;
|
|
saving: boolean;
|
|
onChangeForm: Dispatch<SetStateAction<TranscriptionSettingsUpdate>>;
|
|
onSave: () => void;
|
|
onOpenProviders: () => void;
|
|
showBrandLogos: boolean;
|
|
onRestart?: () => void;
|
|
isRestarting?: boolean;
|
|
requiresRestartPending: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
|
const selectedProvider =
|
|
transcription.providers.find((provider) => provider.name === form.provider) ??
|
|
transcription.providers[0];
|
|
const providerConfigured = !!selectedProvider?.configured;
|
|
|
|
return (
|
|
<section>
|
|
<SettingsSectionTitle>{tx("settings.sections.voiceInput", "Voice input")}</SettingsSectionTitle>
|
|
<SettingsGroup>
|
|
<SettingsRow
|
|
title={tx("settings.rows.transcription", "Transcription")}
|
|
description={tx("settings.help.transcription", "Transcribe microphone input before sending it. Chat channel voice messages use the same settings.")}
|
|
>
|
|
<ToggleButton
|
|
checked={form.enabled}
|
|
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
|
|
ariaLabel={tx("settings.rows.transcription", "Transcription")}
|
|
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow
|
|
title={tx("settings.rows.transcriptionProvider", "Provider")}
|
|
description={tx("settings.help.transcriptionProvider", "Uses the matching provider credentials from Providers.")}
|
|
>
|
|
<ProviderPicker
|
|
providers={transcription.providers}
|
|
value={form.provider}
|
|
emptyLabel={tx("settings.voice.selectProvider", "Select provider")}
|
|
showProviderLogos={showBrandLogos}
|
|
onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))}
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow
|
|
title={tx("settings.rows.transcriptionProviderStatus", "Provider status")}
|
|
description={tx("settings.help.transcriptionProviderStatus", "API keys stay under providers, not in transcription settings.")}
|
|
>
|
|
<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.voice.configureProvider", "Configure provider")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</SettingsRow>
|
|
<SettingsRow
|
|
title={tx("settings.rows.transcriptionModel", "Model")}
|
|
description={tx("settings.help.transcriptionModel", "Leave as the resolved default unless your provider needs a custom model id.")}
|
|
>
|
|
<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.transcriptionLanguage", "Language")}
|
|
description={tx("settings.help.transcriptionLanguage", "Optional ISO-639 hint such as en, zh, ja, or ko.")}
|
|
>
|
|
<Input
|
|
value={form.language}
|
|
onChange={(event) => onChangeForm((prev) => ({ ...prev, language: event.target.value }))}
|
|
placeholder={tx("settings.voice.languageAuto", "Auto")}
|
|
className="h-8 w-[min(180px,60vw)] rounded-full text-[13px]"
|
|
/>
|
|
</SettingsRow>
|
|
<SettingsRow title={tx("settings.rows.voiceLimits", "Limits")}>
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<NumberInput
|
|
value={form.maxDurationSec}
|
|
min={1}
|
|
max={600}
|
|
suffix="s"
|
|
onChange={(maxDurationSec) => onChangeForm((prev) => ({ ...prev, maxDurationSec }))}
|
|
/>
|
|
<NumberInput
|
|
value={form.maxUploadMb}
|
|
min={1}
|
|
max={100}
|
|
suffix="MB"
|
|
onChange={(maxUploadMb) => onChangeForm((prev) => ({ ...prev, maxUploadMb }))}
|
|
/>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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 AutomationsSettings({
|
|
payload,
|
|
loading,
|
|
query,
|
|
filter,
|
|
sort,
|
|
actionKey,
|
|
error,
|
|
onQueryChange,
|
|
onFilterChange,
|
|
onSortChange,
|
|
onAction,
|
|
onRequestEdit,
|
|
onRequestDelete,
|
|
}: {
|
|
payload: AutomationsPayload | null;
|
|
loading: boolean;
|
|
query: string;
|
|
filter: AutomationFilter;
|
|
sort: AutomationSort;
|
|
actionKey: string | null;
|
|
error: string | null;
|
|
onQueryChange: (value: string) => void;
|
|
onFilterChange: (value: AutomationFilter) => void;
|
|
onSortChange: (value: AutomationSort) => void;
|
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
|
onRequestEdit: (job: SessionAutomationJob) => void;
|
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
const jobs = payload?.jobs ?? [];
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
const filtered = sortAutomationJobs(jobs, sort)
|
|
.filter((job) => automationMatchesFilter(job, filter))
|
|
.filter((job) => !normalizedQuery || automationSearchText(job).includes(normalizedQuery));
|
|
const activeCount = jobs.filter((job) => {
|
|
const key = automationStatusKey(job);
|
|
return key === "active" || key === "running";
|
|
}).length;
|
|
const pausedCount = jobs.filter((job) => automationStatusKey(job) === "paused").length;
|
|
const failedCount = jobs.filter(automationNeedsAttention).length;
|
|
const systemCount = jobs.filter((job) => job.protected).length;
|
|
const filterOptions = [
|
|
{ value: "all", label: tx("settings.automations.filters.all", "All") },
|
|
{ value: "active", label: tx("settings.automations.filters.active", "Active") },
|
|
{ value: "paused", label: tx("settings.automations.filters.paused", "Paused") },
|
|
{ value: "failed", label: tx("settings.automations.filters.failed", "Needs attention") },
|
|
{ value: "system", label: tx("settings.automations.filters.system", "System") },
|
|
];
|
|
const sortLabel = {
|
|
next: tx("settings.automations.sort.next", "Next run"),
|
|
last: tx("settings.automations.sort.last", "Last run"),
|
|
updated: tx("settings.automations.sort.updated", "Updated"),
|
|
name: tx("settings.automations.sort.name", "Name"),
|
|
} satisfies Record<AutomationSort, string>;
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<section className="rounded-[24px] border border-border/50 bg-card/82 px-4 py-4 shadow-[0_18px_65px_rgba(15,23,42,0.07)] backdrop-blur-xl sm:px-5">
|
|
<div className="grid gap-2 sm:grid-cols-4">
|
|
<AutomationStat label={tx("settings.automations.stats.active", "Active")} value={activeCount} />
|
|
<AutomationStat label={tx("settings.automations.stats.paused", "Paused")} value={pausedCount} />
|
|
<AutomationStat label={tx("settings.automations.stats.failed", "Needs attention")} value={failedCount} />
|
|
<AutomationStat label={tx("settings.automations.stats.system", "System")} value={systemCount} />
|
|
</div>
|
|
|
|
<div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="relative min-w-0 flex-1">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
|
|
<Input
|
|
value={query}
|
|
onChange={(event) => onQueryChange(event.target.value)}
|
|
placeholder={tx("settings.automations.search", "Search automation, message, session, or cron expression")}
|
|
className="h-9 rounded-full bg-background/85 pl-9 text-[13px]"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-border/55 bg-background/85 px-3 text-[12px] font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/70 hover:text-foreground"
|
|
>
|
|
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
|
|
<span>{sortLabel[sort]}</span>
|
|
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="min-w-40">
|
|
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
|
|
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
|
|
<span>{sortLabel[value]}</span>
|
|
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<SegmentedControl
|
|
value={filter}
|
|
options={filterOptions}
|
|
onChange={(value) => onFilterChange(value as AutomationFilter)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{error ? (
|
|
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
|
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden />
|
|
<span>{error}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
<section>
|
|
<SettingsSectionTitle>{tx("settings.automations.queue", "Queue")}</SettingsSectionTitle>
|
|
{loading && !payload ? (
|
|
<div className="flex h-40 items-center justify-center rounded-[22px] border border-border/45 bg-card/78 text-[13px] text-muted-foreground">
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
|
{tx("settings.automations.loading", "Loading automations...")}
|
|
</div>
|
|
) : filtered.length ? (
|
|
<div className="space-y-2.5">
|
|
{filtered.map((job) => (
|
|
<AutomationRow
|
|
key={job.id}
|
|
job={job}
|
|
locale={i18n.resolvedLanguage || i18n.language}
|
|
actionKey={actionKey}
|
|
onAction={onAction}
|
|
onRequestEdit={onRequestEdit}
|
|
onRequestDelete={onRequestDelete}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-[22px] border border-border/45 bg-card/78 px-5 py-10 text-center text-[13px] text-muted-foreground">
|
|
<div>
|
|
{jobs.length
|
|
? tx("settings.automations.noMatches", "No automations match this view.")
|
|
: tx("settings.automations.empty", "No automations yet.")}
|
|
</div>
|
|
{!jobs.length ? (
|
|
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
|
|
{tx(
|
|
"settings.automations.emptyHint",
|
|
"Create one from the chat or channel where it should run so nanobot keeps the right context.",
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AutomationStat({ label, value }: { label: string; value: number }) {
|
|
return (
|
|
<div className="rounded-[16px] border border-border/45 bg-background/65 px-3 py-2.5">
|
|
<div className="text-[11px] font-medium uppercase leading-none text-muted-foreground">{label}</div>
|
|
<div className="mt-1.5 text-[20px] font-normal leading-none text-foreground">{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AutomationRow({
|
|
job,
|
|
locale,
|
|
actionKey,
|
|
onAction,
|
|
onRequestEdit,
|
|
onRequestDelete,
|
|
}: {
|
|
job: SessionAutomationJob;
|
|
locale: string;
|
|
actionKey: string | null;
|
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
|
onRequestEdit: (job: SessionAutomationJob) => void;
|
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
const status = automationStatus(job, tx);
|
|
const origin = automationOriginLabel(job, tx);
|
|
const originHref = job.origin?.channel === "websocket" && job.origin.session_key
|
|
? `#/chat/${encodeURIComponent(job.origin.session_key)}`
|
|
: null;
|
|
const history = job.state.run_history ?? [];
|
|
const canManage = !job.protected;
|
|
const canRun = canManage && job.enabled && !job.state.pending;
|
|
const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
|
|
const toggleBusy = actionKey === `${toggleAction}:${job.id}`;
|
|
const needsRecreation = automationNeedsRecreation(job);
|
|
|
|
return (
|
|
<article className="rounded-[22px] border border-border/45 bg-card/86 p-4 shadow-[0_18px_65px_rgba(15,23,42,0.06)] backdrop-blur-xl">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="truncate text-[15px] font-medium leading-6 text-foreground">
|
|
{job.name || job.id}
|
|
</span>
|
|
<StatusPill tone={status.tone}>{status.label}</StatusPill>
|
|
{job.delete_after_run ? (
|
|
<StatusPill>{tx("settings.automations.oneShot", "One-time")}</StatusPill>
|
|
) : null}
|
|
</div>
|
|
<p className="mt-1 line-clamp-2 max-w-[46rem] text-[13px] leading-6 text-muted-foreground">
|
|
{job.payload.message || tx("settings.automations.systemTask", "System-managed automation")}
|
|
</p>
|
|
|
|
<div className="mt-3 grid gap-2 text-[12px] text-muted-foreground md:grid-cols-2 xl:grid-cols-4">
|
|
<AutomationDetail
|
|
label={tx("settings.automations.labels.schedule", "Schedule")}
|
|
title={formatAutomationSchedule(job, locale, tx)}
|
|
>
|
|
{formatAutomationSchedule(job, locale, tx)}
|
|
</AutomationDetail>
|
|
<AutomationDetail
|
|
label={tx("settings.automations.labels.next", "Next")}
|
|
title={formatAutomationNextTitle(job, locale, tx)}
|
|
>
|
|
{formatAutomationNext(job, tx)}
|
|
</AutomationDetail>
|
|
<AutomationDetail
|
|
label={tx("settings.automations.labels.last", "Last")}
|
|
title={formatAutomationLast(job, locale, tx)}
|
|
>
|
|
{formatAutomationLast(job, locale, tx)}
|
|
</AutomationDetail>
|
|
<AutomationDetail label={tx("settings.automations.labels.origin", "Linked chat")} title={origin}>
|
|
{originHref ? (
|
|
<a
|
|
className="inline-flex max-w-full items-center gap-1 text-foreground/80 underline-offset-2 hover:underline"
|
|
href={originHref}
|
|
>
|
|
<span className="truncate">{origin}</span>
|
|
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden />
|
|
</a>
|
|
) : (
|
|
origin
|
|
)}
|
|
</AutomationDetail>
|
|
</div>
|
|
|
|
{needsRecreation ? (
|
|
<div className="mt-3 rounded-[14px] border border-amber-500/20 bg-amber-500/8 px-3 py-2 text-[12px] leading-5 text-amber-800 dark:text-amber-200">
|
|
{tx(
|
|
"settings.automations.legacyWarning",
|
|
"This older automation is missing its target chat. Recreate it from the chat or channel where it should run.",
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{job.state.last_error ? (
|
|
<div className="mt-3 rounded-[14px] bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive">
|
|
{job.state.last_error}
|
|
</div>
|
|
) : null}
|
|
|
|
{history.length ? (
|
|
<div className="mt-3">
|
|
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-normal text-muted-foreground">
|
|
{tx("settings.automations.history.recent", "Recent runs")}
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{history.slice(-4).map((record) => {
|
|
const statusLabel = automationRunStatusLabel(record.status, tx);
|
|
const duration = formatAutomationRunDuration(record.duration_ms, locale, tx);
|
|
const visibleLabel = record.status === "ok"
|
|
? duration
|
|
: tx("settings.automations.history.statusWithDuration", "{{status}} · {{duration}}", {
|
|
status: statusLabel,
|
|
duration,
|
|
});
|
|
const detail = record.error || fmtDateTime(record.run_at_ms, locale);
|
|
const accessibleLabel = `${statusLabel} · ${duration} · ${detail}`;
|
|
return (
|
|
<span
|
|
key={`${record.run_at_ms}:${record.status}`}
|
|
className={cn(
|
|
"rounded-full px-2 py-1 text-[11px]",
|
|
record.status === "error"
|
|
? "bg-destructive/10 text-destructive"
|
|
: record.status === "skipped"
|
|
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
|
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
|
)}
|
|
title={accessibleLabel}
|
|
aria-label={accessibleLabel}
|
|
>
|
|
{visibleLabel}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] leading-5 text-muted-foreground">
|
|
{job.created_at_ms ? (
|
|
<span>
|
|
{tx("settings.automations.meta.created", "Created {{time}}", {
|
|
time: fmtDateTime(job.created_at_ms, locale),
|
|
})}
|
|
</span>
|
|
) : null}
|
|
{job.updated_at_ms ? (
|
|
<span>
|
|
{tx("settings.automations.meta.updated", "Updated {{time}}", {
|
|
time: fmtDateTime(job.updated_at_ms, locale),
|
|
})}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
{canManage ? (
|
|
<>
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.automations.edit", "Edit")}
|
|
disabled={Boolean(actionKey)}
|
|
onClick={() => onRequestEdit(job)}
|
|
>
|
|
<Pencil className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.automations.runNow", "Run now")}
|
|
busy={actionKey === `run:${job.id}`}
|
|
disabled={!canRun}
|
|
onClick={() => void onAction("run", job)}
|
|
>
|
|
<PlayCircle className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
<AppsActionButton
|
|
ariaLabel={
|
|
job.enabled
|
|
? tx("settings.automations.pause", "Pause")
|
|
: tx("settings.automations.resume", "Resume")
|
|
}
|
|
busy={toggleBusy}
|
|
onClick={() => void onAction(toggleAction, job)}
|
|
>
|
|
{job.enabled ? (
|
|
<PauseCircle className="h-4 w-4" aria-hidden />
|
|
) : (
|
|
<PlayCircle className="h-4 w-4" aria-hidden />
|
|
)}
|
|
</AppsActionButton>
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.automations.delete", "Delete")}
|
|
tone="danger"
|
|
disabled={Boolean(actionKey)}
|
|
onClick={() => onRequestDelete(job)}
|
|
>
|
|
<Trash2 className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
</>
|
|
) : (
|
|
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
|
|
{tx("settings.automations.protected", "Protected")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function AutomationDetail({
|
|
label,
|
|
title,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
title?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="min-w-0 rounded-[14px] bg-muted/35 px-3 py-2">
|
|
<div className="text-[10.5px] font-medium uppercase leading-none text-muted-foreground/75">
|
|
{label}
|
|
</div>
|
|
<div className="mt-1.5 truncate text-[12.5px] leading-5 text-foreground/85" title={title}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type AutomationEveryUnit = "second" | "minute" | "hour" | "day";
|
|
|
|
type AutomationEditDraft = {
|
|
name: string;
|
|
message: string;
|
|
scheduleKind: "at" | "every" | "cron";
|
|
everyValue: string;
|
|
everyUnit: AutomationEveryUnit;
|
|
cronExpr: string;
|
|
tz: string;
|
|
atLocal: string;
|
|
};
|
|
|
|
const AUTOMATION_EVERY_UNITS: Array<{ value: AutomationEveryUnit; ms: number }> = [
|
|
{ value: "second", ms: 1000 },
|
|
{ value: "minute", ms: 60_000 },
|
|
{ value: "hour", ms: 3_600_000 },
|
|
{ value: "day", ms: 86_400_000 },
|
|
];
|
|
|
|
function AutomationEditDialog({
|
|
job,
|
|
saving,
|
|
onOpenChange,
|
|
onSave,
|
|
}: {
|
|
job: SessionAutomationJob | null;
|
|
saving: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSave: (job: SessionAutomationJob, values: AutomationUpdatePayload) => void | Promise<void>;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
const [draft, setDraft] = useState<AutomationEditDraft>(() => automationDraftFromJob(null));
|
|
|
|
useEffect(() => {
|
|
setDraft(automationDraftFromJob(job));
|
|
}, [job]);
|
|
|
|
const validation = automationEditDraftError(draft, tx);
|
|
const scheduleOptions = [
|
|
{ value: "every", label: tx("settings.automations.scheduleTypes.every", "Interval") },
|
|
{ value: "cron", label: tx("settings.automations.scheduleTypes.cron", "Cron") },
|
|
{ value: "at", label: tx("settings.automations.scheduleTypes.at", "Once") },
|
|
];
|
|
const unitLabels: Record<AutomationEveryUnit, string> = {
|
|
second: tx("settings.automations.everyUnits.second", "Seconds"),
|
|
minute: tx("settings.automations.everyUnits.minute", "Minutes"),
|
|
hour: tx("settings.automations.everyUnits.hour", "Hours"),
|
|
day: tx("settings.automations.everyUnits.day", "Days"),
|
|
};
|
|
|
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const payload = automationUpdatePayloadFromDraft(draft);
|
|
if (!job || typeof payload === "string") return;
|
|
void onSave(job, payload);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
|
|
{job ? (
|
|
<DialogContent className="w-[min(calc(100vw-2rem),34rem)] rounded-[26px]">
|
|
<form className="space-y-5" onSubmit={submit}>
|
|
<DialogHeader>
|
|
<DialogTitle>{tx("settings.automations.editTitle", "Edit automation")}</DialogTitle>
|
|
<DialogDescription>
|
|
{tx(
|
|
"settings.automations.editDescription",
|
|
"Update the prompt and schedule. The linked chat stays unchanged.",
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<label className="block space-y-1.5">
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
{tx("settings.automations.fields.name", "Name")}
|
|
</span>
|
|
<Input
|
|
value={draft.name}
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))}
|
|
className="h-10 rounded-[12px]"
|
|
/>
|
|
</label>
|
|
|
|
<label className="block space-y-1.5">
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
{tx("settings.automations.fields.message", "Message")}
|
|
</span>
|
|
<Textarea
|
|
value={draft.message}
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))}
|
|
className="min-h-24 resize-y rounded-[12px] text-[13px] leading-5"
|
|
/>
|
|
</label>
|
|
|
|
<div className="space-y-2">
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
{tx("settings.automations.fields.scheduleType", "Schedule type")}
|
|
</span>
|
|
<SegmentedControl
|
|
value={draft.scheduleKind}
|
|
options={scheduleOptions}
|
|
onChange={(value) =>
|
|
setDraft((prev) => ({
|
|
...prev,
|
|
scheduleKind: value as AutomationEditDraft["scheduleKind"],
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{draft.scheduleKind === "every" ? (
|
|
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_10rem]">
|
|
<label className="block space-y-1.5">
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
{tx("settings.automations.fields.every", "Every")}
|
|
</span>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
step={1}
|
|
value={draft.everyValue}
|
|
onChange={(event) =>
|
|
setDraft((prev) => ({ ...prev, everyValue: event.target.value }))
|
|
}
|
|
className="h-10 rounded-[12px]"
|
|
/>
|
|
</label>
|
|
<label className="block space-y-1.5">
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
{tx("settings.automations.fields.unit", "Unit")}
|
|
</span>
|
|
<select
|
|
value={draft.everyUnit}
|
|
onChange={(event) =>
|
|
setDraft((prev) => ({
|
|
...prev,
|
|
everyUnit: event.target.value as AutomationEveryUnit,
|
|
}))
|
|
}
|
|
className="h-10 w-full rounded-[12px] border border-input bg-background px-3 text-[13px] text-foreground shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{AUTOMATION_EVERY_UNITS.map((unit) => (
|
|
<option key={unit.value} value={unit.value}>
|
|
{unitLabels[unit.value]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
{draft.scheduleKind === "cron" ? (
|
|
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_12rem]">
|
|
<label className="block space-y-1.5">
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
{tx("settings.automations.fields.cronExpression", "Cron expression")}
|
|
</span>
|
|
<Input
|
|
value={draft.cronExpr}
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, cronExpr: event.target.value }))}
|
|
placeholder="0 9 * * *"
|
|
className="h-10 rounded-[12px] font-mono text-[13px]"
|
|
/>
|
|
</label>
|
|
<label className="block space-y-1.5">
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
{tx("settings.automations.fields.timezone", "Timezone")}
|
|
</span>
|
|
<Input
|
|
value={draft.tz}
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, tz: event.target.value }))}
|
|
placeholder="Asia/Shanghai"
|
|
className="h-10 rounded-[12px] text-[13px]"
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
{draft.scheduleKind === "at" ? (
|
|
<label className="block space-y-1.5">
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
{tx("settings.automations.fields.runAt", "Run at")}
|
|
</span>
|
|
<Input
|
|
type="datetime-local"
|
|
value={draft.atLocal}
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, atLocal: event.target.value }))}
|
|
className="h-10 rounded-[12px]"
|
|
/>
|
|
</label>
|
|
) : null}
|
|
|
|
{validation ? (
|
|
<div className="rounded-[12px] bg-destructive/8 px-3 py-2 text-[12px] text-destructive">
|
|
{validation}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={saving}
|
|
className="rounded-full"
|
|
>
|
|
{tx("settings.automations.cancel", "Cancel")}
|
|
</Button>
|
|
<Button type="submit" disabled={Boolean(validation) || saving} className="rounded-full">
|
|
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
|
{tx("settings.automations.save", "Save")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
) : null}
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function AutomationDeleteDialog({
|
|
job,
|
|
deleting,
|
|
onOpenChange,
|
|
onConfirm,
|
|
}: {
|
|
job: SessionAutomationJob | null;
|
|
deleting: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onConfirm: (job: SessionAutomationJob) => void | Promise<void>;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
return (
|
|
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
|
|
<DialogContent className="w-[min(calc(100vw-2rem),26rem)] rounded-[26px]">
|
|
<DialogHeader>
|
|
<DialogTitle>{tx("settings.automations.deleteTitle", "Delete automation")}</DialogTitle>
|
|
<DialogDescription>
|
|
{tx(
|
|
"settings.automations.deleteDescription",
|
|
"This removes {{name}} from the cron store. Past chat messages stay in the session.",
|
|
{ name: job?.name || job?.id || "" },
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={deleting}
|
|
className="rounded-full"
|
|
>
|
|
{tx("settings.automations.cancel", "Cancel")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() => job && void onConfirm(job)}
|
|
disabled={!job || deleting}
|
|
className="rounded-full"
|
|
>
|
|
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
|
{tx("settings.automations.delete", "Delete")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function automationNeedsRecreation(job: SessionAutomationJob): boolean {
|
|
return !job.protected && !job.origin && job.payload.kind === "agent_turn";
|
|
}
|
|
|
|
function automationNeedsAttention(job: SessionAutomationJob): boolean {
|
|
return automationNeedsRecreation(job) || job.state.last_status === "error";
|
|
}
|
|
|
|
function automationStatusKey(
|
|
job: SessionAutomationJob,
|
|
): "active" | "running" | "paused" | "failed" | "system" | "needs_setup" | "completed" | "idle" {
|
|
if (job.protected) return "system";
|
|
if (automationNeedsRecreation(job)) return "needs_setup";
|
|
if (job.state.pending) return "running";
|
|
if (!job.enabled) return "paused";
|
|
if (job.state.last_status === "error") return "failed";
|
|
if (job.delete_after_run && !job.state.next_run_at_ms && job.state.last_status === "ok") {
|
|
return "completed";
|
|
}
|
|
if (!job.state.next_run_at_ms) return "idle";
|
|
return "active";
|
|
}
|
|
|
|
function sortAutomationJobs(jobs: SessionAutomationJob[], sort: AutomationSort): SessionAutomationJob[] {
|
|
const byName = (left: SessionAutomationJob, right: SessionAutomationJob) =>
|
|
(left.name || left.id).localeCompare(right.name || right.id);
|
|
return [...jobs].sort((left, right) => {
|
|
if (sort === "name") return byName(left, right);
|
|
if (sort === "last") {
|
|
return (right.state.last_run_at_ms ?? 0) - (left.state.last_run_at_ms ?? 0) || byName(left, right);
|
|
}
|
|
if (sort === "updated") {
|
|
return (right.updated_at_ms ?? 0) - (left.updated_at_ms ?? 0) || byName(left, right);
|
|
}
|
|
const leftNext = left.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
|
|
const rightNext = right.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
|
|
return leftNext - rightNext || byName(left, right);
|
|
});
|
|
}
|
|
|
|
function automationDraftFromJob(job: SessionAutomationJob | null): AutomationEditDraft {
|
|
const every = automationIntervalDraft(job?.schedule.every_ms ?? 3_600_000);
|
|
const scheduleKind = job?.schedule.kind === "at" || job?.schedule.kind === "cron"
|
|
? job.schedule.kind
|
|
: "every";
|
|
return {
|
|
name: job?.name ?? "",
|
|
message: job?.payload.message ?? "",
|
|
scheduleKind,
|
|
everyValue: every.value,
|
|
everyUnit: every.unit,
|
|
cronExpr: job?.schedule.expr ?? "0 9 * * *",
|
|
tz: job?.schedule.tz ?? "",
|
|
atLocal: formatLocalDateTimeInput(job?.schedule.at_ms ?? Date.now() + 3_600_000),
|
|
};
|
|
}
|
|
|
|
function automationIntervalDraft(ms: number): { value: string; unit: AutomationEveryUnit } {
|
|
for (const unit of [...AUTOMATION_EVERY_UNITS].reverse()) {
|
|
if (ms >= unit.ms && ms % unit.ms === 0) {
|
|
return { value: String(ms / unit.ms), unit: unit.value };
|
|
}
|
|
}
|
|
return { value: String(Math.max(1, Math.round(ms / 60_000))), unit: "minute" };
|
|
}
|
|
|
|
function formatLocalDateTimeInput(ms: number): string {
|
|
const date = new Date(ms);
|
|
if (!Number.isFinite(date.getTime())) return "";
|
|
const local = new Date(ms - date.getTimezoneOffset() * 60_000);
|
|
return local.toISOString().slice(0, 16);
|
|
}
|
|
|
|
function automationEditDraftError(
|
|
draft: AutomationEditDraft,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string | null {
|
|
if (!draft.name.trim()) return tx("settings.automations.validation.nameRequired", "Name is required.");
|
|
if (!draft.message.trim()) {
|
|
return tx("settings.automations.validation.messageRequired", "Message is required.");
|
|
}
|
|
if (draft.scheduleKind === "every") {
|
|
const value = Number(draft.everyValue);
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
return tx("settings.automations.validation.intervalRequired", "Interval must be a positive number.");
|
|
}
|
|
}
|
|
if (draft.scheduleKind === "cron" && !draft.cronExpr.trim()) {
|
|
return tx("settings.automations.validation.cronRequired", "Cron expression is required.");
|
|
}
|
|
if (draft.scheduleKind === "at") {
|
|
const atMs = new Date(draft.atLocal).getTime();
|
|
if (!Number.isFinite(atMs)) {
|
|
return tx("settings.automations.validation.timeRequired", "Run time is required.");
|
|
}
|
|
if (atMs <= Date.now()) {
|
|
return tx("settings.automations.validation.futureRequired", "Run time must be in the future.");
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function automationUpdatePayloadFromDraft(draft: AutomationEditDraft): AutomationUpdatePayload | string {
|
|
const name = draft.name.trim();
|
|
const message = draft.message.trim();
|
|
if (!name || !message) return "invalid";
|
|
const payload: AutomationUpdatePayload = { name, message };
|
|
if (draft.scheduleKind === "every") {
|
|
const unit = AUTOMATION_EVERY_UNITS.find((candidate) => candidate.value === draft.everyUnit);
|
|
const value = Number(draft.everyValue);
|
|
if (!unit || !Number.isInteger(value) || value <= 0) return "invalid";
|
|
payload.schedule = { kind: "every", every_ms: value * unit.ms };
|
|
} else if (draft.scheduleKind === "cron") {
|
|
const expr = draft.cronExpr.trim();
|
|
if (!expr) return "invalid";
|
|
payload.schedule = { kind: "cron", expr, ...(draft.tz.trim() ? { tz: draft.tz.trim() } : {}) };
|
|
} else {
|
|
const atMs = new Date(draft.atLocal).getTime();
|
|
if (!Number.isFinite(atMs)) return "invalid";
|
|
payload.schedule = { kind: "at", at_ms: atMs };
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function automationSearchText(job: SessionAutomationJob): string {
|
|
const originText = job.origin
|
|
? job.origin.channel === "websocket"
|
|
? [job.origin.session_key, job.origin.title, job.origin.preview]
|
|
: [job.origin.channel]
|
|
: [];
|
|
return [
|
|
job.id,
|
|
job.name,
|
|
job.payload.message,
|
|
job.schedule.kind,
|
|
job.schedule.expr,
|
|
job.schedule.tz,
|
|
...originText,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function automationMatchesFilter(job: SessionAutomationJob, filter: AutomationFilter): boolean {
|
|
const status = automationStatusKey(job);
|
|
if (filter === "active") return status === "active" || status === "running";
|
|
if (filter === "paused") return status === "paused";
|
|
if (filter === "failed") return automationNeedsAttention(job);
|
|
if (filter === "system") return Boolean(job.protected);
|
|
return true;
|
|
}
|
|
|
|
function automationStatus(
|
|
job: SessionAutomationJob,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): { label: string; tone: "neutral" | "success" | "warning" } {
|
|
const status = automationStatusKey(job);
|
|
if (status === "system") return { label: tx("settings.automations.status.system", "System"), tone: "neutral" };
|
|
if (status === "needs_setup") {
|
|
return { label: tx("settings.automations.status.needsSetup", "Needs setup"), tone: "warning" };
|
|
}
|
|
if (status === "running") {
|
|
return { label: tx("settings.automations.status.running", "Running now"), tone: "warning" };
|
|
}
|
|
if (status === "paused") return { label: tx("settings.automations.status.paused", "Paused"), tone: "neutral" };
|
|
if (status === "failed") {
|
|
return { label: tx("settings.automations.status.failed", "Failed"), tone: "warning" };
|
|
}
|
|
if (status === "completed") {
|
|
return { label: tx("settings.automations.status.completed", "Completed"), tone: "neutral" };
|
|
}
|
|
if (status === "idle") {
|
|
return { label: tx("settings.automations.status.noSchedule", "No schedule"), tone: "neutral" };
|
|
}
|
|
return { label: tx("settings.automations.status.active", "Active"), tone: "success" };
|
|
}
|
|
|
|
function automationOriginLabel(
|
|
job: SessionAutomationJob,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
if (job.protected) return tx("settings.automations.origin.system", "System");
|
|
const origin = job.origin;
|
|
if (!origin && job.payload.kind === "agent_turn") {
|
|
return tx("settings.automations.origin.legacy", "Recreate in target chat");
|
|
}
|
|
if (!origin) return tx("settings.automations.origin.unknown", "No linked chat");
|
|
if (origin.channel !== "websocket") return automationChannelLabel(origin.channel, tx);
|
|
return origin.title || origin.preview || origin.session_key || automationChannelLabel(origin.channel, tx);
|
|
}
|
|
|
|
function automationChannelLabel(
|
|
channel: string,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
const key = channel.trim().toLowerCase();
|
|
const labels: Record<string, string> = {
|
|
api: "API",
|
|
cli: "CLI",
|
|
dingtalk: "DingTalk",
|
|
discord: "Discord",
|
|
email: "Email",
|
|
feishu: "Feishu",
|
|
matrix: "Matrix",
|
|
msteams: "Microsoft Teams",
|
|
qq: "QQ",
|
|
slack: "Slack",
|
|
telegram: "Telegram",
|
|
wechat: "WeChat",
|
|
wecom: "WeCom",
|
|
weixin: "WeChat",
|
|
whatsapp: "WhatsApp",
|
|
};
|
|
return labels[key]
|
|
? tx(`settings.automations.channels.${key}`, labels[key])
|
|
: channel;
|
|
}
|
|
|
|
function formatAutomationSchedule(
|
|
job: SessionAutomationJob,
|
|
locale: string,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
if (job.schedule.kind === "at" && job.schedule.at_ms) {
|
|
return tx("settings.automations.schedule.at", "At {{time}}", {
|
|
time: fmtDateTime(job.schedule.at_ms, locale),
|
|
});
|
|
}
|
|
if (job.schedule.kind === "every" && job.schedule.every_ms) {
|
|
return tx("settings.automations.schedule.every", "Every {{duration}}", {
|
|
duration: formatAutomationInterval(job.schedule.every_ms, locale),
|
|
});
|
|
}
|
|
if (job.schedule.kind === "cron" && job.schedule.expr) {
|
|
const summary = formatCronScheduleSummary(job.schedule.expr, tx);
|
|
if (summary) {
|
|
return job.schedule.tz
|
|
? tx("settings.automations.schedule.withTz", "{{summary}} · {{tz}}", {
|
|
summary,
|
|
tz: job.schedule.tz,
|
|
})
|
|
: summary;
|
|
}
|
|
return job.schedule.tz
|
|
? tx("settings.automations.schedule.cronWithTz", "Cron {{expr}} · {{tz}}", {
|
|
expr: job.schedule.expr,
|
|
tz: job.schedule.tz,
|
|
})
|
|
: tx("settings.automations.schedule.cron", "Cron {{expr}}", { expr: job.schedule.expr });
|
|
}
|
|
return tx("settings.automations.schedule.custom", "Custom schedule");
|
|
}
|
|
|
|
function formatCronScheduleSummary(
|
|
expr: string,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string | null {
|
|
const parts = expr.trim().split(/\s+/);
|
|
if (parts.length !== 5) return null;
|
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
const numericMinute = cronNumericToken(minute, 59);
|
|
const numericHour = cronNumericToken(hour, 23);
|
|
const everyDay = dayOfMonth === "*" && month === "*" && dayOfWeek === "*";
|
|
const workdays = dayOfMonth === "*" && month === "*" && ["1-5", "MON-FRI", "mon-fri"].includes(dayOfWeek);
|
|
|
|
if (numericMinute !== null && numericHour !== null) {
|
|
const time = `${String(numericHour).padStart(2, "0")}:${String(numericMinute).padStart(2, "0")}`;
|
|
if (everyDay) return tx("settings.automations.schedule.dailyAt", "Daily at {{time}}", { time });
|
|
if (workdays) return tx("settings.automations.schedule.weekdaysAt", "Weekdays at {{time}}", { time });
|
|
}
|
|
|
|
if (everyDay && numericMinute !== null && hour === "*") {
|
|
return tx("settings.automations.schedule.hourlyAt", "Hourly at :{{minute}}", {
|
|
minute: String(numericMinute).padStart(2, "0"),
|
|
});
|
|
}
|
|
|
|
const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
|
|
if (everyDay && numericMinute !== null && range) {
|
|
const start = Number(range[1]);
|
|
const end = Number(range[2]);
|
|
if (start > 23 || end > 23) return null;
|
|
return tx("settings.automations.schedule.hourlyWindow", "Hourly {{start}}-{{end}} at :{{minute}}", {
|
|
start: String(start).padStart(2, "0"),
|
|
end: String(end).padStart(2, "0"),
|
|
minute: String(numericMinute).padStart(2, "0"),
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function cronNumericToken(value: string, max: number): number | null {
|
|
if (!/^\d{1,2}$/.test(value)) return null;
|
|
const parsed = Number(value);
|
|
return parsed <= max ? parsed : null;
|
|
}
|
|
|
|
function formatAutomationNext(
|
|
job: SessionAutomationJob,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
if (!job.enabled) return tx("settings.automations.next.paused", "Paused");
|
|
if (job.state.pending) return tx("settings.automations.next.pending", "Running now");
|
|
if (!job.state.next_run_at_ms) return tx("settings.automations.next.none", "No next run");
|
|
return relativeTime(job.state.next_run_at_ms);
|
|
}
|
|
|
|
function formatAutomationNextTitle(
|
|
job: SessionAutomationJob,
|
|
locale: string,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
if (!job.state.next_run_at_ms) return formatAutomationNext(job, tx);
|
|
return fmtDateTime(job.state.next_run_at_ms, locale);
|
|
}
|
|
|
|
function formatAutomationLast(
|
|
job: SessionAutomationJob,
|
|
locale: string,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
if (!job.state.last_run_at_ms) return tx("settings.automations.last.never", "Never");
|
|
const status = automationRunStatusLabel(job.state.last_status, tx);
|
|
return `${fmtDateTime(job.state.last_run_at_ms, locale)} · ${status}`;
|
|
}
|
|
|
|
function automationRunStatusLabel(
|
|
status: string | null | undefined,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
if (status === "ok") return tx("settings.automations.history.ok", "Completed");
|
|
if (status === "error") return tx("settings.automations.history.error", "Error");
|
|
if (status === "skipped") return tx("settings.automations.history.skipped", "Skipped");
|
|
return status || tx("settings.automations.last.unknown", "unknown");
|
|
}
|
|
|
|
function formatAutomationUnit(
|
|
value: number,
|
|
unit: Intl.NumberFormatOptions["unit"],
|
|
locale: string,
|
|
maximumFractionDigits = 0,
|
|
): string {
|
|
return new Intl.NumberFormat(locale, {
|
|
style: "unit",
|
|
unit,
|
|
unitDisplay: "long",
|
|
maximumFractionDigits,
|
|
}).format(value);
|
|
}
|
|
|
|
function formatAutomationInterval(ms: number, locale: string): string {
|
|
const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
|
|
["day", 86_400_000],
|
|
["hour", 3_600_000],
|
|
["minute", 60_000],
|
|
["second", 1000],
|
|
];
|
|
for (const [unit, size] of units) {
|
|
if (ms >= size && ms % size === 0) return formatAutomationUnit(ms / size, unit, locale);
|
|
}
|
|
const fallbackUnit = ms < 60_000 ? "second" : "minute";
|
|
const fallbackSize = fallbackUnit === "second" ? 1000 : 60_000;
|
|
return formatAutomationUnit(ms / fallbackSize, fallbackUnit, locale, 1);
|
|
}
|
|
|
|
function formatAutomationRunDuration(
|
|
ms: number | undefined,
|
|
locale: string,
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
): string {
|
|
if (!ms || ms < 1000) {
|
|
return tx("settings.automations.duration.lessThanSecond", "< 1 second");
|
|
}
|
|
if (ms < 60_000) return formatAutomationUnit(ms / 1000, "second", locale, 1);
|
|
return formatAutomationUnit(ms / 60_000, "minute", locale, 1);
|
|
}
|
|
|
|
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,
|
|
bocha: Search,
|
|
brave: Search,
|
|
duckduckgo: Search,
|
|
exa: Search,
|
|
jina: Search,
|
|
kagi: Search,
|
|
olostep: Search,
|
|
searxng: Search,
|
|
tavily: Search,
|
|
vllm: Cpu,
|
|
ollama: Cpu,
|
|
lm_studio: Cpu,
|
|
atomic_chat: Cpu,
|
|
ovms: Cpu,
|
|
nvidia: Zap,
|
|
};
|
|
|
|
function ProviderIcon({
|
|
provider,
|
|
showBrandLogos,
|
|
}: {
|
|
provider: string;
|
|
showBrandLogos: boolean;
|
|
}) {
|
|
const [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>
|
|
);
|
|
}
|