import { IconCode, IconDeviceFloppy, IconTag } from "@tabler/icons-react" import { useQuery, useQueryClient } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" import { patchAppConfig } from "@/api/channels" import { launcherFetch } from "@/api/http" import { postLauncherDashboardSetup } from "@/api/launcher-auth" import { getAutoStartStatus, getLauncherConfig, getSystemVersionInfo, setAutoStartEnabled as updateAutoStartEnabled, setLauncherConfig as updateLauncherConfig, } from "@/api/system" import { ConfigChangeNotice } from "@/components/config-change-notice" import { AgentDefaultsSection, CronSection, DevicesSection, EvolutionSection, ExecSection, LauncherSection, MCPSection, RuntimeSection, } from "@/components/config/config-sections" import { type CoreConfigForm, EMPTY_FORM, EMPTY_LAUNCHER_FORM, type LauncherForm, type MCPServerForm, buildFormFromConfig, parseCIDRText, parseFloatField, parseIntField, parseJSONObjectField, parseMultilineList, } from "@/components/config/form-model" import { PageHeader } from "@/components/page-header" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" function buildStringMapMergePatch( next: Record, previous: Record, ): Record { const patch: Record = { ...next } for (const key of Object.keys(previous)) { if (!(key in next)) { patch[key] = null } } return patch } export function ConfigPage() { const { t } = useTranslation() const queryClient = useQueryClient() const [form, setForm] = useState(EMPTY_FORM) const [baseline, setBaseline] = useState(EMPTY_FORM) const [launcherForm, setLauncherForm] = useState(EMPTY_LAUNCHER_FORM) const [launcherBaseline, setLauncherBaseline] = useState(EMPTY_LAUNCHER_FORM) const [autoStartEnabled, setAutoStartEnabled] = useState(false) const [autoStartBaseline, setAutoStartBaseline] = useState(false) const [saving, setSaving] = useState(false) const { data, isLoading, error } = useQuery({ queryKey: ["config"], queryFn: async () => { const res = await launcherFetch("/api/config") if (!res.ok) { throw new Error("Failed to load config") } return res.json() }, }) const { data: launcherConfig, isLoading: isLauncherLoading } = useQuery({ queryKey: ["system", "launcher-config"], queryFn: getLauncherConfig, }) const { data: versionInfo } = useQuery({ queryKey: ["system", "version"], queryFn: getSystemVersionInfo, staleTime: 5 * 60 * 1000, }) const { data: autoStartStatus, isLoading: isAutoStartLoading, error: autoStartError, } = useQuery({ queryKey: ["system", "autostart"], queryFn: getAutoStartStatus, }) useEffect(() => { if (!data) return const parsed = buildFormFromConfig(data) setForm(parsed) setBaseline(parsed) }, [data]) useEffect(() => { if (!launcherConfig) return const parsed: LauncherForm = { port: String(launcherConfig.port), publicAccess: launcherConfig.public, allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), dashboardPassword: "", dashboardPasswordConfirm: "", } setLauncherForm(parsed) setLauncherBaseline(parsed) }, [launcherConfig]) useEffect(() => { if (!autoStartStatus) return setAutoStartEnabled(autoStartStatus.enabled) setAutoStartBaseline(autoStartStatus.enabled) }, [autoStartStatus]) const configDirty = JSON.stringify(form) !== JSON.stringify(baseline) const launcherSettingsDirty = launcherForm.port !== launcherBaseline.port || launcherForm.publicAccess !== launcherBaseline.publicAccess || launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText const launcherPasswordDirty = launcherForm.dashboardPassword.trim() !== "" || launcherForm.dashboardPasswordConfirm.trim() !== "" const launcherDirty = launcherSettingsDirty || launcherPasswordDirty const autoStartDirty = autoStartEnabled !== autoStartBaseline const isDirty = configDirty || launcherDirty || autoStartDirty const autoStartSupported = autoStartStatus?.supported !== false const autoStartHint = autoStartError ? t("pages.config.autostart_load_error") : !autoStartSupported ? t("pages.config.autostart_unsupported") : t("pages.config.autostart_hint") const updateField = ( key: K, value: CoreConfigForm[K], ) => { setForm((prev) => ({ ...prev, [key]: value })) } const updateLauncherField = ( key: K, value: LauncherForm[K], ) => { setLauncherForm((prev) => ({ ...prev, [key]: value })) } const handleMCPServerAdd = () => { const nextIndex = form.mcpServers.length + 1 const server: MCPServerForm = { id: `mcp-${Date.now()}-${nextIndex}`, name: "", enabled: true, deferredOverride: null, type: "stdio", url: "", command: "", argsText: "", envText: "{}", envFile: "", headersText: "{}", } updateField("mcpServers", [...form.mcpServers, server]) } const handleMCPServerRemove = (id: string) => { updateField( "mcpServers", form.mcpServers.filter((server) => server.id !== id), ) } const handleMCPServerFieldChange = ( id: string, key: K, value: MCPServerForm[K], ) => { updateField( "mcpServers", form.mcpServers.map((server) => server.id === id ? { ...server, [key]: value } : server, ), ) } const handleReset = () => { setForm(baseline) setLauncherForm(launcherBaseline) setAutoStartEnabled(autoStartBaseline) toast.info(t("pages.config.reset_success")) } const handleSave = async () => { try { setSaving(true) const password = launcherForm.dashboardPassword.trim() const confirm = launcherForm.dashboardPasswordConfirm.trim() if (launcherPasswordDirty) { if (!password) { throw new Error(t("pages.config.dashboard_password_required")) } if (password !== confirm) { throw new Error(t("pages.config.dashboard_password_mismatch")) } if (Array.from(password).length < 8) { throw new Error(t("pages.config.dashboard_password_min_length")) } } if (configDirty) { const workspace = form.workspace.trim() const dmScope = form.dmScope.trim() if (!workspace) { throw new Error("Workspace path is required.") } if (!dmScope) { throw new Error("Session scope is required.") } if ( form.mcpEnabled && form.mcpDiscoveryEnabled && !form.mcpDiscoveryUseBM25 && !form.mcpDiscoveryUseRegex ) { throw new Error( "MCP discovery requires at least one search method (BM25 or regex).", ) } const maxTokens = parseIntField(form.maxTokens, "Max tokens", { min: 1, }) const contextWindow = form.contextWindow.trim() ? parseIntField(form.contextWindow, "Context window", { min: 1 }) : undefined const maxToolIterations = parseIntField( form.maxToolIterations, "Max tool iterations", { min: 1 }, ) const toolFeedbackMaxArgsLength = parseIntField( form.toolFeedbackMaxArgsLength, "Tool feedback max args length", { min: 0 }, ) const summarizeMessageThreshold = parseIntField( form.summarizeMessageThreshold, "Summarize message threshold", { min: 1 }, ) const summarizeTokenPercent = parseIntField( form.summarizeTokenPercent, "Summarize token percent", { min: 1, max: 100 }, ) const heartbeatInterval = parseIntField( form.heartbeatInterval, "Heartbeat interval", { min: 1 }, ) const cronExecTimeoutMinutes = parseIntField( form.cronExecTimeoutMinutes, "Cron exec timeout", { min: 0 }, ) const evolutionMinTaskCount = parseIntField( form.evolutionMinTaskCount, "Evolution minimum task count", { min: 1 }, ) const evolutionMinSuccessRatio = parseFloatField( form.evolutionMinSuccessRatio, "Evolution minimum success ratio", { min: 0.01, max: 1 }, ) const mcpDiscoveryValidationEnabled = form.mcpEnabled && form.mcpDiscoveryEnabled const mcpDiscoveryPatch: Record = { enabled: form.mcpDiscoveryEnabled, use_bm25: form.mcpDiscoveryUseBM25, use_regex: form.mcpDiscoveryUseRegex, } if (mcpDiscoveryValidationEnabled) { mcpDiscoveryPatch.ttl = parseIntField( form.mcpDiscoveryTTL, "MCP discovery ttl", { min: 1, }, ) mcpDiscoveryPatch.max_search_results = parseIntField( form.mcpDiscoveryMaxSearchResults, "MCP discovery max search results", { min: 1 }, ) } const execConfigPatch: Record = { enabled: form.execEnabled, } let mcpServersPatch: Record | null> = {} if (form.mcpEnabled) { const baselineServerNames = new Set( baseline.mcpServers .map((server) => server.name.trim()) .filter((name) => name !== ""), ) const normalizedServers = form.mcpServers .map((server) => ({ ...server, name: server.name.trim(), url: server.url.trim(), command: server.command.trim(), envFile: server.envFile.trim(), })) .filter((server) => server.name !== "") const serverNameCounts = new Map() for (const server of normalizedServers) { serverNameCounts.set( server.name, (serverNameCounts.get(server.name) ?? 0) + 1, ) } const duplicateNames = Array.from(serverNameCounts.entries()) .filter(([, count]) => count > 1) .map(([name]) => name) .sort((a, b) => a.localeCompare(b)) if (duplicateNames.length > 0) { throw new Error( `MCP server names must be unique. Duplicates: ${duplicateNames.join(", ")}.`, ) } const currentServerNames = new Set( normalizedServers.map((server) => server.name), ) const removedServerEntries = Array.from(baselineServerNames) .filter((name) => !currentServerNames.has(name)) .map((name) => [name, null] as const) const baselineServersByName = new Map( baseline.mcpServers .map((server) => ({ ...server, name: server.name.trim(), })) .filter((server) => server.name !== "") .map((server) => [server.name, server] as const), ) const upsertServerEntries = normalizedServers.map((server) => { const deferredPatch = { deferred: server.deferredOverride } const baselineServer = baselineServersByName.get(server.name) const shouldValidateServer = server.enabled if (server.type !== "stdio") { if (shouldValidateServer && server.url === "") { throw new Error(`MCP server ${server.name} requires a URL.`) } if (shouldValidateServer) { try { const parsedURL = new URL(server.url) if ( parsedURL.protocol !== "http:" && parsedURL.protocol !== "https:" ) { throw new Error("invalid protocol") } } catch { throw new Error( `MCP server ${server.name} requires a valid HTTP(S) URL.`, ) } } const baselineHeaders = baselineServer ? parseJSONObjectField( baselineServer.headersText, `Saved MCP server ${server.name} headers`, ) : {} return [ server.name, { ...deferredPatch, enabled: server.enabled, type: server.type, url: server.url, headers: buildStringMapMergePatch( shouldValidateServer ? parseJSONObjectField( server.headersText, `MCP server ${server.name} headers`, ) : baselineHeaders, baselineHeaders, ), command: null, args: null, env: null, env_file: null, }, ] as const } if (shouldValidateServer && server.command === "") { throw new Error(`MCP server ${server.name} requires a command.`) } const baselineEnv = baselineServer ? parseJSONObjectField( baselineServer.envText, `Saved MCP server ${server.name} env`, ) : {} return [ server.name, { ...deferredPatch, enabled: server.enabled, type: "stdio", command: server.command, args: parseMultilineList(server.argsText), env: buildStringMapMergePatch( shouldValidateServer ? parseJSONObjectField( server.envText, `MCP server ${server.name} env`, ) : baselineEnv, baselineEnv, ), env_file: server.envFile === "" ? null : server.envFile, url: null, headers: null, }, ] as const }) mcpServersPatch = Object.fromEntries([ ...upsertServerEntries, ...removedServerEntries, ]) } if (form.execEnabled) { execConfigPatch.allow_remote = form.allowRemote execConfigPatch.enable_deny_patterns = form.enableDenyPatterns execConfigPatch.custom_allow_patterns = parseMultilineList( form.customAllowPatternsText, ) execConfigPatch.timeout_seconds = parseIntField( form.execTimeoutSeconds, "Exec timeout", { min: 0 }, ) if (form.enableDenyPatterns) { execConfigPatch.custom_deny_patterns = parseMultilineList( form.customDenyPatternsText, ) } } await patchAppConfig({ agents: { defaults: { workspace, restrict_to_workspace: form.restrictToWorkspace, split_on_marker: form.splitOnMarker, tool_feedback: { enabled: form.toolFeedbackEnabled, max_args_length: toolFeedbackMaxArgsLength, separate_messages: form.toolFeedbackSeparateMessages, }, max_tokens: maxTokens, context_window: contextWindow, max_tool_iterations: maxToolIterations, summarize_message_threshold: summarizeMessageThreshold, summarize_token_percent: summarizeTokenPercent, }, }, session: { dm_scope: dmScope, }, evolution: { enabled: form.evolutionEnabled, mode: form.evolutionMode, state_dir: form.evolutionStateDir.trim() === "" ? null : form.evolutionStateDir.trim(), min_task_count: evolutionMinTaskCount, min_success_ratio: evolutionMinSuccessRatio, cold_path_trigger: form.evolutionColdPathTrigger, cold_path_times: parseMultilineList( form.evolutionColdPathTimesText, ), }, tools: { cron: { allow_command: form.allowCommand, exec_timeout_minutes: cronExecTimeoutMinutes, }, exec: execConfigPatch, mcp: { enabled: form.mcpEnabled, discovery: mcpDiscoveryPatch, servers: mcpServersPatch, }, }, heartbeat: { enabled: form.heartbeatEnabled, interval: heartbeatInterval, }, devices: { enabled: form.devicesEnabled, monitor_usb: form.monitorUSB, }, }) setBaseline(form) queryClient.invalidateQueries({ queryKey: ["config"] }) } let savedLauncherForm: LauncherForm | null = null if (launcherSettingsDirty) { const port = parseIntField(launcherForm.port, "Service port", { min: 1, max: 65535, }) const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText) const savedLauncherConfig = await updateLauncherConfig({ port, public: launcherForm.publicAccess, allowed_cidrs: allowedCIDRs, }) const parsedLauncher: LauncherForm = { port: String(savedLauncherConfig.port), publicAccess: savedLauncherConfig.public, allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( "\n", ), dashboardPassword: "", dashboardPasswordConfirm: "", } savedLauncherForm = parsedLauncher setLauncherForm(parsedLauncher) setLauncherBaseline(parsedLauncher) queryClient.setQueryData( ["system", "launcher-config"], savedLauncherConfig, ) } if (launcherPasswordDirty) { const result = await postLauncherDashboardSetup(password, confirm) if (!result.ok) { throw new Error(result.error) } const clearedLauncherForm = savedLauncherForm ?? { ...launcherForm, dashboardPassword: "", dashboardPasswordConfirm: "", } setLauncherForm(clearedLauncherForm) if (savedLauncherForm) { setLauncherBaseline(savedLauncherForm) } } if (autoStartDirty) { if (!autoStartSupported) { throw new Error(t("pages.config.autostart_unsupported")) } const status = await updateAutoStartEnabled(autoStartEnabled) setAutoStartEnabled(status.enabled) setAutoStartBaseline(status.enabled) queryClient.setQueryData(["system", "autostart"], status) } const gateway = await refreshGatewayState({ force: true }) showSaveSuccessOrRestartToast( t, t("pages.config.save_success"), t("navigation.config"), gateway?.restartRequired === true, ) } catch (err) { toast.error( err instanceof Error ? err.message : t("pages.config.save_error"), ) } finally { setSaving(false) } } const actionButtons = (
) return (
{versionInfo.version} ) } children={ } />
{isLoading ? (
{t("labels.loading")}
) : error ? (
{t("pages.config.load_error")}
) : (
{!isDirty && actionButtons}
)}
{isDirty && (
{actionButtons}
)}
) }