2026-05-08 15:31:52 +00:00
import {
2026-05-19 22:42:38 +08:00
useCallback ,
useEffect ,
useMemo ,
useState ,
type Dispatch ,
type ReactNode ,
type SetStateAction ,
} from "react" ;
import {
Activity ,
2026-05-08 15:31:52 +00:00
Bot ,
Brain ,
Check ,
2026-05-19 22:42:38 +08:00
ChevronDown ,
ChevronLeft ,
ChevronRight ,
2026-05-08 15:31:52 +00:00
Cloud ,
Cpu ,
Database ,
Eye ,
EyeOff ,
Gem ,
2026-05-19 22:42:38 +08:00
Globe2 ,
2026-05-08 15:31:52 +00:00
Grid3X3 ,
2026-05-19 22:42:38 +08:00
HardDrive ,
2026-05-08 15:31:52 +00:00
Hexagon ,
2026-05-19 22:42:38 +08:00
ImageIcon ,
Info ,
2026-05-08 15:31:52 +00:00
Layers ,
2026-05-19 22:42:38 +08:00
Loader2 ,
LogOut ,
2026-05-08 15:31:52 +00:00
Moon ,
2026-05-22 22:25:12 +08:00
Package ,
PlayCircle ,
2026-05-24 13:38:37 +08:00
Plus ,
2026-05-08 15:31:52 +00:00
Orbit ,
2026-05-19 22:42:38 +08:00
Palette ,
Pencil ,
2026-05-08 15:31:52 +00:00
RotateCcw ,
2026-05-19 22:42:38 +08:00
Search ,
Server ,
ShieldCheck ,
SlidersHorizontal ,
2026-05-08 15:31:52 +00:00
Sparkles ,
2026-05-22 22:25:12 +08:00
Trash2 ,
2026-05-08 15:31:52 +00:00
Triangle ,
Waves ,
Zap ,
type LucideIcon ,
} from "lucide-react" ;
2026-05-06 23:15:18 +08:00
import { useTranslation } from "react-i18next" ;
2026-04-25 18:05:06 +00:00
import { LanguageSwitcher } from "@/components/LanguageSwitcher" ;
import { Button } from "@/components/ui/button" ;
2026-05-08 15:31:52 +00:00
import {
DropdownMenu ,
DropdownMenuContent ,
DropdownMenuItem ,
DropdownMenuTrigger ,
} from "@/components/ui/dropdown-menu" ;
2026-05-09 06:48:26 +00:00
import {
2026-05-24 13:38:37 +08:00
Dialog ,
DialogContent ,
DialogDescription ,
DialogFooter ,
DialogHeader ,
DialogTitle ,
} from "@/components/ui/dialog" ;
import { Input } from "@/components/ui/input" ;
import { Textarea } from "@/components/ui/textarea" ;
import {
createModelConfiguration ,
2026-05-09 06:48:26 +00:00
fetchSettings ,
2026-05-22 22:25:12 +08:00
fetchCliApps ,
2026-05-24 13:38:37 +08:00
fetchMcpPresets ,
importMcpConfig ,
2026-05-22 22:25:12 +08:00
runCliAppAction ,
2026-05-24 13:38:37 +08:00
runMcpPresetAction ,
saveCustomMcpServer ,
2026-05-19 22:42:38 +08:00
updateImageGenerationSettings ,
2026-05-24 13:38:37 +08:00
updateMcpServerTools ,
2026-05-09 06:48:26 +00:00
updateProviderSettings ,
updateSettings ,
updateWebSearchSettings ,
} from "@/lib/api" ;
2026-05-23 00:59:54 +08:00
import { notifyCliAppsChanged } from "@/lib/cli-app-events" ;
2026-05-24 13:38:37 +08:00
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events" ;
import {
logoFallbackUrls ,
providerBrand ,
providerDisplayLabel ,
} from "@/lib/provider-brand" ;
2026-04-25 18:05:06 +00:00
import { cn } from "@/lib/utils" ;
import { useClient } from "@/providers/ClientProvider" ;
2026-05-19 22:42:38 +08:00
import type {
2026-05-22 22:25:12 +08:00
CliAppInfo ,
CliAppsPayload ,
2026-05-19 22:42:38 +08:00
ImageGenerationSettingsUpdate ,
2026-05-24 13:38:37 +08:00
McpPresetInfo ,
McpPresetsPayload ,
2026-05-19 22:42:38 +08:00
SettingsPayload ,
WebSearchSettingsUpdate ,
} from "@/lib/types" ;
2026-04-25 18:05:06 +00:00
2026-05-19 22:42:38 +08:00
type SettingsSectionKey =
| "overview"
| "appearance"
| "models"
| "image"
| "web"
2026-05-22 22:25:12 +08:00
| "cliApps"
2026-05-24 13:38:37 +08:00
| "mcp"
2026-05-19 22:42:38 +08:00
| "runtime"
| "advanced" ;
type LocalDensity = "comfortable" | "compact" ;
type LocalActivityMode = "auto" | "expanded" ;
interface LocalPreferences {
density : LocalDensity ;
activityMode : LocalActivityMode ;
codeWrap : boolean ;
2026-05-22 22:25:12 +08:00
brandLogos : boolean ;
2026-05-19 22:42:38 +08:00
}
interface AgentSettingsDraft {
model : string ;
provider : string ;
modelPreset : string ;
timezone : string ;
botName : string ;
botIcon : string ;
toolHintMaxLength : number ;
}
2026-05-24 13:38:37 +08:00
interface ModelConfigurationDraft {
label : string ;
provider : string ;
model : string ;
}
2026-05-19 22:42:38 +08:00
type PendingRestartSection = "runtime" | "web" | "image" ;
type PendingRestartSections = Record < PendingRestartSection , boolean >;
2026-05-24 13:38:37 +08:00
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse" ;
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png" ;
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 ;
}
2026-05-19 22:42:38 +08:00
const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences" ;
const DEFAULT_LOCAL_PREFS : LocalPreferences = {
density : "comfortable" ,
activityMode : "auto" ,
codeWrap : true ,
2026-05-22 22:25:12 +08:00
brandLogos : true ,
2026-05-19 22:42:38 +08:00
};
2026-05-08 15:31:52 +00:00
2026-05-17 17:41:33 +08:00
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map (
[ "vllm" , "ollama" , "lm_studio" , "atomic_chat" , "ovms" ]. map (( name , index ) => [
name ,
index ,
]),
);
2026-05-19 22:42:38 +08:00
const IMAGE_ASPECT_RATIO_OPTIONS = [ "1:1" , "3:4" , "9:16" , "4:3" , "16:9" , "3:2" , "2:3" , "21:9" ];
const IMAGE_SIZE_OPTIONS = [ "1K" , "2K" , "4K" , "1024x1024" , "1536x1024" , "1024x1536" ];
const EMPTY_PENDING_RESTART_SECTIONS : PendingRestartSections = {
runtime : false ,
web : false ,
image : false ,
};
2026-05-24 13:38:37 +08:00
const DEFAULT_CUSTOM_MCP_FORM : CustomMcpForm = {
name : "" ,
transport : "stdio" ,
command : "" ,
args : "" ,
url : "" ,
env : "" ,
headers : "" ,
toolTimeout : "30" ,
};
2026-04-25 18:05:06 +00:00
interface SettingsViewProps {
theme : "light" | "dark" ;
onToggleTheme : () => void ;
onBackToChat : () => void ;
onModelNameChange : ( modelName : string | null ) => void ;
2026-05-06 23:15:18 +08:00
onLogout ?: () => void ;
2026-05-08 09:40:15 +00:00
onRestart ?: () => void ;
2026-05-08 15:31:52 +00:00
isRestarting? : boolean ;
2026-04-25 18:05:06 +00:00
}
2026-05-19 22:42:38 +08:00
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 ,
2026-05-22 22:25:12 +08:00
brandLogos : parsed.brandLogos !== false ,
2026-05-19 22:42:38 +08:00
};
} catch {
return DEFAULT_LOCAL_PREFS ;
}
}
function modelPresetValue ( payload : SettingsPayload ) : string {
return payload . agent . model_preset || "default" ;
}
function defaultPreset ( payload : SettingsPayload ) : SettingsPayload [ "model_presets" ][ number ] | null {
return payload . model_presets . find (( preset ) => preset . is_default ) ?? null ;
}
function editableDefaultProvider ( payload : SettingsPayload ) : string {
const base = defaultPreset ( payload );
return base ? . provider ?? payload . agent . provider ?? payload . agent . resolved_provider ?? "" ;
}
2026-04-25 18:05:06 +00:00
export function SettingsView ({
2026-05-08 15:31:52 +00:00
theme ,
onToggleTheme ,
2026-04-25 18:05:06 +00:00
onBackToChat ,
onModelNameChange ,
2026-05-06 23:15:18 +08:00
onLogout ,
2026-05-08 09:40:15 +00:00
onRestart ,
2026-05-08 15:31:52 +00:00
isRestarting = false ,
2026-04-25 18:05:06 +00:00
} : SettingsViewProps ) {
2026-05-08 15:31:52 +00:00
const { t } = useTranslation ();
2026-04-25 18:05:06 +00:00
const { token } = useClient ();
const [ settings , setSettings ] = useState < SettingsPayload | null >( null );
2026-05-22 22:25:12 +08:00
const [ cliApps , setCliApps ] = useState < CliAppsPayload | null >( null );
2026-05-24 13:38:37 +08:00
const [ mcpPresets , setMcpPresets ] = useState < McpPresetsPayload | null >( null );
2026-04-25 18:05:06 +00:00
const [ loading , setLoading ] = useState ( true );
2026-05-22 22:25:12 +08:00
const [ cliAppsLoading , setCliAppsLoading ] = useState ( true );
2026-05-24 13:38:37 +08:00
const [ mcpPresetsLoading , setMcpPresetsLoading ] = useState ( true );
2026-04-25 18:05:06 +00:00
const [ saving , setSaving ] = useState ( false );
2026-05-24 13:38:37 +08:00
const [ modelConfigurationOpen , setModelConfigurationOpen ] = useState ( false );
const [ modelConfigurationSaving , setModelConfigurationSaving ] = useState ( false );
const [ modelConfigurationForm , setModelConfigurationForm ] = useState < ModelConfigurationDraft >({
label : "" ,
provider : "" ,
model : "" ,
});
2026-05-22 22:25:12 +08:00
const [ cliAppsAction , setCliAppsAction ] = useState < string | null >( null );
2026-05-24 13:38:37 +08:00
const [ mcpPresetAction , setMcpPresetAction ] = useState < string | null >( null );
2026-05-08 15:31:52 +00:00
const [ providerSaving , setProviderSaving ] = useState < string | null >( null );
2026-05-09 06:48:26 +00:00
const [ webSearchSaving , setWebSearchSaving ] = useState ( false );
2026-05-19 22:42:38 +08:00
const [ imageGenerationSaving , setImageGenerationSaving ] = useState ( false );
2026-04-25 18:05:06 +00:00
const [ error , setError ] = useState < string | null >( null );
2026-05-19 22:42:38 +08:00
const [ activeSection , setActiveSection ] = useState < SettingsSectionKey >( "overview" );
2026-05-08 15:31:52 +00:00
const [ expandedProvider , setExpandedProvider ] = useState < string | null >( null );
2026-05-19 22:42:38 +08:00
const [ providerQuery , setProviderQuery ] = useState ( "" );
2026-05-22 22:25:12 +08:00
const [ cliAppsQuery , setCliAppsQuery ] = useState ( "" );
const [ cliAppsCategory , setCliAppsCategory ] = useState ( "all" );
const [ cliAppsInstallFilter , setCliAppsInstallFilter ] = useState < "all" | "installed" | "notInstalled" > ( "all" );
const [ cliAppsMessage , setCliAppsMessage ] = useState < string | null >( null );
const [ cliAppsError , setCliAppsError ] = useState < string | null >( null );
const [ cliAppsFocusName , setCliAppsFocusName ] = useState < string | null >( null );
2026-05-24 13:38:37 +08:00
const [ mcpQuery , setMcpQuery ] = useState ( "" );
const [ mcpCategory , setMcpCategory ] = useState ( "all" );
const [ mcpInstallFilter , setMcpInstallFilter ] = useState < "all" | "installed" | "notInstalled" > ( "all" );
const [ mcpMessage , setMcpMessage ] = useState < string | null >( null );
const [ mcpError , setMcpError ] = useState < string | null >( null );
const [ mcpFieldValues , setMcpFieldValues ] = useState < Record < string , Record < string , string >>>({});
const [ customMcpForm , setCustomMcpForm ] = useState < CustomMcpForm >( DEFAULT_CUSTOM_MCP_FORM );
const [ mcpConfigImport , setMcpConfigImport ] = useState ( "" );
2026-05-08 15:31:52 +00:00
const [ providerForms , setProviderForms ] = useState < Record < string , { apiKey : string ; apiBase : string }>>({});
const [ visibleProviderKeys , setVisibleProviderKeys ] = useState < Record < string , boolean >>({});
const [ editingProviderKeys , setEditingProviderKeys ] = useState < Record < string , boolean >>({});
2026-05-19 22:42:38 +08:00
const [ pendingRestartSections , setPendingRestartSections ] = useState < PendingRestartSections >(
EMPTY_PENDING_RESTART_SECTIONS ,
);
const [ localPrefs , setLocalPrefs ] = useState < LocalPreferences >(() => readLocalPreferences ());
2026-05-09 06:48:26 +00:00
const [ webSearchForm , setWebSearchForm ] = useState < WebSearchSettingsUpdate >({
provider : "duckduckgo" ,
apiKey : "" ,
baseUrl : "" ,
2026-05-19 22:42:38 +08:00
maxResults : 5 ,
timeout : 30 ,
useJinaReader : true ,
});
const [ imageGenerationForm , setImageGenerationForm ] = useState < ImageGenerationSettingsUpdate >({
enabled : false ,
provider : "openrouter" ,
model : "openai/gpt-5.4-image-2" ,
defaultAspectRatio : "1:1" ,
defaultImageSize : "1K" ,
maxImagesPerTurn : 4 ,
2026-05-09 06:48:26 +00:00
});
const [ webSearchKeyVisible , setWebSearchKeyVisible ] = useState ( false );
const [ webSearchKeyEditing , setWebSearchKeyEditing ] = useState ( false );
2026-05-19 22:42:38 +08:00
const [ form , setForm ] = useState < AgentSettingsDraft >({
2026-04-25 18:05:06 +00:00
model : "" ,
2026-05-08 15:31:52 +00:00
provider : "" ,
2026-05-19 22:42:38 +08:00
modelPreset : "default" ,
timezone : "UTC" ,
botName : "nanobot" ,
botIcon : "" ,
toolHintMaxLength : 40 ,
2026-04-25 18:05:06 +00:00
});
2026-05-19 22:42:38 +08:00
const text = useCallback (
( key : string , fallback : string , options? : Record < string , unknown >) =>
t ( key , { defaultValue : fallback , ...( options ?? {}) }),
[ t ],
);
2026-04-25 18:05:06 +00:00
const applyPayload = useCallback (( payload : SettingsPayload ) => {
2026-05-19 22:42:38 +08:00
const fallbackDefault = defaultPreset ( payload );
2026-04-25 18:05:06 +00:00
setSettings ( payload );
setForm ({
2026-05-19 22:42:38 +08:00
model : fallbackDefault?.model ?? payload . agent . model ,
provider : editableDefaultProvider ( payload ),
modelPreset : modelPresetValue ( payload ),
timezone : payload.agent.timezone ,
botName : payload.agent.bot_name ,
botIcon : payload.agent.bot_icon ,
toolHintMaxLength : payload.agent.tool_hint_max_length ,
2026-04-25 18:05:06 +00:00
});
2026-05-09 06:48:26 +00:00
setWebSearchForm (( prev ) => ({
provider : payload.web_search.provider ,
apiKey : prev.provider === payload . web_search . provider ? prev . apiKey ?? "" : "" ,
baseUrl : payload.web_search.base_url ?? "" ,
2026-05-19 22:42:38 +08:00
maxResults : payload.web_search.max_results ,
timeout : payload.web_search.timeout ,
useJinaReader : payload.web.fetch.use_jina_reader ,
2026-05-09 06:48:26 +00:00
}));
2026-05-19 22:42:38 +08:00
setImageGenerationForm ({
enabled : payload.image_generation.enabled ,
provider : payload.image_generation.provider ,
model : payload.image_generation.model ,
defaultAspectRatio : payload.image_generation.default_aspect_ratio ,
defaultImageSize : payload.image_generation.default_image_size ,
maxImagesPerTurn : payload.image_generation.max_images_per_turn ,
});
if ( payload . restart_required_sections ) {
setPendingRestartSections ({
runtime : payload.restart_required_sections.includes ( "runtime" ),
web : payload.restart_required_sections.includes ( "web" ),
image : payload.restart_required_sections.includes ( "image" ),
});
}
2026-04-25 18:05:06 +00:00
}, []);
useEffect (() => {
let cancelled = false ;
setLoading ( true );
fetchSettings ( token )
. then (( payload ) => {
if ( ! cancelled ) {
applyPayload ( payload );
setError ( null );
}
})
. catch (( err ) => {
if ( ! cancelled ) setError (( err as Error ). message );
})
. finally (() => {
if ( ! cancelled ) setLoading ( false );
});
return () => {
cancelled = true ;
};
}, [ applyPayload , token ]);
2026-05-22 22:25:12 +08:00
useEffect (() => {
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 ;
};
}, [ token ]);
2026-05-24 13:38:37 +08:00
useEffect (() => {
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 ;
};
}, [ token ]);
2026-05-19 22:42:38 +08:00
useEffect (() => {
try {
window . localStorage . setItem ( LOCAL_PREFS_STORAGE_KEY , JSON . stringify ( localPrefs ));
} catch {
// Browser-only preferences should never block settings.
}
}, [ localPrefs ]);
2026-05-08 15:31:52 +00:00
useEffect (() => {
if ( ! settings ) return ;
setProviderForms (( prev ) => {
const next = { ... prev };
for ( const provider of settings . providers ) {
next [ provider . name ] = {
apiKey : next [ provider . name ] ? . apiKey ?? "" ,
apiBase : next [ provider . name ] ? . apiBase ?? provider . api_base ?? provider . default_api_base ?? "" ,
};
}
return next ;
});
}, [ settings ]);
2026-05-19 22:42:38 +08:00
const modelDirty = useMemo (() => {
2026-04-25 18:05:06 +00:00
if ( ! settings ) return false ;
2026-05-19 22:42:38 +08:00
const preset = modelPresetValue ( settings );
const base = defaultPreset ( settings );
2026-04-25 18:05:06 +00:00
return (
2026-05-19 22:42:38 +08:00
form . modelPreset !== preset ||
( form . modelPreset === "default" &&
( form . model !== ( base ? . model ?? settings . agent . model ) ||
form . provider !== editableDefaultProvider ( settings )))
2026-04-25 18:05:06 +00:00
);
}, [ form , settings ]);
2026-05-19 22:42:38 +08:00
const runtimeDirty = useMemo (() => {
if ( ! settings ) return false ;
return (
form . timezone !== settings . agent . timezone ||
form . botName !== settings . agent . bot_name ||
form . botIcon !== settings . agent . bot_icon ||
form . toolHintMaxLength !== settings . agent . tool_hint_max_length
);
}, [ form , settings ]);
const imageGenerationDirty = useMemo (() => {
if ( ! settings ) return false ;
return (
imageGenerationForm . enabled !== settings . image_generation . enabled ||
imageGenerationForm . provider !== settings . image_generation . provider ||
imageGenerationForm . model !== settings . image_generation . model ||
imageGenerationForm . defaultAspectRatio !== settings . image_generation . default_aspect_ratio ||
imageGenerationForm . defaultImageSize !== settings . image_generation . default_image_size ||
imageGenerationForm . maxImagesPerTurn !== settings . image_generation . max_images_per_turn
);
}, [ imageGenerationForm , settings ]);
2026-05-24 13:38:37 +08:00
const configuredModelProviderOptions = useMemo (
() =>
settings ? . providers
. filter (( provider ) => provider . configured )
. map (( provider ) => ({ name : provider.name , label : provider.label })) ?? [],
[ settings ],
);
2026-05-19 22:42:38 +08:00
const hasPendingRestart = useMemo (
() =>
!! settings ? . requires_restart ||
pendingRestartSections . runtime ||
pendingRestartSections . web ||
pendingRestartSections . image ,
[ pendingRestartSections , settings ? . requires_restart ],
);
const saveModelSettings = async () => {
if ( ! settings || ! modelDirty || saving ) return ;
2026-04-25 18:05:06 +00:00
setSaving ( true );
try {
2026-05-19 22:42:38 +08:00
const defaultModel = defaultPreset ( settings ) ? . model ?? settings . agent . model ;
const defaultProvider = editableDefaultProvider ( settings );
2026-05-08 15:31:52 +00:00
const payload = await updateSettings ( token , {
2026-05-19 22:42:38 +08:00
modelPreset : form.modelPreset ,
...( form . modelPreset === "default" && form . model !== defaultModel ? { model : form.model } : {}),
...( form . modelPreset === "default" && form . provider !== defaultProvider ? { provider : form.provider } : {}),
2026-05-08 15:31:52 +00:00
});
2026-04-25 18:05:06 +00:00
applyPayload ( payload );
onModelNameChange ( payload . agent . model || null );
setError ( null );
} catch ( err ) {
setError (( err as Error ). message );
} finally {
setSaving ( false );
}
};
2026-05-24 13:38:37 +08:00
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 );
}
};
2026-05-19 22:42:38 +08:00
const saveRuntimeSettings = async () => {
if ( ! settings || ! runtimeDirty || saving ) return ;
setSaving ( true );
try {
const payload = await updateSettings ( token , {
timezone : form.timezone ,
botName : form.botName ,
botIcon : form.botIcon ,
toolHintMaxLength : form.toolHintMaxLength ,
});
applyPayload ( payload );
if ( payload . requires_restart ) {
setPendingRestartSections (( prev ) => ({ ... prev , runtime : true }));
}
setError ( null );
} catch ( err ) {
setError (( err as Error ). message );
} finally {
setSaving ( false );
}
};
const saveImageGenerationSettings = async () => {
if ( ! settings || ! imageGenerationDirty || imageGenerationSaving ) return ;
setImageGenerationSaving ( true );
try {
const payload = await updateImageGenerationSettings ( token , imageGenerationForm );
applyPayload ( payload );
if ( payload . requires_restart ) {
setPendingRestartSections (( prev ) => ({ ... prev , image : true }));
}
setError ( null );
} catch ( err ) {
setError (( err as Error ). message );
} finally {
setImageGenerationSaving ( false );
}
};
2026-05-08 15:31:52 +00:00
const saveProvider = async ( providerName : string ) => {
if ( providerSaving ) return ;
const provider = settings ? . providers . find (( item ) => item . name === providerName );
if ( ! provider ) return ;
const providerForm = providerForms [ providerName ] ?? { apiKey : "" , apiBase : "" };
const apiKey = providerForm . apiKey . trim ();
2026-05-17 17:41:33 +08:00
const apiKeyRequired = provider . api_key_required ?? true ;
if ( ! provider . configured && apiKeyRequired && ! apiKey ) {
2026-05-08 15:31:52 +00:00
setError ( t ( "settings.byok.apiKeyRequired" ));
return ;
}
setProviderSaving ( providerName );
try {
const payload = await updateProviderSettings ( token , {
provider : providerName ,
apiKey : apiKey || undefined ,
apiBase : providerForm.apiBase.trim (),
});
applyPayload ( payload );
2026-05-19 22:42:38 +08:00
if ( payload . requires_restart ) {
setPendingRestartSections (( prev ) => ({ ... prev , image : true }));
}
2026-05-08 15:31:52 +00:00
setProviderForms (( prev ) => ({
... prev ,
[ providerName ] : {
apiKey : "" ,
apiBase : providerForm.apiBase.trim (),
},
}));
setVisibleProviderKeys (( prev ) => ({ ... prev , [ providerName ] : false }));
setEditingProviderKeys (( prev ) => ({ ... prev , [ providerName ] : false }));
setError ( null );
} catch ( err ) {
setError (( err as Error ). message );
} finally {
setProviderSaving ( null );
}
};
2026-05-09 06:48:26 +00:00
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 {
2026-05-19 22:42:38 +08:00
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 ,
};
2026-05-09 06:48:26 +00:00
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 );
2026-05-19 22:42:38 +08:00
if ( payload . requires_restart || webFetchRestartRequired ) {
setPendingRestartSections (( prev ) => ({ ... prev , web : true }));
}
2026-05-09 06:48:26 +00:00
setWebSearchForm (( prev ) => ({
provider : payload.web_search.provider ,
apiKey : "" ,
baseUrl : payload.web_search.base_url ?? prev . baseUrl ?? "" ,
2026-05-19 22:42:38 +08:00
maxResults : payload.web_search.max_results ,
timeout : payload.web_search.timeout ,
useJinaReader : payload.web.fetch.use_jina_reader ,
2026-05-09 06:48:26 +00:00
}));
setWebSearchKeyVisible ( false );
setWebSearchKeyEditing ( false );
setError ( null );
} catch ( err ) {
setError (( err as Error ). message );
} finally {
setWebSearchSaving ( false );
}
};
const resetProviderDraft = useCallback (( providerName : string ) => {
const provider = settings ? . providers . find (( item ) => item . name === providerName );
if ( ! provider ) return ;
setProviderForms (( prev ) => ({
... prev ,
[ providerName ] : {
apiKey : "" ,
apiBase : provider.api_base ?? provider . default_api_base ?? "" ,
},
}));
setVisibleProviderKeys (( prev ) => ({ ... prev , [ providerName ] : false }));
setEditingProviderKeys (( prev ) => ({ ... prev , [ providerName ] : false }));
}, [ settings ]);
const handleToggleProvider = useCallback (( providerName : string ) => {
if ( expandedProvider ) resetProviderDraft ( expandedProvider );
setExpandedProvider ( expandedProvider === providerName ? null : providerName );
}, [ expandedProvider , resetProviderDraft ]);
const resetWebSearchDraft = useCallback (() => {
if ( ! settings ) return ;
setWebSearchForm ({
provider : settings.web_search.provider ,
apiKey : "" ,
baseUrl : settings.web_search.base_url ?? "" ,
2026-05-19 22:42:38 +08:00
maxResults : settings.web_search.max_results ,
timeout : settings.web_search.timeout ,
useJinaReader : settings.web.fetch.use_jina_reader ,
2026-05-09 06:48:26 +00:00
});
setWebSearchKeyVisible ( false );
setWebSearchKeyEditing ( false );
}, [ settings ]);
const handleWebSearchProviderChange = useCallback (( provider : string ) => {
if ( ! settings ) return ;
2026-05-19 22:42:38 +08:00
setWebSearchForm (( prev ) => ({
2026-05-09 06:48:26 +00:00
provider ,
apiKey : "" ,
baseUrl : provider === settings . web_search . provider ? settings . web_search . base_url ?? "" : "" ,
2026-05-19 22:42:38 +08:00
maxResults : prev.maxResults ?? settings . web_search . max_results ,
timeout : prev.timeout ?? settings . web_search . timeout ,
useJinaReader : prev.useJinaReader ?? settings . web . fetch . use_jina_reader ,
}));
2026-05-09 06:48:26 +00:00
setWebSearchKeyVisible ( false );
setWebSearchKeyEditing ( false );
}, [ settings ]);
2026-05-08 15:31:52 +00:00
const toggleProviderKeyVisibility = ( providerName : string ) => {
const isVisible = visibleProviderKeys [ providerName ];
setVisibleProviderKeys (( prev ) => ({ ... prev , [ providerName ] : ! isVisible }));
};
const toggleProviderKeyEditing = ( providerName : string ) => {
setEditingProviderKeys (( prev ) => {
const nextEditing = ! prev [ providerName ];
if ( ! nextEditing ) {
setProviderForms (( forms ) => ({
... forms ,
[ providerName ] : {
apiKey : "" ,
apiBase : forms [ providerName ] ? . apiBase ?? "" ,
},
}));
setVisibleProviderKeys (( visible ) => ({ ... visible , [ providerName ] : false }));
}
return { ... prev , [ providerName ] : nextEditing };
});
};
2026-05-22 22:25:12 +08:00
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 );
2026-05-23 00:59:54 +08:00
if ( action !== "test" ) {
notifyCliAppsChanged ( payload );
}
2026-05-22 22:25:12 +08:00
setCliAppsMessage ( payload . last_action ? . message ?? null );
setCliAppsFocusName ( action === "uninstall" ? null : name );
} catch ( err ) {
setCliAppsError (( err as Error ). message );
} finally {
setCliAppsAction ( null );
}
};
2026-05-24 13:38:37 +08:00
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 }));
}
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 }));
}
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 }));
}
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 }));
}
} catch ( err ) {
setMcpError (( err as Error ). message );
} finally {
setMcpPresetAction ( null );
}
};
2026-05-19 22:42:38 +08:00
const renderSection = () => {
if ( ! settings ) return null ;
switch ( activeSection ) {
case "overview" :
return (
< OverviewSettings
settings = { settings }
requiresRestart = { hasPendingRestart }
onRestart = { onRestart }
isRestarting = { isRestarting }
2026-05-24 13:38:37 +08:00
showBrandLogos = { localPrefs . brandLogos }
2026-05-19 22:42:38 +08:00
onSelectSection = { setActiveSection }
/>
);
case "appearance" :
return (
< AppearanceSettings
theme = { theme }
onToggleTheme = { onToggleTheme }
localPrefs = { localPrefs }
onChangeLocalPrefs = { setLocalPrefs }
/>
);
case "models" :
return (
2026-05-24 13:38:37 +08:00
< div className = "space-y-8" >
< ModelsSettings
form = { form }
setForm = { setForm }
settings = { settings }
dirty = { modelDirty }
saving = { saving }
showBrandLogos = { localPrefs . brandLogos }
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 ?? "" ,
... value ,
},
}))
}
onSaveProvider = { saveProvider }
onResetProviderDraft = { resetProviderDraft }
imageProviderRestartPending = { pendingRestartSections . image }
onRestart = { onRestart }
isRestarting = { isRestarting }
/>
</ div >
2026-05-19 22:42:38 +08:00
);
case "image" :
return (
< ImageGenerationSettings
settings = { settings }
form = { imageGenerationForm }
dirty = { imageGenerationDirty }
saving = { imageGenerationSaving }
onChangeForm = { setImageGenerationForm }
onSave = { saveImageGenerationSettings }
2026-05-24 13:38:37 +08:00
onOpenProviders = {() => setActiveSection ( "models" )}
showBrandLogos = { localPrefs . brandLogos }
2026-05-19 22:42:38 +08:00
onRestart = { onRestart }
isRestarting = { isRestarting }
requiresRestartPending = { pendingRestartSections . image }
/>
);
case "web" :
return (
< WebSettings
settings = { settings }
form = { webSearchForm }
keyVisible = { webSearchKeyVisible }
keyEditing = { webSearchKeyEditing }
saving = { webSearchSaving }
onChangeForm = { setWebSearchForm }
onChangeProvider = { handleWebSearchProviderChange }
onToggleKey = {() => setWebSearchKeyVisible (( visible ) => ! visible )}
onToggleKeyEditing = {() => {
setWebSearchKeyEditing (( editing ) => ! editing );
setWebSearchKeyVisible ( false );
setWebSearchForm (( prev ) => ({ ... prev , apiKey : "" }));
}}
onReset = { resetWebSearchDraft }
onSave = { saveWebSearch }
2026-05-24 13:38:37 +08:00
showBrandLogos = { localPrefs . brandLogos }
2026-05-19 22:42:38 +08:00
onRestart = { onRestart }
isRestarting = { isRestarting }
requiresRestartPending = { pendingRestartSections . web }
/>
);
2026-05-22 22:25:12 +08:00
case "cliApps" :
return (
< CliAppsSettings
payload = { cliApps }
loading = { cliAppsLoading }
query = { cliAppsQuery }
category = { cliAppsCategory }
installFilter = { cliAppsInstallFilter }
actionKey = { cliAppsAction }
message = { cliAppsMessage }
error = { cliAppsError }
focusName = { cliAppsFocusName }
showBrandLogos = { localPrefs . brandLogos }
onQueryChange = { setCliAppsQuery }
onCategoryChange = { setCliAppsCategory }
onInstallFilterChange = { setCliAppsInstallFilter }
onAction = { handleCliAppAction }
onBackToChat = { onBackToChat }
/>
);
2026-05-24 13:38:37 +08:00
case "mcp" :
return (
< McpPresetsSettings
payload = { mcpPresets }
loading = { mcpPresetsLoading }
query = { mcpQuery }
category = { mcpCategory }
installFilter = { mcpInstallFilter }
actionKey = { mcpPresetAction }
message = { mcpMessage }
error = { mcpError }
fieldValues = { mcpFieldValues }
customForm = { customMcpForm }
configImport = { mcpConfigImport }
showBrandLogos = { localPrefs . brandLogos }
requiresRestartPending = { pendingRestartSections . runtime }
onQueryChange = { setMcpQuery }
onCategoryChange = { setMcpCategory }
onInstallFilterChange = { setMcpInstallFilter }
onCustomFormChange = { setCustomMcpForm }
onConfigImportChange = { setMcpConfigImport }
onFieldChange = {( presetName , fieldName , value ) => {
setMcpFieldValues (( prev ) => ({
... prev ,
[ presetName ] : {
...( prev [ presetName ] ?? {}),
[ fieldName ] : value ,
},
}));
}}
onAction = { handleMcpPresetAction }
onSaveCustom = { handleSaveCustomMcp }
onImportConfig = { handleImportMcpConfig }
onToolsChange = { handleMcpToolsChange }
onRestart = { onRestart }
isRestarting = { isRestarting }
/>
);
2026-05-19 22:42:38 +08:00
case "runtime" :
return (
< RuntimeSettings
form = { form }
setForm = { setForm }
settings = { settings }
dirty = { runtimeDirty }
saving = { saving }
onSave = { saveRuntimeSettings }
onRestart = { onRestart }
isRestarting = { isRestarting }
requiresRestartPending = { pendingRestartSections . runtime }
/>
);
case "advanced" :
return < AdvancedSettings settings = { settings } />;
default :
return null ;
}
};
2026-04-25 18:05:06 +00:00
return (
2026-05-19 22:42:38 +08:00
< 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" >
2026-05-08 15:31:52 +00:00
< SettingsSidebar
activeSection = { activeSection }
onSelectSection = { setActiveSection }
onBackToChat = { onBackToChat }
onLogout = { onLogout }
/>
2026-04-25 18:05:06 +00:00
2026-05-24 13:38:37 +08:00
< NewModelConfigurationDialog
open = { modelConfigurationOpen }
draft = { modelConfigurationForm }
providers = { configuredModelProviderOptions }
saving = { modelConfigurationSaving }
showProviderLogos = { localPrefs . brandLogos }
onOpenChange = { setModelConfigurationOpen }
onChangeDraft = { setModelConfigurationForm }
onSave = { handleCreateModelConfiguration }
/>
2026-05-09 06:48:26 +00:00
< main className = "min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]" >
2026-05-19 22:42:38 +08:00
< div className = "mx-auto w-full max-w-[920px] px-5 py-8 sm:px-8 lg:py-12" >
< div className = "mb-7" >
2026-05-08 15:31:52 +00:00
< p className = "mb-2 text-[13px] font-medium text-muted-foreground" >
{ t ( "settings.sidebar.title" )}
</ p >
2026-05-19 22:42:38 +08:00
< h1 className = "text-[28px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[34px]" >
{ text ( `settings.nav. ${ activeSection } ` , titleForSection ( activeSection ))}
2026-05-08 15:31:52 +00:00
</ h1 >
2026-04-25 18:05:06 +00:00
</ div >
2026-05-08 15:31:52 +00:00
{ 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 }
2026-05-19 22:42:38 +08:00
{ renderSection ()}
2026-05-08 15:31:52 +00:00
</ div >
) : null }
</ div >
2026-04-25 18:05:06 +00:00
</ main >
</ div >
);
}
2026-05-19 22:42:38 +08:00
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 : "web" , icon : Globe2 , fallback : "Web" },
2026-05-22 22:25:12 +08:00
{ key : "cliApps" , icon : Package , fallback : "CLI Apps" },
2026-05-24 13:38:37 +08:00
{ key : "mcp" , icon : Layers , fallback : "MCP" },
2026-05-19 22:42:38 +08:00
{ key : "runtime" , icon : Server , fallback : "Runtime" },
{ key : "advanced" , icon : ShieldCheck , fallback : "Advanced" },
];
function titleForSection ( section : SettingsSectionKey ) : string {
return SETTINGS_NAV_ITEMS . find (( item ) => item . key === section ) ? . fallback ?? "Settings" ;
}
2026-05-08 15:31:52 +00:00
function SettingsSidebar ({
activeSection ,
onSelectSection ,
onBackToChat ,
onLogout ,
} : {
activeSection : SettingsSectionKey ;
onSelectSection : ( section : SettingsSectionKey ) => void ;
onBackToChat : () => void ;
onLogout ?: () => void ;
}) {
const { t } = useTranslation ();
return (
2026-05-19 22:42:38 +08:00
< aside className = "flex w-full shrink-0 flex-col border-b border-border/55 bg-card/62 px-4 pb-3 pt-4 shadow-[inset_0_-1px_0_rgba(255,255,255,0.55)] backdrop-blur-xl dark:bg-card/45 dark:shadow-none md:w-[17rem] md:border-b-0 md:border-r md:px-3 md:py-4 md:shadow-[inset_-1px_0_0_rgba(255,255,255,0.55)]" >
2026-05-08 15:31:52 +00:00
< button
type = "button"
onClick = { onBackToChat }
2026-05-19 22:42:38 +08:00
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"
2026-05-08 15:31:52 +00:00
>
< ChevronLeft className = "h-3.5 w-3.5" aria - hidden />
{ t ( "settings.backToChat" )}
</ button >
2026-05-19 22:42:38 +08:00
< div className = "mb-3 px-1 md:mb-4 md:px-2" >
< h2 className = "text-[21px] font-semibold tracking-[-0.02em] text-foreground" >
2026-05-08 15:31:52 +00:00
{ t ( "settings.sidebar.title" )}
</ h2 >
</ div >
2026-05-19 22:42:38 +08:00
< 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 }) => {
2026-05-08 15:31:52 +00:00
const active = key === activeSection ;
return (
< button
key = { key }
type = "button"
aria-current = { active ? "page" : undefined }
onClick = {() => onSelectSection ( key )}
className = { cn (
2026-05-19 22:42:38 +08:00
"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" ,
2026-05-08 15:31:52 +00:00
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 />
2026-05-19 22:42:38 +08:00
< span className = "truncate" >{ t ( `settings.nav. ${ key } ` , { defaultValue : fallback })}</ span >
2026-05-08 15:31:52 +00:00
</ button >
);
})}
</ nav >
2026-05-19 22:42:38 +08:00
< div className = "hidden md:mt-auto md:block md:pt-4" >
2026-05-08 15:31:52 +00:00
{ onLogout ? (
< Button
type = "button"
variant = "ghost"
onClick = { onLogout }
className = "h-9 w-full justify-start gap-2 rounded-[10px] px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
>
< LogOut className = "h-4 w-4" aria - hidden />
{ t ( "app.account.logout" )}
</ Button >
) : null }
</ div >
</ aside >
);
}
2026-05-19 22:42:38 +08:00
function OverviewSettings ({
2026-04-25 18:05:06 +00:00
settings ,
2026-05-19 22:42:38 +08:00
requiresRestart ,
2026-05-08 09:40:15 +00:00
onRestart ,
2026-05-08 15:31:52 +00:00
isRestarting ,
2026-05-19 22:42:38 +08:00
onSelectSection ,
2026-05-24 13:38:37 +08:00
showBrandLogos ,
2026-05-19 22:42:38 +08:00
} : {
settings : SettingsPayload ;
requiresRestart : boolean ;
onRestart ?: () => void ;
isRestarting? : boolean ;
onSelectSection : ( section : SettingsSectionKey ) => void ;
2026-05-24 13:38:37 +08:00
showBrandLogos : boolean ;
2026-05-19 22:42:38 +08:00
}) {
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 webStatus = settings . web . enable
? tx ( "settings.values.enabled" , "Enabled" )
: tx ( "settings.values.disabled" , "Disabled" );
const imageStatus = settings . image_generation . enabled
? tx ( "settings.values.enabled" , "Enabled" )
: tx ( "settings.values.disabled" , "Disabled" );
2026-05-24 13:38:37 +08:00
const imageCaption = ` ${ providerDisplayLabel ( settings . image_generation . providers , settings . image_generation . provider ) } · ${
2026-05-19 22:42:38 +08:00
settings . image_generation . provider_configured
? tx ( "settings.values.configured" , "Configured" )
: tx ( "settings.values.notConfigured" , "Not configured" )
} ` ;
return (
< div className = "space-y-7" >
< section >
< div className = "overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.075)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.24)]" >
< div className = "flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between" >
< div className = "flex min-w-0 items-center gap-3" >
2026-05-24 13:38:37 +08:00
< NanobotBrandLogo size = "lg" testId = "overview-nanobot-logo" />
2026-05-19 22:42:38 +08:00
< div className = "min-w-0" >
< div className = "text-[12px] font-medium text-muted-foreground" > nanobot </ div >
< div className = "mt-0.5 truncate text-[18px] font-semibold leading-6 text-foreground" >
{ settings . agent . model }
</ div >
< div className = "mt-0.5 truncate text-[13px] leading-5 text-muted-foreground" >
{ activeProvider } · { activePreset }
</ div >
</ div >
</ div >
< div className = "flex flex-wrap items-center gap-2 sm:justify-end" >
< StatusPill tone = { requiresRestart ? "neutral" : "success" }>
{ requiresRestart
? tx ( "settings.values.restartPending" , "Restart pending" )
: tx ( "settings.values.ready" , "Ready" )}
</ StatusPill >
{ requiresRestart && onRestart ? (
< Button
size = "sm"
variant = "ghost"
onClick = { onRestart }
disabled = { isRestarting }
className = "rounded-full"
>
{ isRestarting ? (
< Loader2 className = "mr-1.5 h-3.5 w-3.5 animate-spin" aria - hidden />
) : (
< RotateCcw className = "mr-1.5 h-3.5 w-3.5" aria - hidden />
)}
{ isRestarting ? t ( "app.system.restarting" ) : t ( "app.system.restart" )}
</ Button >
) : null }
</ div >
</ div >
</ div >
</ section >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.ai" , "AI" )}</ SettingsSectionTitle >
< SettingsGroup >
< OverviewListRow
icon = { Bot }
2026-05-24 13:38:37 +08:00
valueLogoProvider = { activeProvider }
2026-05-19 22:42:38 +08:00
title = { tx ( "settings.overview.model" , "Current model" )}
value = { settings . agent . model }
caption = { ` ${ activeProvider } · ${ activePreset } ` }
2026-05-24 13:38:37 +08:00
showBrandLogos = { showBrandLogos }
2026-05-19 22:42:38 +08:00
onClick = {() => onSelectSection ( "models" )}
/>
</ SettingsGroup >
</ section >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.capabilities" , "Capabilities" )}</ SettingsSectionTitle >
< SettingsGroup >
< OverviewListRow
icon = { Globe2 }
2026-05-24 13:38:37 +08:00
valueLogoProvider = { settings . web_search . provider }
2026-05-19 22:42:38 +08:00
title = { tx ( "settings.overview.webSearch" , "Web search" )}
2026-05-24 13:38:37 +08:00
value = { providerDisplayLabel ( settings . web_search . providers , settings . web_search . provider )}
2026-05-19 22:42:38 +08:00
caption = { webStatus }
2026-05-24 13:38:37 +08:00
showBrandLogos = { showBrandLogos }
2026-05-19 22:42:38 +08:00
onClick = {() => onSelectSection ( "web" )}
/>
< OverviewListRow
icon = { ImageIcon }
2026-05-24 13:38:37 +08:00
valueLogoProvider = { settings . image_generation . provider }
2026-05-19 22:42:38 +08:00
title = { tx ( "settings.overview.imageGeneration" , "Image generation" )}
value = { imageStatus }
caption = { imageCaption }
2026-05-24 13:38:37 +08:00
showBrandLogos = { showBrandLogos }
2026-05-19 22:42:38 +08:00
onClick = {() => onSelectSection ( "image" )}
/>
</ SettingsGroup >
</ section >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.system" , "System" )}</ SettingsSectionTitle >
< SettingsGroup >
< OverviewListRow
icon = { Server }
title = { tx ( "settings.rows.gateway" , "Gateway" )}
value = { ` ${ settings . runtime . gateway_host } : ${ settings . runtime . gateway_port } ` }
caption = {
requiresRestart
? tx ( "settings.values.restartPending" , "Restart pending" )
: tx ( "settings.values.ready" , "Ready" )
}
onClick = {() => onSelectSection ( "runtime" )}
/>
< OverviewListRow
icon = { HardDrive }
title = { tx ( "settings.overview.workspace" , "Workspace" )}
value = { settings . runtime . workspace_path }
caption = { settings . runtime . config_path }
onClick = {() => onSelectSection ( "runtime" )}
/>
</ SettingsGroup >
</ section >
</ div >
);
}
function AppearanceSettings ({
theme ,
onToggleTheme ,
localPrefs ,
onChangeLocalPrefs ,
2026-04-25 18:05:06 +00:00
} : {
2026-05-08 15:31:52 +00:00
theme : "light" | "dark" ;
onToggleTheme : () => void ;
2026-05-19 22:42:38 +08:00
localPrefs : LocalPreferences ;
onChangeLocalPrefs : Dispatch < SetStateAction < LocalPreferences >>;
2026-04-25 18:05:06 +00:00
}) {
2026-05-06 23:15:18 +08:00
const { t } = useTranslation ();
2026-05-19 22:42:38 +08:00
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
2026-04-25 18:05:06 +00:00
return (
2026-05-19 22:42:38 +08:00
< div className = "space-y-7" >
2026-04-25 18:05:06 +00:00
< section >
2026-05-08 15:31:52 +00:00
< SettingsSectionTitle >{ t ( "settings.sections.interface" )}</ SettingsSectionTitle >
2026-04-25 18:05:06 +00:00
< SettingsGroup >
2026-05-08 15:31:52 +00:00
< 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"
2026-04-25 18:05:06 +00:00
>
2026-05-08 15:31:52 +00:00
< 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 >
2026-04-25 18:05:06 +00:00
</ SettingsRow >
2026-05-08 15:31:52 +00:00
< SettingsRow
title = { t ( "settings.rows.language" )}
description = { t ( "settings.help.language" )}
>
< LanguageSwitcher />
</ SettingsRow >
</ SettingsGroup >
</ section >
2026-05-19 22:42:38 +08:00
< 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 }))}
2026-05-24 13:38:37 +08:00
ariaLabel = { tx ( "settings.rows.codeWrap" , "Code wrapping" )}
2026-05-19 22:42:38 +08:00
label = { localPrefs . codeWrap ? tx ( "settings.values.on" , "On" ) : tx ( "settings.values.off" , "Off" )}
/>
</ SettingsRow >
2026-05-22 22:25:12 +08:00
< 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 }))}
2026-05-24 13:38:37 +08:00
ariaLabel = { tx ( "settings.rows.brandLogos" , "Brand logos" )}
2026-05-22 22:25:12 +08:00
label = { localPrefs . brandLogos ? tx ( "settings.values.on" , "On" ) : tx ( "settings.values.off" , "Off" )}
/>
</ SettingsRow >
2026-05-19 22:42:38 +08:00
</ SettingsGroup >
</ section >
</ div >
);
}
2026-05-24 13:38:37 +08:00
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" , "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 >
);
}
2026-05-19 22:42:38 +08:00
function ModelsSettings ({
form ,
setForm ,
settings ,
dirty ,
saving ,
2026-05-24 13:38:37 +08:00
showBrandLogos ,
2026-05-19 22:42:38 +08:00
onSave ,
2026-05-24 13:38:37 +08:00
onCreateConfiguration ,
2026-05-19 22:42:38 +08:00
} : {
form : AgentSettingsDraft ;
setForm : Dispatch < SetStateAction < AgentSettingsDraft >>;
settings : SettingsPayload ;
dirty : boolean ;
saving : boolean ;
2026-05-24 13:38:37 +08:00
showBrandLogos : boolean ;
2026-05-19 22:42:38 +08:00
onSave : () => void ;
2026-05-24 13:38:37 +08:00
onCreateConfiguration : () => void ;
2026-05-19 22:42:38 +08:00
}) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
const configuredProviders = settings . providers . filter (( provider ) => provider . configured );
const showAutoProvider = defaultPreset ( settings ) ? . provider === "auto" || form . provider === "auto" ;
const providerOptions = showAutoProvider
? [{ name : "auto" , label : tx ( "settings.values.auto" , "Auto" ) }, ... configuredProviders ]
: configuredProviders ;
const providerValue = providerOptions . some (( provider ) => provider . name === form . provider )
? form . provider
: "" ;
return (
< div className = "space-y-7" >
< section >
2026-05-08 15:31:52 +00:00
< SettingsGroup >
< SettingsRow
2026-05-24 13:38:37 +08:00
title = { tx ( "settings.rows.currentModel" , "Current model" )}
description = { tx ( "settings.help.currentModel" , "Choose the model nanobot uses for new replies." )}
2026-05-08 15:31:52 +00:00
>
2026-05-24 13:38:37 +08:00
< ModelPresetPicker
presets = { settings . model_presets }
value = { form . modelPreset }
settings = { settings }
draftModel = { form . model }
draftProvider = { form . provider }
showProviderLogos = { showBrandLogos }
onChange = {( modelPreset ) => setForm (( prev ) => ({ ... prev , modelPreset }))}
onCreateConfiguration = { onCreateConfiguration }
/>
2026-05-08 15:31:52 +00:00
</ SettingsRow >
2026-05-19 22:42:38 +08:00
{ form . modelPreset === "default" ? (
<>
< SettingsRow
title = { t ( "settings.rows.provider" )}
description = { t ( "settings.help.provider" )}
>
< ProviderPicker
providers = { providerOptions }
value = { providerValue }
emptyLabel = { t ( "settings.byok.noConfiguredProviders" )}
2026-05-24 13:38:37 +08:00
showProviderLogos = { showBrandLogos }
2026-05-19 22:42:38 +08:00
onChange = {( provider ) => setForm (( prev ) => ({ ... prev , provider }))}
/>
</ SettingsRow >
< SettingsRow
title = { t ( "settings.rows.model" )}
description = { t ( "settings.help.model" )}
>
< Input
value = { form . model }
onChange = {( event ) => setForm (( prev ) => ({ ... prev , model : event.target.value }))}
className = "h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
/>
</ SettingsRow >
</>
2026-05-24 13:38:37 +08:00
) : null }
2026-05-19 22:42:38 +08:00
< SettingsFooter
dirty = { dirty }
saving = { saving }
saved = { false }
onSave = { onSave }
/>
2026-04-25 18:05:06 +00:00
</ SettingsGroup >
</ section >
</ div >
);
}
2026-05-19 22:42:38 +08:00
function ProvidersSettings ({
2026-05-08 15:31:52 +00:00
settings ,
expandedProvider ,
providerForms ,
visibleProviderKeys ,
editingProviderKeys ,
providerSaving ,
2026-05-19 22:42:38 +08:00
query ,
2026-05-22 22:25:12 +08:00
showBrandLogos ,
2026-05-19 22:42:38 +08:00
onQueryChange ,
2026-05-08 15:31:52 +00:00
onToggleProvider ,
onToggleProviderKey ,
onToggleProviderKeyEditing ,
onChangeProviderForm ,
onSaveProvider ,
2026-05-09 06:48:26 +00:00
onResetProviderDraft ,
2026-05-19 22:42:38 +08:00
imageProviderRestartPending ,
onRestart ,
isRestarting ,
2026-05-08 15:31:52 +00:00
} : {
settings : SettingsPayload ;
expandedProvider : string | null ;
providerForms : Record < string , { apiKey : string ; apiBase : string }>;
visibleProviderKeys : Record < string , boolean >;
editingProviderKeys : Record < string , boolean >;
providerSaving : string | null ;
2026-05-19 22:42:38 +08:00
query : string ;
2026-05-22 22:25:12 +08:00
showBrandLogos : boolean ;
2026-05-19 22:42:38 +08:00
onQueryChange : ( query : string ) => void ;
2026-05-08 15:31:52 +00:00
onToggleProvider : ( provider : string ) => void ;
onToggleProviderKey : ( provider : string ) => void ;
onToggleProviderKeyEditing : ( provider : string ) => void ;
onChangeProviderForm : ( provider : string , value : Partial < { apiKey : string ; apiBase : string } > ) => void ;
onSaveProvider : ( provider : string ) => void ;
2026-05-09 06:48:26 +00:00
onResetProviderDraft : ( provider : string ) => void ;
2026-05-19 22:42:38 +08:00
imageProviderRestartPending : boolean ;
onRestart ?: () => void ;
isRestarting? : boolean ;
2026-05-08 15:31:52 +00:00
}) {
const { t } = useTranslation ();
2026-05-19 22:42:38 +08:00
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
2026-05-08 15:31:52 +00:00
const configuredProviders = settings . providers . filter (( provider ) => provider . configured );
2026-05-17 17:41:33 +08:00
const unconfiguredProviders = useMemo (
() => orderUnconfiguredProviders ( settings . providers . filter (( provider ) => ! provider . configured )),
[ settings . providers ],
);
2026-05-19 22:42:38 +08:00
const filteredConfigured = filterProviders ( configuredProviders , query );
const filteredUnconfigured = filterProviders ( unconfiguredProviders , query );
2026-05-08 15:31:52 +00:00
const renderProviderRow = ( provider : SettingsPayload [ "providers" ][ number ]) => {
const expanded = expandedProvider === provider . name ;
const form = providerForms [ provider . name ] ?? {
apiKey : "" ,
apiBase : provider.api_base ?? provider . default_api_base ?? "" ,
};
const saving = providerSaving === provider . name ;
const keyVisible = !! visibleProviderKeys [ provider . name ];
const editingKey = ! provider . configured || !! editingProviderKeys [ provider . name ];
2026-05-17 17:41:33 +08:00
const apiKeyRequired = provider . api_key_required ?? true ;
const apiKey = form . apiKey . trim ();
const apiBase = form . apiBase . trim ();
const missingRequiredApiKey = apiKeyRequired && ! provider . configured && ! apiKey ;
const missingOptionalCredential =
! apiKeyRequired && ! provider . configured && ! apiKey && ! apiBase ;
2026-05-08 15:31:52 +00:00
return (
2026-05-19 22:42:38 +08:00
< div key = { provider . name } className = "divide-y divide-border/45" >
2026-05-08 15:31:52 +00:00
< 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" >
2026-05-22 22:25:12 +08:00
< ProviderIcon
provider = { provider . name }
showBrandLogos = { showBrandLogos }
/>
2026-05-08 15:31:52 +00:00
< span className = "min-w-0" >
< span className = "block truncate text-[15px] font-semibold leading-5 text-foreground" >
{ provider . label }
</ span >
2026-05-19 22:42:38 +08:00
< span className = "block truncate text-[12px] text-muted-foreground" >
{ provider . api_base || provider . default_api_base || provider . name }
</ span >
2026-05-08 15:31:52 +00:00
</ span >
</ span >
2026-05-19 22:42:38 +08:00
< StatusPill tone = { provider . configured ? "success" : "neutral" }>
2026-05-08 15:31:52 +00:00
{ provider . configured
? t ( "settings.byok.configured" )
: t ( "settings.byok.notConfigured" )}
2026-05-19 22:42:38 +08:00
</ StatusPill >
2026-05-08 15:31:52 +00:00
</ button >
{ expanded ? (
< div className = "space-y-3 bg-muted/18 px-4 py-4 sm:px-5" >
< label className = "block space-y-1.5" >
< span className = "text-[12px] font-medium text-muted-foreground" >
{ t ( "settings.byok.apiKey" )}
</ span >
< div className = "relative" >
{ editingKey ? (
<>
< Input
type = { keyVisible ? "text" : "password" }
value = { form . apiKey }
onChange = {( event ) =>
onChangeProviderForm ( provider . name , { apiKey : event.target.value })
}
placeholder = {
provider . configured
? t ( "settings.byok.apiKeyConfiguredPlaceholder" )
: t ( "settings.byok.apiKeyPlaceholder" )
}
className = "h-9 rounded-full pr-11 text-[13px]"
/>
< Button
type = "button"
variant = "ghost"
size = "icon"
onClick = {() => onToggleProviderKey ( provider . name )}
aria-label = {
keyVisible
? t ( "settings.byok.hideApiKey" )
: t ( "settings.byok.showApiKey" )
}
className = "absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
{ keyVisible ? (
< EyeOff className = "h-3.5 w-3.5" aria - hidden />
) : (
< Eye className = "h-3.5 w-3.5" aria - hidden />
)}
</ Button >
</>
) : (
<>
< div className = "flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground" >
{ provider . api_key_hint ?? t ( "settings.byok.configuredKeyHint" )}
</ div >
< Button
type = "button"
variant = "ghost"
size = "icon"
onClick = {() => onToggleProviderKeyEditing ( provider . name )}
aria-label = { t ( "settings.actions.edit" )}
className = "absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
< Pencil className = "h-3.5 w-3.5" aria - hidden />
</ Button >
</>
)}
</ div >
</ label >
< label className = "block space-y-1.5" >
< span className = "text-[12px] font-medium text-muted-foreground" >
{ t ( "settings.byok.apiBase" )}
</ span >
< Input
value = { form . apiBase }
onChange = {( event ) =>
onChangeProviderForm ( provider . name , { apiBase : event.target.value })
}
placeholder = { provider . default_api_base ?? t ( "settings.byok.apiBasePlaceholder" )}
className = "h-9 rounded-full text-[13px]"
/>
</ label >
2026-05-19 22:42:38 +08:00
< 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 >
2026-05-08 15:31:52 +00:00
< Button
size = "sm"
variant = "outline"
onClick = {() => onSaveProvider ( provider . name )}
2026-05-17 17:41:33 +08:00
disabled = { saving || missingRequiredApiKey || missingOptionalCredential }
2026-05-08 15:31:52 +00:00
className = "rounded-full"
>
2026-05-24 13:38:37 +08:00
{ saving ? t ( "settings.actions.saving" ) : tx ( "settings.providers.saveProvider" , "Save provider" )}
2026-05-08 15:31:52 +00:00
</ 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 >
2026-05-19 22:42:38 +08:00
{ 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" )}
2026-05-09 06:48:26 +00:00
>
2026-05-19 22:42:38 +08:00
{ 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 >
2026-05-22 22:25:12 +08:00
< ThirdPartyBrandNotice />
2026-05-19 22:42:38 +08:00
</ div >
);
}
function ImageGenerationSettings ({
settings ,
form ,
dirty ,
saving ,
onChangeForm ,
onSave ,
onOpenProviders ,
2026-05-24 13:38:37 +08:00
showBrandLogos ,
2026-05-19 22:42:38 +08:00
onRestart ,
isRestarting ,
requiresRestartPending ,
} : {
settings : SettingsPayload ;
form : ImageGenerationSettingsUpdate ;
dirty : boolean ;
saving : boolean ;
onChangeForm : Dispatch < SetStateAction < ImageGenerationSettingsUpdate >>;
onSave : () => void ;
onOpenProviders : () => void ;
2026-05-24 13:38:37 +08:00
showBrandLogos : boolean ;
2026-05-19 22:42:38 +08:00
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 }))}
2026-05-24 13:38:37 +08:00
ariaLabel = { tx ( "settings.rows.imageGeneration" , "Image generation" )}
2026-05-19 22:42:38 +08:00
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" )}
2026-05-24 13:38:37 +08:00
showProviderLogos = { showBrandLogos }
2026-05-19 22:42:38 +08:00
onChange = {( provider ) => onChangeForm (( prev ) => ({ ... prev , provider }))}
/>
</ SettingsRow >
< SettingsRow
title = { tx ( "settings.rows.imageProviderStatus" , "Provider status" )}
description = { tx ( "settings.help.imageProviderStatus" , "Image generation reuses provider credentials from Providers." )}
>
< div className = "flex flex-wrap items-center justify-end gap-2" >
< StatusPill tone = { providerConfigured ? "success" : "neutral" }>
{ providerConfigured
? tx ( "settings.values.configured" , "Configured" )
: tx ( "settings.values.notConfigured" , "Not configured" )}
</ StatusPill >
{ ! providerConfigured ? (
< Button size = "sm" variant = "outline" onClick = { onOpenProviders } className = "rounded-full" >
{ tx ( "settings.image.configureProvider" , "Configure provider" )}
</ Button >
) : null }
</ div >
</ SettingsRow >
< SettingsRow title = { tx ( "settings.rows.imageProviderBase" , "Provider base" )}>
< span className = "max-w-[320px] truncate text-right text-[13px] text-muted-foreground" >
{ selectedProvider ? . api_base || selectedProvider ? . default_api_base || selectedProvider ? . name || tx ( "settings.values.notAvailable" , "Not available" )}
</ span >
</ SettingsRow >
</ SettingsGroup >
</ section >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.imageDefaults" , "Defaults" )}</ SettingsSectionTitle >
< SettingsGroup >
< SettingsRow
title = { tx ( "settings.rows.imageModel" , "Image model" )}
description = { tx ( "settings.help.imageModel" , "Model name sent to the selected image provider." )}
>
< Input
value = { form . model }
onChange = {( event ) => onChangeForm (( prev ) => ({ ... prev , model : event.target.value }))}
className = "h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
/>
</ SettingsRow >
< SettingsRow
title = { tx ( "settings.rows.defaultAspectRatio" , "Default aspect" )}
description = { tx ( "settings.help.defaultAspectRatio" , "Used when the prompt does not choose an aspect ratio." )}
>
< ProviderPicker
providers = { aspectOptions }
value = { form . defaultAspectRatio }
emptyLabel = { tx ( "settings.image.selectAspect" , "Select aspect" )}
onChange = {( defaultAspectRatio ) =>
onChangeForm (( prev ) => ({ ... prev , defaultAspectRatio }))
}
/>
</ SettingsRow >
< SettingsRow
title = { tx ( "settings.rows.defaultImageSize" , "Default size" )}
description = { tx ( "settings.help.defaultImageSize" , "Size hint sent to providers that support it." )}
>
< ProviderPicker
providers = { sizeOptions }
value = { form . defaultImageSize }
emptyLabel = { tx ( "settings.image.selectSize" , "Select size" )}
onChange = {( defaultImageSize ) =>
onChangeForm (( prev ) => ({ ... prev , defaultImageSize }))
}
/>
</ SettingsRow >
< SettingsRow
title = { tx ( "settings.rows.maxImagesPerTurn" , "Max images per turn" )}
description = { tx ( "settings.help.maxImagesPerTurn" , "Upper bound for one generate_image request." )}
>
< NumberInput
value = { form . maxImagesPerTurn }
min = { 1 }
max = { 8 }
onChange = {( maxImagesPerTurn ) =>
onChangeForm (( prev ) => ({ ... prev , maxImagesPerTurn }))
}
/>
</ SettingsRow >
< ReadOnlyRow title = { tx ( "settings.rows.imageSaveDir" , "Save directory" )} value = { settings . image_generation . save_dir } />
< RestartSettingsFooter
dirty = { dirty }
saving = { saving }
pendingRestart = { requiresRestartPending }
disabled = { missingCredential }
message = {
missingCredential
? tx ( "settings.image.missingCredential" , "Configure this provider before enabling image generation." )
: undefined
}
dirtyMessage = { tx ( "settings.status.restartAfterSaving" , "Save changes, then restart when ready." )}
pendingMessage = { tx ( "settings.status.savedRestartApply" , "Saved. Restart when ready." )}
onSave = { onSave }
onRestart = { onRestart }
isRestarting = { isRestarting }
/>
</ SettingsGroup >
</ section >
</ div >
);
}
function WebSettings ({
settings ,
form ,
keyVisible ,
keyEditing ,
saving ,
onChangeForm ,
onChangeProvider ,
onToggleKey ,
onToggleKeyEditing ,
onReset ,
onSave ,
2026-05-24 13:38:37 +08:00
showBrandLogos ,
2026-05-19 22:42:38 +08:00
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 ;
2026-05-24 13:38:37 +08:00
showBrandLogos : boolean ;
2026-05-19 22:42:38 +08:00
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" )}
2026-05-24 13:38:37 +08:00
showProviderLogos = { showBrandLogos }
2026-05-19 22:42:38 +08:00
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 }))}
2026-05-24 13:38:37 +08:00
ariaLabel = { tx ( "settings.rows.jinaReader" , "Jina reader" )}
2026-05-19 22:42:38 +08:00
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 >
);
}
2026-05-22 22:25:12 +08:00
function CliAppsSettings ({
payload ,
loading ,
query ,
category ,
installFilter ,
actionKey ,
message ,
error ,
focusName ,
showBrandLogos ,
onQueryChange ,
onCategoryChange ,
onInstallFilterChange ,
onAction ,
onBackToChat ,
} : {
payload : CliAppsPayload | null ;
loading : boolean ;
query : string ;
category : string ;
installFilter : "all" | "installed" | "notInstalled" ;
actionKey : string | null ;
message : string | null ;
error : string | null ;
focusName : string | null ;
showBrandLogos : boolean ;
onQueryChange : ( value : string ) => void ;
onCategoryChange : ( value : string ) => void ;
onInstallFilterChange : ( value : "all" | "installed" | "notInstalled" ) => void ;
onAction : ( action : "install" | "update" | "uninstall" | "test" , name : string ) => void ;
onBackToChat : () => void ;
}) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
const apps = payload ? . apps ?? [];
const categories = useMemo (
() => [ "all" , ... Array . from ( new Set ( apps . map (( app ) => app . category ))). sort ()],
[ apps ],
);
const normalizedQuery = query . trim (). toLowerCase ();
const filteredApps = apps . filter (( app ) => {
const categoryMatch = category === "all" || app . category === category ;
if ( ! categoryMatch ) return false ;
if ( installFilter === "installed" && ! app . installed ) return false ;
if ( installFilter === "notInstalled" && app . installed ) return false ;
if ( ! normalizedQuery ) return true ;
return (
app . display_name . toLowerCase (). includes ( normalizedQuery ) ||
app . name . toLowerCase (). includes ( normalizedQuery ) ||
app . description . toLowerCase (). includes ( normalizedQuery ) ||
app . category . toLowerCase (). includes ( normalizedQuery )
);
});
const categoryLabel =
category === "all"
? tx ( "settings.cliApps.allCategories" , "All categories" )
: category ;
const installFilterOptions = [
{ value : "all" , label : tx ( "settings.cliApps.filterAll" , "All" ) },
{ value : "installed" , label : tx ( "settings.cliApps.filterInstalled" , "Installed CLIs" ) },
{ value : "notInstalled" , label : tx ( "settings.cliApps.filterNotInstalled" , "Not installed" ) },
];
const focusedApp = focusName
? apps . find (( app ) => app . name === focusName && app . installed )
: null ;
const visibleStatusMessage = error || ( ! focusedApp ? message : null );
return (
< div className = "space-y-5" >
< section className = "space-y-4" >
< div className = "flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between" >
< div >
< SettingsSectionTitle >{ tx ( "settings.sections.cliApps" , "CLI Apps" )}</ SettingsSectionTitle >
< p className = "mt-1 text-[13px] text-muted-foreground" >
{ tx ( "settings.cliApps.summary" , "{{installed}} of {{total}} CLIs installed" )
. replace ( "{{installed}}" , String ( payload ? . installed_count ?? 0 ))
. replace ( "{{total}}" , String ( apps . length ))}
</ p >
</ div >
< SegmentedControl
value = { installFilter }
options = { installFilterOptions }
onChange = {( value ) => onInstallFilterChange ( value as "all" | "installed" | "notInstalled" )}
/>
</ div >
< div className = "flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" >
< div className = "relative flex-1" >
< 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 ) => onQueryChange ( event . target . value )}
placeholder = { tx ( "settings.cliApps.searchPlaceholder" , "Search CLIs" )}
className = "h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]"
/>
</ div >
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button variant = "outline" size = "sm" className = "h-10 justify-between rounded-full bg-card/80 px-4" >
< span className = "max-w-[180px] truncate" >{ categoryLabel }</ span >
< ChevronDown className = "ml-2 h-3.5 w-3.5" aria - hidden />
</ Button >
</ DropdownMenuTrigger >
< DropdownMenuContent align = "end" className = "max-h-[320px] overflow-y-auto" >
{ categories . map (( item ) => (
< DropdownMenuItem key = { item } onClick = {() => onCategoryChange ( item )}>
{ item === "all" ? tx ( "settings.cliApps.allCategories" , "All categories" ) : item }
</ DropdownMenuItem >
))}
</ DropdownMenuContent >
</ DropdownMenu >
</ div >
</ section >
{ visibleStatusMessage ? (
< div
className = { cn (
"rounded-[10px] border px-3.5 py-2.5 text-[12.5px]" ,
error
? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/55 bg-muted/35 text-muted-foreground" ,
)}
>
{ visibleStatusMessage }
</ div >
) : null }
{ focusedApp ? (
< CliAppReadyPanel
app = { focusedApp }
showBrandLogos = { showBrandLogos }
onBackToChat = { onBackToChat }
/>
) : null }
{ loading ? (
< div className = "flex h-36 items-center justify-center rounded-[8px] border border-border/45 bg-card/82 text-sm text-muted-foreground" >
< Loader2 className = "mr-2 h-4 w-4 animate-spin" aria - hidden />
{ tx ( "settings.cliApps.loading" , "Loading CLI Apps..." )}
</ div >
) : (
< section >
< div className = "grid gap-2" >
{ filteredApps . map (( app ) => (
< CliAppCard
key = { app . name }
app = { app }
actionKey = { actionKey }
showBrandLogos = { showBrandLogos }
onAction = { onAction }
/>
))}
</ div >
{ ! filteredApps . length ? (
< div className = "rounded-[8px] border border-border/45 bg-card/82 px-4 py-8 text-center text-sm text-muted-foreground" >
{ tx ( "settings.cliApps.empty" , "No CLI Apps match this filter." )}
</ div >
) : null }
</ section >
)}
< ThirdPartyBrandNotice />
</ div >
);
}
2026-05-24 13:38:37 +08:00
function McpPresetsSettings ({
payload ,
loading ,
query ,
category ,
installFilter ,
actionKey ,
message ,
error ,
fieldValues ,
customForm ,
configImport ,
showBrandLogos ,
requiresRestartPending ,
onQueryChange ,
onCategoryChange ,
onInstallFilterChange ,
onCustomFormChange ,
onConfigImportChange ,
onFieldChange ,
onAction ,
onSaveCustom ,
onImportConfig ,
onToolsChange ,
onRestart ,
isRestarting ,
} : {
payload : McpPresetsPayload | null ;
loading : boolean ;
query : string ;
category : string ;
installFilter : "all" | "installed" | "notInstalled" ;
actionKey : string | null ;
message : string | null ;
error : string | null ;
fieldValues : Record < string , Record < string , string >>;
customForm : CustomMcpForm ;
configImport : string ;
showBrandLogos : boolean ;
requiresRestartPending : boolean ;
onQueryChange : ( value : string ) => void ;
onCategoryChange : ( value : string ) => void ;
onInstallFilterChange : ( value : "all" | "installed" | "notInstalled" ) => void ;
onCustomFormChange : Dispatch < SetStateAction < CustomMcpForm >>;
onConfigImportChange : ( value : string ) => void ;
onFieldChange : ( presetName : string , fieldName : string , value : string ) => void ;
onAction : ( action : "enable" | "remove" | "test" , name : string , values? : Record < string , string >) => void ;
onSaveCustom : () => void ;
onImportConfig : () => void ;
onToolsChange : ( name : string , enabledTools : string []) => void ;
onRestart ?: () => void ;
isRestarting? : boolean ;
}) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
const presets = payload ? . presets ?? [];
const categories = useMemo (
() => [ "all" , ... Array . from ( new Set ( presets . map (( preset ) => preset . category ))). sort ()],
[ presets ],
);
const normalizedQuery = query . trim (). toLowerCase ();
const filteredPresets = presets . filter (( preset ) => {
const categoryMatch = category === "all" || preset . category === category ;
if ( ! categoryMatch ) return false ;
if ( installFilter === "installed" && ! preset . installed ) return false ;
if ( installFilter === "notInstalled" && preset . installed ) return false ;
if ( ! normalizedQuery ) return true ;
return (
preset . display_name . toLowerCase (). includes ( normalizedQuery ) ||
preset . name . toLowerCase (). includes ( normalizedQuery ) ||
preset . description . toLowerCase (). includes ( normalizedQuery ) ||
preset . category . toLowerCase (). includes ( normalizedQuery )
);
});
const installFilterOptions = [
{ value : "all" , label : tx ( "settings.mcp.filterAll" , "All" ) },
{ value : "installed" , label : tx ( "settings.mcp.filterInstalled" , "Enabled" ) },
{ value : "notInstalled" , label : tx ( "settings.mcp.filterNotInstalled" , "Not enabled" ) },
];
const categoryLabel = category === "all" ? tx ( "settings.mcp.allCategories" , "All categories" ) : category ;
const visibleStatusMessage = error || message ;
const testToolNames = payload ? . last_action ? . tool_names ?? [];
const testToolCount = payload ? . last_action ? . tool_count ;
const showTestDetails = typeof testToolCount === "number" || testToolNames . length > 0 || !! payload ? . last_action ? . error ;
return (
< div className = "space-y-5" >
< section className = "space-y-4" >
< div className = "flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between" >
< div >
< SettingsSectionTitle >{ tx ( "settings.sections.mcp" , "MCP" )}</ SettingsSectionTitle >
< p className = "mt-1 text-[13px] text-muted-foreground" >
{ tx ( "settings.mcp.summary" , "{{installed}} of {{total}} presets enabled" )
. replace ( "{{installed}}" , String ( payload ? . installed_count ?? 0 ))
. replace ( "{{total}}" , String ( presets . length ))}
</ p >
</ div >
< SegmentedControl
value = { installFilter }
options = { installFilterOptions }
onChange = {( value ) => onInstallFilterChange ( value as "all" | "installed" | "notInstalled" )}
/>
</ div >
< div className = "flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" >
< div className = "relative flex-1" >
< 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 ) => onQueryChange ( event . target . value )}
placeholder = { tx ( "settings.mcp.searchPlaceholder" , "Search MCP presets" )}
className = "h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]"
/>
</ div >
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button variant = "outline" size = "sm" className = "h-10 justify-between rounded-full bg-card/80 px-4" >
< span className = "max-w-[180px] truncate" >{ categoryLabel }</ span >
< ChevronDown className = "ml-2 h-3.5 w-3.5" aria - hidden />
</ Button >
</ DropdownMenuTrigger >
< DropdownMenuContent align = "end" className = "max-h-[320px] overflow-y-auto" >
{ categories . map (( item ) => (
< DropdownMenuItem key = { item } onClick = {() => onCategoryChange ( item )}>
{ item === "all" ? tx ( "settings.mcp.allCategories" , "All categories" ) : item }
</ DropdownMenuItem >
))}
</ DropdownMenuContent >
</ DropdownMenu >
</ div >
</ section >
< McpCustomServerPanel
form = { customForm }
configImport = { configImport }
actionKey = { actionKey }
onFormChange = { onCustomFormChange }
onConfigImportChange = { onConfigImportChange }
onSave = { onSaveCustom }
onImportConfig = { onImportConfig }
/>
{ requiresRestartPending ? (
< div className = "flex flex-col gap-3 rounded-[12px] border border-amber-500/20 bg-amber-500/8 px-3.5 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 }
{ visibleStatusMessage ? (
< div
className = { cn (
"rounded-[10px] border px-3.5 py-2.5 text-[12.5px]" ,
error
? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/55 bg-muted/35 text-muted-foreground" ,
)}
>
{ visibleStatusMessage }
</ div >
) : null }
{ showTestDetails ? (
< div className = "rounded-[10px] border border-border/55 bg-card/82 px-3.5 py-3 text-[12px] text-muted-foreground" >
< div className = "flex flex-wrap items-center gap-2" >
{ typeof testToolCount === "number" ? (
< span className = "rounded-full bg-muted px-2 py-0.5 font-medium text-foreground/80" >
{ tx ( "settings.mcp.toolsFound" , "{{count}} tools" ). replace ( "{{count}}" , String ( testToolCount ))}
</ span >
) : null }
{ payload ? . last_action ? . checked_at ? (
< span >{ payload . last_action . checked_at }</ span >
) : null }
</ div >
{ testToolNames . length ? (
< div className = "mt-2 flex flex-wrap gap-1.5" >
{ testToolNames . map (( toolName ) => (
< span key = { toolName } className = "rounded-full bg-muted px-2 py-0.5 font-mono text-[11px] text-foreground/80" >
{ toolName }
</ span >
))}
</ div >
) : null }
{ payload ? . last_action ? . error ? (
< p className = "mt-2 font-mono text-[11px] text-destructive/75" >
{ payload . last_action . error }
</ p >
) : null }
</ div >
) : null }
{ loading ? (
< div className = "flex h-36 items-center justify-center rounded-[8px] border border-border/45 bg-card/82 text-sm text-muted-foreground" >
< Loader2 className = "mr-2 h-4 w-4 animate-spin" aria - hidden />
{ tx ( "settings.mcp.loading" , "Loading MCP presets..." )}
</ div >
) : (
< section >
< div className = "grid gap-2" >
{ filteredPresets . map (( preset ) => (
< McpPresetCard
key = { preset . name }
preset = { preset }
values = { fieldValues [ preset . name ] ?? {}}
actionKey = { actionKey }
showBrandLogos = { showBrandLogos }
onFieldChange = { onFieldChange }
onAction = { onAction }
onToolsChange = { onToolsChange }
/>
))}
</ div >
{ ! filteredPresets . length ? (
< div className = "rounded-[8px] border border-border/45 bg-card/82 px-4 py-8 text-center text-sm text-muted-foreground" >
{ tx ( "settings.mcp.empty" , "No MCP presets match this filter." )}
</ div >
) : null }
</ section >
)}
< ThirdPartyBrandNotice />
</ div >
);
}
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 McpPresetCard ({
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 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 [ setupOpen , setSetupOpen ] = useState ( false );
const missingFields = preset . required_fields . filter (( field ) => field . required && ! field . configured );
const hasFields = preset . required_fields . length > 0 ;
const needsSetupInput = missingFields . length > 0 ;
const showSetup = setupOpen && preset . install_supported && hasFields ;
const readyInstalled = preset . installed && preset . configured ;
const statusLabel = mcpPresetStatusLabel ( preset . status , tx );
const canEnable = preset . install_supported && (
missingFields . length === 0 || missingFields . every (( field ) => Boolean ( values [ field . name ] ? . trim ()))
);
const toolNames = preset . tool_names ?? [];
const enabledTools = preset . enabled_tools ?? [ "*" ];
const allowAllTools = enabledTools . includes ( "*" );
const enabledSet = new Set ( allowAllTools ? toolNames : enabledTools );
const showToolControls = preset . installed && toolNames . length > 0 ;
const setTools = ( next : string []) => onToolsChange ( preset . name , next );
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 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-[8px] border border-border/45 bg-card/82 px-4 py-3 shadow-[0_6px_22px_rgba(15,23,42,0.045)]" >
< div className = "flex min-w-0 items-start gap-3" >
< McpPresetLogo preset = { preset } 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" >
{ preset . display_name }
</ h3 >
< span className = "shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground" >
{ preset . category }
</ span >
< span className = { cn (
"shrink-0 rounded-full px-2 py-0.5 text-[10.5px] font-medium" ,
preset . installed
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground" ,
)}>
{ statusLabel }
</ span >
</ div >
< p className = "mt-1 text-[12px] leading-5 text-muted-foreground" >
{ preset . description }
</ p >
</ div >
< div className = "flex shrink-0 items-center gap-2" >
{ preset . docs_url ? (
< a
className = "inline-flex h-8 items-center rounded-full px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
href = { preset . docs_url }
target = "_blank"
rel = "noreferrer"
aria-label = { tx ( "settings.mcp.openDocs" , "Open docs" )}
>
< Info className = "h-3.5 w-3.5" aria - hidden />
</ a >
) : null }
{ readyInstalled ? (
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button
type = "button"
size = "sm"
variant = "outline"
disabled = { busy }
className = "h-8 rounded-full border-emerald-500/20 bg-emerald-500/10 px-3 text-[12px] font-semibold text-emerald-700 hover:bg-emerald-500/12 dark:text-emerald-300"
>
{ busy ? < 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.enabled" , "Enabled" )}
< ChevronDown className = "ml-1.5 h-3 w-3" aria - hidden />
</ Button >
</ 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 >
< 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 >
) : preset . installed && ! preset . configured ? (
< Button
type = "button"
size = "sm"
variant = "outline"
disabled = { busy }
onClick = {() => {
if ( hasFields ) setSetupOpen ( true );
else onAction ( "enable" , preset . name , values );
}}
className = "h-8 rounded-full border-amber-500/25 bg-amber-500/8 px-3 text-[12px] font-semibold text-amber-700 hover:bg-amber-500/12 dark:text-amber-300"
>
{ enableBusy ? < Loader2 className = "mr-1.5 h-3.5 w-3.5 animate-spin" aria - hidden /> : null }
{ hasFields ? tx ( "settings.mcp.configure" , "Configure" ) : tx ( "settings.mcp.enable" , "Enable" )}
</ Button >
) : preset . install_supported ? (
< Button
type = "button"
size = "sm"
variant = "outline"
disabled = { busy }
onClick = { enableOrOpenSetup }
className = "h-8 rounded-full px-4 text-[12px] font-semibold"
>
{ enableBusy ? < Loader2 className = "mr-1.5 h-3.5 w-3.5 animate-spin" aria - hidden /> : null }
{ needsSetupInput ? tx ( "settings.mcp.setup" , "Set up" ) : tx ( "settings.mcp.enable" , "Enable" )}
</ Button >
) : (
< Button
type = "button"
size = "sm"
variant = "outline"
disabled
className = "h-8 rounded-full px-3 text-[12px] font-semibold"
>
{ tx ( "settings.mcp.comingSoon" , "Coming soon" )}
</ Button >
)}
</ div >
</ div >
{ showSetup ? (
< div className = "mt-3 rounded-[12px] border border-border/45 bg-muted/18 p-3" >
< div className = "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between" >
< div >
< div className = "text-[12px] font-semibold text-foreground" >
{ tx ( "settings.mcp.connectTitle" , "Connect {{name}}" ). replace ( "{{name}}" , preset . display_name )}
</ div >
< p className = "mt-0.5 text-[11.5px] leading-4 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 sm:grid-cols-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 }
{ showToolControls ? (
< div className = "mt-3 border-t border-border/35 pt-3" >
< div className = "flex flex-wrap items-center justify-between gap-2" >
< div className = "flex items-center gap-2 text-[11.5px] font-medium text-muted-foreground" >
{ toolsBusy ? < Loader2 className = "h-3.5 w-3.5 animate-spin" aria - hidden /> : < SlidersHorizontal className = "h-3.5 w-3.5" aria - hidden />}
{ 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-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-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 >
) : preset . installed && ! testBusy ? (
< div className = "mt-3 border-t border-border/35 pt-3 text-[11.5px] text-muted-foreground" >
{ tx ( "settings.mcp.testForTools" , "Run Test to inspect and choose individual tools." )}
</ div >
) : null }
</ article >
);
}
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 >
);
}
2026-05-22 22:25:12 +08:00
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 CliAppCard ({
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 ;
return (
< article className = "flex min-w-0 items-center gap-3 rounded-[8px] border border-border/45 bg-card/82 px-4 py-3 shadow-[0_6px_22px_rgba(15,23,42,0.045)]" >
< 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 >
< span className = "shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground" >
{ app . category }
</ span >
</ div >
< div className = "mt-0.5 truncate text-[12px] text-muted-foreground" >
{ app . entry_point || app . name }
</ div >
< p className = "mt-1 truncate text-[12px] leading-5 text-muted-foreground" >
{ app . requires
? ` ${ tx ( "settings.cliApps.requires" , "Requires" ) } : ${ app . requires } `
: app . description || tx ( "settings.cliApps.noDescription" , "No description available." )}
</ p >
</ div >
< div className = "shrink-0" >
{ app . installed ? (
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button
type = "button"
size = "sm"
variant = "outline"
disabled = { busy }
className = "h-8 rounded-full border-emerald-500/20 bg-emerald-500/10 px-3 text-[12px] font-semibold text-emerald-700 hover:bg-emerald-500/12 dark:text-emerald-300"
>
{ busy ? < 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.cliApps.statusInstalled" , "CLI installed" )}
< ChevronDown className = "ml-1.5 h-3 w-3" aria - hidden />
</ Button >
</ 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 >
) : app . install_supported ? (
< Button
type = "button"
size = "sm"
variant = "outline"
disabled = { busy }
onClick = {() => onAction ( "install" , app . name )}
className = "h-8 rounded-full px-4 text-[12px] font-semibold"
>
{ installBusy ? < Loader2 className = "mr-1.5 h-3.5 w-3.5 animate-spin" aria - hidden /> : null }
{ tx ( "settings.cliApps.install" , "Install CLI" )}
</ Button >
) : (
< Button
type = "button"
size = "sm"
variant = "outline"
disabled
className = "h-8 rounded-full px-3 text-[12px] font-semibold"
>
{ tx ( "settings.cliApps.unavailable" , "Unavailable" )}
</ Button >
)}
</ div >
</ article >
);
}
function CliAppLogo ({ app , showBrandLogos } : { app : CliAppInfo ; showBrandLogos : boolean }) {
2026-05-24 13:38:37 +08:00
const [ logoIndex , setLogoIndex ] = useState ( 0 );
2026-05-22 22:25:12 +08:00
const bg = app . brand_color || "hsl(var(--muted))" ;
2026-05-24 13:38:37 +08:00
const logoUrls = useMemo (() => logoFallbackUrls ( app . logo_url ), [ app . logo_url ]);
const logoUrl = logoUrls [ logoIndex ];
2026-05-22 22:25:12 +08:00
const initials = app . display_name
. split ( /\s+/ )
. filter ( Boolean )
. slice ( 0 , 2 )
. map (( part ) => part [ 0 ] ? . toUpperCase ())
. join ( "" ) || app . name . slice ( 0 , 2 ). toUpperCase ();
2026-05-24 13:38:37 +08:00
useEffect (() => setLogoIndex ( 0 ), [ app . logo_url ]);
if ( showBrandLogos && logoUrl ) {
2026-05-22 22:25:12 +08:00
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
2026-05-24 13:38:37 +08:00
src = { logoUrl }
2026-05-22 22:25:12 +08:00
alt = ""
className = "h-6 w-6 object-contain"
2026-05-24 13:38:37 +08:00
onError = {() => setLogoIndex (( index ) => index + 1 )}
2026-05-22 22:25:12 +08:00
/>
</ 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 >
);
}
2026-05-19 22:42:38 +08:00
function RuntimeSettings ({
form ,
setForm ,
settings ,
dirty ,
saving ,
onSave ,
onRestart ,
isRestarting ,
requiresRestartPending ,
} : {
form : AgentSettingsDraft ;
setForm : Dispatch < SetStateAction < AgentSettingsDraft >>;
settings : SettingsPayload ;
dirty : boolean ;
saving : boolean ;
onSave : () => void ;
onRestart ?: () => void ;
isRestarting? : boolean ;
requiresRestartPending : boolean ;
}) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
return (
< div className = "space-y-7" >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.identity" , "Identity" )}</ SettingsSectionTitle >
< SettingsGroup >
< SettingsRow title = { tx ( "settings.rows.botName" , "Bot name" )} description = { tx ( "settings.help.botName" , "Shown in runtime surfaces that use the configured bot identity." )}>
< Input
value = { form . botName }
onChange = {( event ) => setForm (( prev ) => ({ ... prev , botName : event.target.value }))}
className = "h-8 w-[220px] rounded-full text-[13px]"
/>
</ SettingsRow >
< SettingsRow title = { tx ( "settings.rows.botIcon" , "Bot icon" )} description = { tx ( "settings.help.botIcon" , "Short emoji or text shown beside the bot name." )}>
< Input
value = { form . botIcon }
onChange = {( event ) => setForm (( prev ) => ({ ... prev , botIcon : event.target.value }))}
className = "h-8 w-[120px] rounded-full text-center text-[13px]"
/>
</ SettingsRow >
< SettingsRow title = { tx ( "settings.rows.timezone" , "Timezone" )} description = { tx ( "settings.help.timezone" , "IANA timezone used by runtime context and schedules." )}>
2026-05-24 13:38:37 +08:00
< TimezonePicker
2026-05-19 22:42:38 +08:00
value = { form . timezone }
2026-05-24 13:38:37 +08:00
onChange = {( timezone ) => setForm (( prev ) => ({ ... prev , timezone }))}
2026-05-19 22:42:38 +08:00
/>
</ SettingsRow >
< RestartSettingsFooter
dirty = { dirty }
saving = { saving }
pendingRestart = { requiresRestartPending }
dirtyMessage = { tx ( "settings.status.restartAfterSaving" , "Save changes, then restart when ready." )}
pendingMessage = { tx ( "settings.status.savedRestartApply" , "Saved. Restart when ready." )}
onSave = { onSave }
onRestart = { onRestart }
isRestarting = { isRestarting }
/>
</ SettingsGroup >
</ section >
< section >
< SettingsSectionTitle >{ t ( "settings.sections.system" )}</ SettingsSectionTitle >
< SettingsGroup >
{ onRestart && ! requiresRestartPending ? (
< SettingsRow
title = { t ( "settings.rows.restart" )}
description = { t ( "app.system.restartHint" )}
>
< Button
size = "sm"
variant = "outline"
onClick = { onRestart }
disabled = { isRestarting }
className = "rounded-full"
>
{ isRestarting ? (
< Loader2 className = "mr-1.5 h-3.5 w-3.5 animate-spin" aria - hidden />
) : (
< RotateCcw className = "mr-1.5 h-3.5 w-3.5" aria - hidden />
)}
{ isRestarting ? t ( "app.system.restarting" ) : t ( "app.system.restart" )}
</ Button >
</ SettingsRow >
) : null }
< ReadOnlyRow title = { t ( "settings.rows.configPath" )} value = { settings . runtime . config_path } />
< ReadOnlyRow title = { tx ( "settings.rows.workspacePath" , "Workspace path" )} value = { settings . runtime . workspace_path } />
< ReadOnlyRow title = { tx ( "settings.rows.heartbeat" , "Heartbeat" )} value = { settings . runtime . heartbeat . enabled ? ` ${ settings . runtime . heartbeat . interval_s } s` : tx ( "settings.values.disabled" , "Disabled" )} />
< ReadOnlyRow title = { tx ( "settings.rows.dream" , "Dream" )} value = { settings . runtime . dream . schedule } />
< ReadOnlyRow title = { tx ( "settings.rows.unifiedSession" , "Unified session" )} value = { settings . runtime . unified_session ? tx ( "settings.values.enabled" , "Enabled" ) : tx ( "settings.values.disabled" , "Disabled" )} />
</ SettingsGroup >
</ section >
</ div >
);
}
function AdvancedSettings ({ settings } : { settings : SettingsPayload }) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
return (
< div className = "space-y-7" >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.safety" , "Safety" )}</ SettingsSectionTitle >
< SettingsGroup >
< ReadOnlyRow title = { tx ( "settings.rows.restrictWorkspace" , "Restrict to workspace" )} value = { settings . advanced . restrict_to_workspace ? tx ( "settings.values.enabled" , "Enabled" ) : tx ( "settings.values.disabled" , "Disabled" )} />
< ReadOnlyRow title = { tx ( "settings.rows.execTool" , "Exec tool" )} value = { settings . advanced . exec_enabled ? tx ( "settings.values.enabled" , "Enabled" ) : tx ( "settings.values.disabled" , "Disabled" )} />
< ReadOnlyRow title = { tx ( "settings.rows.execSandbox" , "Exec sandbox" )} value = { settings . advanced . exec_sandbox ?? tx ( "settings.values.notAvailable" , "Not available" )} />
< ReadOnlyRow title = { tx ( "settings.rows.ssrfWhitelist" , "SSRF whitelist" )} value = { String ( settings . advanced . ssrf_whitelist_count )} />
</ SettingsGroup >
</ section >
< section >
< SettingsSectionTitle >{ tx ( "settings.sections.integrations" , "Integrations" )}</ SettingsSectionTitle >
< SettingsGroup >
< ReadOnlyRow title = { tx ( "settings.rows.mcpServers" , "MCP servers" )} value = { String ( settings . advanced . mcp_server_count )} />
< ReadOnlyRow title = { tx ( "settings.rows.pathAppend" , "PATH append" )} value = { settings . advanced . exec_path_append_set ? tx ( "settings.values.configured" , "Configured" ) : tx ( "settings.values.notConfigured" , "Not configured" )} />
< SettingsRow
title = { tx ( "settings.rows.configurationDocs" , "Configuration docs" )}
description = { tx ( "settings.help.advancedReadOnly" , "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed." )}
>
< a
className = "inline-flex h-8 items-center rounded-full border border-input bg-background px-3 text-[13px] font-medium text-foreground shadow-sm transition-colors hover:bg-muted"
href = "https://github.com/HKUDS/nanobot/blob/main/docs/configuration.md"
target = "_blank"
rel = "noreferrer"
>
< Info className = "mr-1.5 h-3.5 w-3.5" aria - hidden />
{ tx ( "settings.actions.openDocs" , "Open docs" )}
</ a >
</ SettingsRow >
</ SettingsGroup >
</ section >
</ div >
);
}
2026-05-24 13:38:37 +08:00
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)] rounded-[18px] border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]"
>
< 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" 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 >
);
}
2026-05-19 22:42:38 +08:00
function ProviderPicker ({
providers ,
value ,
emptyLabel ,
2026-05-24 13:38:37 +08:00
showProviderLogos = false ,
2026-05-19 22:42:38 +08:00
onChange ,
} : {
providers : Array < { name : string ; label : string } > ;
value : string ;
emptyLabel : string ;
2026-05-24 13:38:37 +08:00
showProviderLogos? : boolean ;
2026-05-19 22:42:38 +08:00
onChange : ( provider : string ) => void ;
}) {
const selectedProvider = providers . find (( provider ) => provider . name === value ) ?? null ;
const disabled = providers . length === 0 ;
return (
< DropdownMenu >
< DropdownMenuTrigger asChild disabled = { disabled }>
< Button
type = "button"
variant = "outline"
disabled = { disabled }
className = { cn (
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none" ,
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring" ,
disabled && "text-muted-foreground" ,
)}
>
2026-05-24 13:38:37 +08:00
< 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 >
2026-05-19 22:42:38 +08:00
< ChevronDown className = "ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria - hidden />
</ Button >
</ DropdownMenuTrigger >
< DropdownMenuContent
align = "end"
className = "max-h-[18rem] w-[240px] overflow-y-auto rounded-[18px] border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]"
>
{ providers . map (( provider ) => {
const selected = provider . name === value ;
2026-05-09 06:48:26 +00:00
return (
2026-05-19 22:42:38 +08:00
< DropdownMenuItem
key = { provider . name }
onSelect = {() => onChange ( provider . name )}
2026-05-09 06:48:26 +00:00
className = { cn (
2026-05-24 13:38:37 +08:00
"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" ,
2026-05-09 06:48:26 +00:00
)}
2026-05-08 15:31:52 +00:00
>
2026-05-24 13:38:37 +08:00
< 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 >
2026-05-19 22:42:38 +08:00
{ selected ? < Check className = "h-3.5 w-3.5 shrink-0" aria - hidden /> : null }
</ DropdownMenuItem >
2026-05-09 06:48:26 +00:00
);
})}
2026-05-19 22:42:38 +08:00
</ DropdownMenuContent >
</ DropdownMenu >
);
}
2026-05-09 06:48:26 +00:00
2026-05-24 13:38:37 +08:00
function ProviderPickerIcon ({
provider ,
showBrandLogos ,
} : {
provider : string ;
showBrandLogos : boolean ;
}) {
const [ logoIndex , setLogoIndex ] = useState ( 0 );
const brand = providerBrand ( provider );
const Icon = PROVIDER_ICONS [ provider ] ?? Sparkles ;
const logoUrl = brand ? . logoUrls [ logoIndex ];
useEffect (() => setLogoIndex ( 0 ), [ provider ]);
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 >
);
}
2026-05-19 22:42:38 +08:00
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 >
2026-05-08 15:31:52 +00:00
);
}
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 >
);
}
2026-05-22 22:25:12 +08:00
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 >
);
}
2026-05-17 17:41:33 +08:00
function orderUnconfiguredProviders (
providers : SettingsPayload [ "providers" ],
) : SettingsPayload [ "providers" ] {
return providers
. map (( provider , index ) => ({ provider , index }))
. sort (( left , right ) => {
const rank = providerVisibilityRank ( left . provider ) - providerVisibilityRank ( right . provider );
return rank || left . index - right . index ;
})
. map (({ provider }) => provider );
}
function providerVisibilityRank ( provider : SettingsPayload [ "providers" ][ number ]) : number {
const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER . get ( provider . name );
if ( localRank !== undefined ) return localRank ;
if (( provider . api_key_required ?? true ) === false ) return 100 ;
return 200 ;
}
2026-05-19 22:42:38 +08:00
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 ),
);
}
2026-05-24 13:38:37 +08:00
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" ;
}
}
2026-05-19 22:42:38 +08:00
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 ];
}
2026-05-24 13:38:37 +08:00
function modelPresetProviderKey (
preset : SettingsPayload [ "model_presets" ][ number ],
settings : SettingsPayload ,
options : { draftProvider? : string } = {},
2026-05-19 22:42:38 +08:00
) : string {
2026-05-24 13:38:37 +08:00
const provider = options . draftProvider ?? preset . provider ;
if ( provider === "auto" ) {
return settings . agent . resolved_provider || settings . agent . provider || preset . provider ;
}
return provider ;
2026-05-22 22:25:12 +08:00
}
2026-05-08 15:31:52 +00:00
const PROVIDER_ICONS : Record < string , LucideIcon > = {
custom : Hexagon ,
openrouter : Sparkles ,
2026-05-20 00:04:39 +08:00
skywork : Sparkles ,
2026-05-08 15:31:52 +00:00
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 ,
2026-05-18 21:12:22 +08:00
ant_ling : Sparkles ,
2026-05-08 15:31:52 +00:00
azure_openai : Cloud ,
bedrock : Database ,
2026-05-24 13:38:37 +08:00
brave : Search ,
duckduckgo : Search ,
exa : Search ,
jina : Search ,
kagi : Search ,
olostep : Search ,
searxng : Search ,
tavily : Search ,
2026-05-17 17:41:33 +08:00
vllm : Cpu ,
ollama : Cpu ,
lm_studio : Cpu ,
atomic_chat : Cpu ,
ovms : Cpu ,
nvidia : Zap ,
2026-05-08 15:31:52 +00:00
};
2026-05-22 22:25:12 +08:00
function ProviderIcon ({
provider ,
showBrandLogos ,
} : {
provider : string ;
showBrandLogos : boolean ;
}) {
2026-05-24 13:38:37 +08:00
const [ logoIndex , setLogoIndex ] = useState ( 0 );
2026-05-22 22:25:12 +08:00
const brand = providerBrand ( provider );
2026-05-08 15:31:52 +00:00
const Icon = PROVIDER_ICONS [ provider ] ?? Hexagon ;
2026-05-24 13:38:37 +08:00
const logoUrl = brand ? . logoUrls [ logoIndex ];
useEffect (() => setLogoIndex ( 0 ), [ provider ]);
if ( showBrandLogos && logoUrl ) {
2026-05-22 22:25:12 +08:00
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
2026-05-24 13:38:37 +08:00
src = { logoUrl }
2026-05-22 22:25:12 +08:00
alt = ""
className = "h-6 w-6 object-contain"
2026-05-24 13:38:37 +08:00
onError = {() => setLogoIndex (( index ) => index + 1 )}
2026-05-22 22:25:12 +08:00
/>
</ 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 >
);
}
2026-05-08 15:31:52 +00:00
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 >
);
}
2026-05-24 13:38:37 +08:00
function NanobotBrandLogo ({
size = "sm" ,
testId ,
} : {
size ?: "sm" | "lg" ;
testId? : string ;
}) {
return (
< span
data-testid = { testId }
className = { cn (
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]" ,
size === "lg" ? "h-12 w-12 rounded-[16px]" : "h-9 w-9 rounded-[12px]" ,
)}
aria - hidden
>
< img
src = { NANOBOT_ICON_SRC }
alt = ""
className = { cn ( "select-none object-contain" , size === "lg" ? "h-10 w-10" : "h-7 w-7" )}
draggable = { false }
/>
</ 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 >
);
}
2026-05-19 22:42:38 +08:00
function OverviewListRow ({
icon : Icon ,
2026-05-24 13:38:37 +08:00
valueLogoProvider ,
2026-05-19 22:42:38 +08:00
title ,
value ,
caption ,
2026-05-24 13:38:37 +08:00
showBrandLogos = false ,
2026-05-19 22:42:38 +08:00
onClick ,
} : {
icon : LucideIcon ;
2026-05-24 13:38:37 +08:00
valueLogoProvider? : string | null ;
2026-05-19 22:42:38 +08:00
title : string ;
value : string ;
caption : string ;
2026-05-24 13:38:37 +08:00
showBrandLogos? : boolean ;
2026-05-19 22:42:38 +08:00
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"
>
2026-05-24 13:38:37 +08:00
< OverviewRowIcon icon = { Icon } />
2026-05-19 22:42:38 +08:00
< 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" >
2026-05-24 13:38:37 +08:00
< OverviewValueLogo provider = { valueLogoProvider } showBrandLogos = { showBrandLogos } />
2026-05-19 22:42:38 +08:00
< 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 >
);
}
2026-05-08 15:31:52 +00:00
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 >
2026-04-25 18:05:06 +00:00
</ div >
);
}
function SettingsRow ({
title ,
2026-05-08 15:31:52 +00:00
description ,
2026-04-25 18:05:06 +00:00
children ,
} : {
title : string ;
2026-05-08 15:31:52 +00:00
description? : string ;
children? : ReactNode ;
2026-04-25 18:05:06 +00:00
}) {
return (
2026-05-08 15:31:52 +00:00
< 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" >
2026-04-25 18:05:06 +00:00
< div className = "min-w-0" >
2026-05-08 15:31:52 +00:00
< 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 }
2026-04-25 18:05:06 +00:00
</ div >
{ children ? < div className = "shrink-0 sm:ml-6" >{ children }</ div > : null }
</ div >
);
}
2026-05-19 22:42:38 +08:00
function ReadOnlyRow ({ title , value } : { title : string ; value : string }) {
return (
< SettingsRow title = { title }>
< span className = "block max-w-[320px] truncate text-right text-[13px] text-muted-foreground" >
{ value }
</ span >
</ SettingsRow >
);
}
2026-05-24 13:38:37 +08:00
function ModelPresetPicker ({
presets ,
value ,
settings ,
draftModel ,
draftProvider ,
showProviderLogos ,
onChange ,
onCreateConfiguration ,
} : {
presets : SettingsPayload [ "model_presets" ];
value : string ;
settings : SettingsPayload ;
draftModel : string ;
draftProvider : string ;
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 >
< DropdownMenuTrigger asChild disabled = { ! presets . length }>
< Button
type = "button"
variant = "outline"
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 }
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 rounded-[18px] border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]"
>
{ 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 = { onCreateConfiguration }
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 ,
showProviderLogos ,
compact = false ,
} : {
preset : SettingsPayload [ "model_presets" ][ number ];
settings : SettingsPayload ;
draftModel : string ;
draftProvider : string ;
showProviderLogos : boolean ;
compact? : boolean ;
}) {
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 );
return (
< span className = "flex min-w-0 items-center gap-2.5" >
< ProviderPickerIcon provider = { provider } showBrandLogos = { showProviderLogos } />
< span className = "min-w-0 text-left leading-tight" >
< span className = "block truncate font-medium text-foreground" >{ model || preset . label }</ span >
< span
className = { cn (
"mt-0.5 block truncate text-muted-foreground" ,
compact ? "text-[11.5px]" : "text-[12px]" ,
)}
>
{ providerName }
{ preset . label ? ` · ${ preset . label } ` : "" }
</ span >
</ span >
</ span >
);
}
2026-05-19 22:42:38 +08:00
function RestartSettingsFooter ({
dirty ,
saving ,
pendingRestart ,
disabled = false ,
message ,
dirtyMessage ,
pendingMessage ,
onSave ,
onRestart ,
onReset ,
isRestarting ,
} : {
dirty : boolean ;
saving : boolean ;
pendingRestart : boolean ;
disabled? : boolean ;
message? : string ;
dirtyMessage? : string ;
pendingMessage? : string ;
onSave : () => void ;
onRestart ?: () => void ;
onReset ?: () => void ;
isRestarting? : boolean ;
}) {
const { t } = useTranslation ();
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
const statusMessage =
message ??
( pendingRestart && ! dirty
? pendingMessage ?? tx ( "settings.status.savedRestartApply" , "Saved. Restart when ready." )
: dirty
? dirtyMessage ?? t ( "settings.status.unsaved" )
: undefined );
const statusTone = disabled ? "danger" : dirty || pendingRestart ? "accent" : undefined ;
return (
< div className = "flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5" >
< div className = "min-w-0 text-[13px] leading-5 text-muted-foreground" >
< SettingsStatusMessage tone = { statusTone }>{ statusMessage }</ SettingsStatusMessage >
</ div >
< div className = "flex w-full shrink-0 flex-wrap justify-end gap-2 sm:w-auto" >
{ pendingRestart && ! dirty && onRestart ? (
< Button
size = "sm"
variant = "ghost"
onClick = { onRestart }
disabled = { isRestarting }
className = "rounded-full"
>
{ isRestarting ? (
< Loader2 className = "mr-1.5 h-3.5 w-3.5 animate-spin" aria - hidden />
) : (
< RotateCcw className = "mr-1.5 h-3.5 w-3.5" aria - hidden />
)}
{ isRestarting ? t ( "app.system.restarting" ) : t ( "app.system.restart" )}
</ Button >
) : null }
{ onReset ? (
< Button
size = "sm"
variant = "ghost"
onClick = { onReset }
disabled = { ! dirty || saving }
className = "rounded-full"
>
{ t ( "settings.actions.cancel" )}
</ Button >
) : null }
< Button
size = "sm"
variant = "outline"
onClick = { onSave }
disabled = { ! dirty || disabled || saving }
className = "rounded-full"
>
{ saving ? t ( "settings.actions.saving" ) : t ( "settings.actions.save" )}
</ Button >
</ div >
</ div >
);
}
2026-04-25 18:05:06 +00:00
function SettingsFooter ({
dirty ,
saving ,
saved ,
onSave ,
} : {
dirty : boolean ;
saving : boolean ;
saved : boolean ;
onSave : () => void ;
}) {
2026-05-08 15:31:52 +00:00
const { t } = useTranslation ();
2026-05-19 22:42:38 +08:00
const tx = ( key : string , fallback : string ) => t ( key , { defaultValue : fallback });
const statusMessage = dirty
? t ( "settings.status.unsaved" )
: saved
? t ( "settings.status.savedRestart" )
: tx ( "settings.status.upToDate" , "Up to date." );
2026-04-25 18:05:06 +00:00
return (
2026-05-19 22:42:38 +08:00
< 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" >
2026-05-08 15:31:52 +00:00
< div className = "text-[13px] text-muted-foreground" >
2026-05-19 22:42:38 +08:00
< SettingsStatusMessage tone = { dirty || saved ? "accent" : undefined }>
{ statusMessage }
</ SettingsStatusMessage >
</ div >
< div className = "flex justify-end" >
< Button size = "sm" variant = "outline" onClick = { onSave } disabled = { ! dirty || saving } className = "rounded-full" >
{ saving ? t ( "settings.actions.saving" ) : t ( "settings.actions.save" )}
</ Button >
2026-04-25 18:05:06 +00:00
</ div >
2026-05-19 22:42:38 +08:00
</ 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 ,
2026-05-24 13:38:37 +08:00
ariaLabel ,
2026-05-19 22:42:38 +08:00
label ,
} : {
checked : boolean ;
onChange : ( checked : boolean ) => void ;
2026-05-24 13:38:37 +08:00
ariaLabel? : string ;
2026-05-19 22:42:38 +08:00
label : string ;
}) {
return (
< button
type = "button"
2026-05-24 13:38:37 +08:00
role = "switch"
aria-checked = { checked }
aria-label = { ariaLabel ?? label }
2026-05-19 22:42:38 +08:00
onClick = {() => onChange ( ! checked )}
className = { cn (
2026-05-24 13:38:37 +08:00
"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" ,
2026-05-19 22:42:38 +08:00
checked
2026-05-24 13:38:37 +08:00
? "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" ,
2026-05-19 22:42:38 +08:00
)}
>
2026-05-24 13:38:37 +08:00
< 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 >
2026-05-19 22:42:38 +08:00
</ 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 }
2026-04-25 18:05:06 +00:00
</ div >
);
}